Compare commits

...

149 Commits

Author SHA1 Message Date
Egor c7ce80e882 Merge pull request #2643 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.18.0
2026-02-24 06:38:48 +03:00
github-actions[bot] 83e04a2e93 chore(main): release 3.18.0 2026-02-24 03:38:23 +00:00
Egor 351ebcf9eb Merge pull request #2642 from BEDOLAGA-DEV/dev
Dev
2026-02-24 06:37:51 +03:00
Fringg e5fa45f74f fix: correct broadcast button deep-links for cabinet mode
- promocode button now opens /balance instead of /subscription
- add menu_promocode to CALLBACK_TO_CABINET_PATH and style mappings
2026-02-24 06:33:56 +03:00
Fringg 25f014fd89 feat: add ChatTypeFilterMiddleware to ignore group/forum messages
Drop all messages and callback queries from non-private chats
(groups, supergroups with forum topics, channels) before they
reach any handler or heavy middleware (DB, throttle, blacklist).

- Registered after ContextVarsMiddleware, before GlobalErrorMiddleware
- chat_member events intentionally excluded (needed for channel tracking)
- pre_checkout_query excluded (no chat context, always private)
- Uses ChatType.PRIVATE enum for type safety
- Debug logging on dropped events for observability
2026-02-24 06:19:03 +03:00
Fringg 6f473defef fix: restore RemnaWave config management endpoints
The previous refactoring accidentally deleted RemnaWave API routes
(/remnawave/status, /uuid, /config, /configs) along with the legacy
file-based CRUD routes. Restore only the RemnaWave endpoints that
the cabinet frontend depends on.
2026-02-24 06:02:36 +03:00
Fringg 59fb08c3ea style: format 5 files with ruff 2026-02-24 05:59:08 +03:00
Fringg 295d2e877e refactor: remove legacy app-config.json system
Replace dual-configuration architecture (Remnawave API + local file fallback)
with Remnawave-only approach. When config is unavailable, show explicit
"not configured" message instead of silent file fallback.

- Delete app-config.json and admin_apps.py CRUD module (~1260 lines)
- Remove sync loaders, legacy step format handlers, device_mapping dicts
- Remove miniapp /app-config.json endpoint and filesystem search
- Remove backup service app-config.json snapshot/restore
- Remove APP_CONFIG_PATH setting, env var, docker volume mount
- Remove hardcoded 6-device keyboard fallback
- Remove legacy step-based keyboard rendering (installationStep etc.)
- Add "config not configured" message when Remnawave config is missing
- Update admin UI: "clear config" disables guide mode instead of reverting
2026-02-24 05:58:25 +03:00
Fringg 711ec344c6 fix: HTML-escape all externally-sourced text in guide messages
- Escape app names, device names, and other_app_names in
  handle_device_guide, handle_app_selection, handle_specific_app_guide
- Redact internal paths and exception details from cabinet API
  error responses in _load_config, _save_config, and Remnawave
  fetch endpoints
2026-02-24 05:27:59 +03:00
Fringg 978726a785 fix: invalidate app config cache on local file saves
_save_config() in admin_apps.py now calls invalidate_app_config_cache()
after writing app-config.json, so changes via cabinet API are immediately
visible in guide mode without waiting for TTL expiry.
2026-02-24 05:26:27 +03:00
Fringg 6a50013c21 fix: callback routing safety and cache invalidation order
- Add explicit negative filter for app_ vs app_list_ callback routing
  to prevent fragile registration-order dependency
- Reorder invalidate_app_config_cache to set timestamp to 0 first,
  ensuring fast-path check fails immediately without lock
- Add debug logging to _get_remnawave_config_uuid fallback path
2026-02-24 05:26:02 +03:00
Fringg 1bb939f63a fix: pre-existing bugs found during review
- Fix NameError: texts used before assignment in handle_single_device_reset
  (crash on malformed callback_data)
- HTML-escape subscription_link in all <code> tag interpolations
  (3 locations in devices.py)
2026-02-24 05:24:55 +03:00
Fringg 6feec1eaa8 fix: address security review findings
- Replace format_map with regex-based placeholder substitution to
  prevent format string injection via attribute traversal (CRITICAL)
- Add UUID format validation in select_remna_config handler
- Redact exception details from user-facing callback answers
- HTML-escape current_uuid in admin config menu
- HTML-escape title/description in format_additional_section
2026-02-24 05:19:57 +03:00
Fringg fae6f71def fix: address code review issues in guide mode rework
- Add fallback else branch for subscriptionLink in blocks format
  (prevents silent button drop when deep link resolution fails)
- Extract render_guide_blocks() helper to eliminate duplicated
  block-rendering logic between handle_device_guide and
  handle_specific_app_guide
- Add HTML escaping for admin-controlled config text in guide blocks
- Remove unused get_localized_value import from devices.py
2026-02-24 05:18:25 +03:00
Fringg 5a269b249e feat: rework guide mode with Remnawave API integration
- Add async Remnawave config loader with TTL cache and asyncio.Lock
- Normalize both legacy (steps) and Remnawave (blocks) formats to unified structure
- Build dynamic platform selection keyboard from config instead of hardcoded 6-device layout
- Add colored buttons via Bot API 9.4 (green for connect, blue for download)
- Add admin panel handler for selecting Remnawave subscription page config
- Add cache invalidation from both bot admin and cabinet API
- Fix callback data parsing for app IDs with underscores
- Add Linux platform support across all device mappings
2026-02-24 05:16:18 +03:00
Fringg 0b3b2e5dc5 feat: colored channel subscription buttons via Bot API 9.4 style
- Subscribed channels shown as green (style=success) with checkmark
- Unsubscribed channels shown as blue (style=primary)
- Clicking "I subscribed" now updates keyboard with colored status
  instead of just showing error alert
- Extracted _normalize_channels helper for DRY
2026-02-24 03:58:11 +03:00
Fringg 314c892c4d style: format monitoring_service.py 2026-02-24 03:30:00 +03:00
Fringg 1bc9074c1b fix: translate required channels handler to Russian, add localization keys
- All bot handler strings translated from English to Russian
- Back button now correctly navigates to admin_submenu_settings
- Added ADMIN_SETTINGS_REQUIRED_CHANNELS key to all 5 locales
2026-02-24 03:22:39 +03:00
Fringg 3af07ff627 feat: add required channels button to admin settings submenu in bot 2026-02-24 03:18:21 +03:00
Fringg 2aead9a68b fix: improve deduplication log message wording in monitoring service 2026-02-24 03:16:03 +03:00
Fringg a7db469fd7 fix: remove @username channel ID input, auto-prefix -100 for bare digits
@username resolution via bot.get_chat() was unreliable for subscription
checking. Now only numeric channel IDs are accepted with automatic -100
prefix when entering bare digits (e.g. 1234567890 -> -1001234567890).
2026-02-24 03:06:57 +03:00
Fringg a47ef67090 fix: add missing CHANNEL_CHECK_NOT_SUBSCRIBED localization key
Added to all 5 locales (en, ru, fa, ua, zh) to fix runtime warning
when user clicks subscription check button in middleware.
2026-02-24 03:00:08 +03:00
Fringg 8375d7ecc5 feat: add multi-channel mandatory subscription system
- Multi-channel subscription enforcement via middleware, events, and cabinet API
- 3-layer cache architecture: Redis -> PostgreSQL -> rate-limited Telegram API
- ChatMemberUpdated event-driven tracking with automatic VPN access control
- Admin management via bot FSM handler and REST API with full CRUD
- Channel ID normalization: @username resolved to numeric ID at creation time
- Fail-closed error handling: API errors deny access (security-first)
- Background reconciliation with keyset pagination (100 per batch)
- Per-user rate limiting on subscription check button (5s cooldown)
- Redis connection pooling via cache singleton (no per-request connections)
- Database: channel_id index, multi-row upsert optimization
- Localization: en, ru, zh, fa, ua translations for all new strings
- Frontend blocking UI with channel list and subscription status
- Admin channel management page with toggle, delete, and create
2026-02-24 02:50:31 +03:00
Egor 751e312f28 Merge pull request #2641 from BEDOLAGA-DEV/main
dev
2026-02-23 23:39:09 +03:00
Egor 4eaaf06a17 Merge pull request #2640 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.17.1
2026-02-23 21:33:25 +03:00
github-actions[bot] 1930a9dcde chore(main): release 3.17.1 2026-02-23 18:33:00 +00:00
Egor b876c6dd0b Merge pull request #2639 from BEDOLAGA-DEV/dev
Dev
2026-02-23 21:32:13 +03:00
Fringg d15b69710c style: ruff format 2026-02-23 21:29:54 +03:00
Fringg 708bb9eec7 fix: migrate all remaining naive timestamp columns to timestamptz
Old universal_migration.py created some tables (including email_templates)
with `timestamp` (naive) columns and had a catch-all that converted all
naive columns to `timestamptz` on each startup. After switching to Alembic,
that catch-all stopped running.

Users whose email_templates table was created by universal_migration.py
before the catch-all ran still have naive `timestamp` columns. The code
uses `datetime.now(UTC)` (timezone-aware), causing asyncpg to raise:
  "can't subtract offset-naive and offset-aware datetimes"

Migration 0007 finds and converts ALL remaining naive timestamp columns
in public schema to timestamptz, assuming UTC for existing data.

Fixes: email template save returning 503 with DataError
2026-02-23 21:26:16 +03:00
Fringg 97b3f899d1 fix: add diagnostic logging for device_limit sync to RemnaWave
Users report tariff change doesn't update device count and device
purchase doesn't sync to panel. Added structured logging to trace:
- resolve_hwid_device_limit: forced limit vs subscription limit
- PATCH /api/users: payload hwidDeviceLimit vs response value
2026-02-23 19:45:00 +03:00
Fringg 5ee45f97d1 fix: show negative amounts for withdrawals in admin transaction list
Admin endpoints returned amount_kopeks as always-positive from DB,
causing withdrawals and subscription payments to display as credits
in the admin panel. User-facing balance.py already handled this correctly.
2026-02-23 19:12:51 +03:00
Fringg d4c4a8a211 fix: add missing broadcast_history columns and harden subscription logic
- Add migration 0006 for blocked_count, channel, email_subject,
  email_html_content columns missing from broadcast_history table
- Fix infinite trial reactivation loop in monitoring service
- Prevent webhook from overwriting freshly extended end_date
- Use tariff-specific pricing for auto-renewal instead of global config
2026-02-23 19:07:59 +03:00
Fringg 205c8d987d fix: use aiogram 3.x bot.download() instead of document.download() 2026-02-23 18:31:31 +03:00
Fringg ebe508302b fix: uploaded backup restore button not triggering handler
Callback data prefix was 'backup_restore_uploaded_' but the handler
listens for 'backup_restore_execute_' and 'backup_restore_clear_'.
2026-02-23 18:29:10 +03:00
Fringg c20355b06d fix: repair missing DB columns and make backup resilient to schema mismatches
- Add migration 0005 to re-apply missing columns from 0002-0004
  (fixes DBs that were auto-stamped to head without running migrations)
- Add per-table error handling in backup ORM export so one table
  failure doesn't break the entire backup
- Escape HTML in error notifications to prevent Telegram parse errors
2026-02-23 18:22:32 +03:00
Fringg 50a931ec36 fix: add int32 overflow guards and strengthen auth validation
- Add le= bounds to all user-facing Pydantic int fields (balance, subscription, traffic, devices)
- Add self-referral guard in process_referral_registration
- Add Telegram identity cross-validation to get_optional_cabinet_user
- Log when initData validation fails but header is present
2026-02-23 18:12:58 +03:00
Fringg 115c0c84c0 fix: prevent partner self-referral via own campaign link
When a partner clicks their own campaign link (any bonus_type), they get
attributed as their own referral — their purchases counted as campaign
revenue and they earn referral commissions on their own payments.

Add self-referral guards in three layers:
- auth.py: early return in _process_campaign_bonus if user is campaign partner
- campaign_service.py: defense-in-depth check in apply_campaign_bonus
- start.py: guards on all referrer_id assignments and process_referral calls
2026-02-23 18:02:25 +03:00
Fringg 973b3d3d3f fix: cross-validate Telegram identity on every authenticated request
Telegram Mini App WebView shares localStorage across accounts on the
same device. This allows refresh tokens from user A to be reused by
user B if they open the same Mini App.

Add server-side defense: read X-Telegram-Init-Data header (already sent
by the frontend), validate it cryptographically, and reject requests
where the Telegram user ID doesn't match the JWT user's telegram_id.
2026-02-23 17:53:44 +03:00
Fringg 2ef6185715 fix: cap expected_monthly_referrals to prevent int32 overflow
Add le=2_000_000_000 constraint to Pydantic schema so PostgreSQL Integer
column doesn't receive values outside int32 range.
2026-02-23 17:27:33 +03:00
Fringg ed4624c664 fix: handle RemnaWave API errors in traffic aggregation
Catch exceptions from get_all_nodes() in _aggregate_traffic() to prevent
unhandled ASGI errors when RemnaWave returns HTTP 502. Cache empty result
on failure to avoid request storms from parallel frontend calls.
2026-02-23 17:25:01 +03:00
Fringg 1b6bbc7131 fix: protect active paid subscriptions from being disabled in RemnaWave
Add is_active_paid_subscription() helper that checks if subscription is
non-trial, active, and not expired. Use it across all disable_remnawave_user
call sites to prevent disabling VPN access for users with paid subscriptions.

Protected paths: block_user, delete_user_account, broadcast cleanup,
channel unsubscribe, admin deactivation, webapi endpoints, cabinet
reset-trial, reset-subscription, and disable-user endpoints.
2026-02-23 16:49:31 +03:00
Fringg 1f4430f3af fix: suppress web page preview when logo mode is disabled
When ENABLE_LOGO_MODE is on, messages are sent as photos which
naturally don't show URL previews. When off, messages are sent as
text but disable_web_page_preview was never set, causing link
previews in menu, welcome, and other messages.

Always patch Message.answer/edit_text and inject
disable_web_page_preview=True for all text message paths.
2026-02-23 15:55:53 +03:00
Fringg 67f3547ae2 fix: allow tariff switch when less than 1 day remains
Check subscription.end_date <= now instead of remaining_days == 0 to
allow switching when hours remain. The .days property truncates to whole
days, blocking users with a few hours left from switching tariffs.
2026-02-23 15:49:08 +03:00
Egor 49f64cacd7 Merge pull request #2634 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.17.0
2026-02-19 02:14:24 +03:00
github-actions[bot] 9101c98244 chore(main): release 3.17.0 2026-02-18 23:14:04 +00:00
Egor 311f278123 Merge pull request #2633 from BEDOLAGA-DEV/dev
Dev
2026-02-19 02:13:31 +03:00
Fringg 493f315a65 fix: skip blocked users in trial notifications and broadcasts without DB status change
- Add User.status filter to trial notification SQL queries
- Add pre-send blocked/deleted user check in _send_message_with_logo
- Fix UserStatus import shadowing (alias RemnaWaveUserStatus)
- Remove broadcast cleanup that marked users as BLOCKED in DB
- Remove dead _background_tasks variable
2026-02-19 02:08:39 +03:00
Fringg 18c2477173 feat: add referral code tracking to all cabinet auth methods + email_templates migration
Referral links from cabinet (?ref=CODE) were only tracked for email registration.
Now referral_code is accepted and processed in Telegram initData, Telegram Widget,
and OAuth authentication endpoints. Includes self-referral protection by email
for OAuth, proper error logging, and the missing email_templates table migration.
2026-02-18 23:59:29 +03:00
Fringg 6e28a1a22b fix: prevent 'caption is too long' error in logo mode
Telegram limits photo captions to 1024 characters. When menu_text or
rules_text exceeds 900 chars (with promo hints, random messages etc),
bot.send_photo fails with TelegramBadRequest.

Added len() check before each of 3 send_photo calls in
required_sub_channel_check — falls back to send_message when text
is too long, consistent with _answer_with_photo in message_patch.py.
2026-02-18 18:26:26 +03:00
Egor be00256618 Merge pull request #2631 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.16.3
2026-02-18 15:01:49 +03:00
github-actions[bot] 7f693f2b58 chore(main): release 3.16.3 2026-02-18 12:00:19 +00:00
Egor c3bf0dc0fd Merge pull request #2630 from BEDOLAGA-DEV/dev
Dev
2026-02-18 14:59:51 +03:00
Fringg d651a6c02f fix: eliminate deadlock by matching lock order with webhook
Deadlock: DELETE locks server_squads first, then subscriptions.
Webhook locks subscriptions first, then server_squads. Classic deadlock.

Fix: remove duplicate decrement block (was decrementing server_squads
twice), restructure subscription block to delete subscription FIRST
then decrement server_squads — matching webhook's lock acquisition order.
2026-02-18 12:24:08 +03:00
Fringg d7039d75a4 fix: connected_squads stores UUIDs, not int IDs — use get_server_ids_by_uuids
connected_squads JSON contains squad UUIDs like 'b4d782fa-...', not
integer IDs. int() cast fails on these. Now resolves UUIDs to integer
IDs via get_server_ids_by_uuids() before passing to remove_user_from_servers.
2026-02-18 12:17:27 +03:00
Fringg 6409b0c023 fix: auth middleware catches all commit errors, not just connection errors
When a handler swallows a DB error (e.g. ProgrammingError for missing
column), the transaction is aborted but the handler returns normally.
The auth middleware then tries db.commit() which fails with DBAPIError.

Now catches any exception on commit and does rollback, preventing the
cascade of "current transaction is aborted" errors through all
subsequent middleware layers.
2026-02-18 12:01:40 +03:00
Fringg af31c551d2 fix: 3 user deletion bugs — type cast, inner savepoint, lazy load
1. connected_squads JSON stores IDs as strings but server_squads.id is
   integer — cast to int before passing to remove_user_from_servers
2. Wrap remove_user_from_servers in its own db.begin_nested() so its
   failure doesn't abort the parent savepoint (subscription deletion)
3. Pre-fetch admin.id before delete_user_account to avoid MissingGreenlet
   when transaction rollback expires the ORM object
2026-02-18 11:59:25 +03:00
Fringg a38dfcb75a fix: wrap user deletion steps in savepoints to prevent transaction cascade abort
When one deletion step fails (e.g. missing campaign_id column in referral_earnings),
PostgreSQL aborts the entire transaction. All subsequent operations then fail with
"current transaction is aborted, commands ignored until end of transaction block".

Each of the 24 try/except blocks now uses `async with db.begin_nested():`
(PostgreSQL SAVEPOINT) so individual failures are isolated and rolled back
without poisoning the outer transaction.
2026-02-18 11:48:37 +03:00
Fringg b7b83abb72 fix: deadlock on user deletion + robust migration 0002
Decrement server_squads.current_users BEFORE deleting subscription
to match lock ordering with webhook handler, preventing deadlocks.

Also made migration 0002 robust with table existence checks to
prevent failures on DBs missing referral_earnings or
advertising_campaign_registrations tables.
2026-02-18 11:34:07 +03:00
Fringg f076269c32 fix: make migration 0002 robust with table existence checks
Migration was failing on DBs where referral_earnings or
advertising_campaign_registrations tables didn't exist yet,
causing campaign_id column to never be added. Added _has_table
and _has_column guards, wrapped backfill in existence check.
2026-02-18 11:30:38 +03:00
Egor 8d16935c1c Merge pull request #2629 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.16.2
2026-02-18 11:18:08 +03:00
github-actions[bot] 49d8de76a2 chore(main): release 3.16.2 2026-02-18 08:17:02 +00:00
Egor b4d8cabbd8 Merge pull request #2628 from BEDOLAGA-DEV/dev
Dev
2026-02-18 11:16:34 +03:00
Fringg a7f3d652c5 fix: use AwareDateTime TypeDecorator for all datetime columns
TypeDecorator with process_result_value guarantees naive datetimes
from pre-TIMESTAMPTZ databases are converted to UTC-aware on every
load. Replaces unreliable event listener approach. All 175 DateTime
columns now use AwareDateTime.
2026-02-18 11:11:58 +03:00
Fringg 38f3a9a16a fix: handle naive datetime in raw SQL row comparison (payment/common) 2026-02-18 11:02:09 +03:00
Fringg f7d33a7d2b fix: auto-convert naive datetimes to UTC-aware on model load
SQLAlchemy event listener on Base ensures all DateTime columns are
timezone-aware after loading from DB. Fixes TypeError crashes in
50+ comparison sites across handlers, services, and middlewares
for pre-TIMESTAMPTZ databases.
2026-02-18 11:01:04 +03:00
Fringg bd11801467 fix: extend naive datetime guard to all model properties
Move _aware() to module level and apply to 4 more models:
- PromoCode.is_valid (valid_from, valid_until)
- TrafficPurchase.is_expired (expires_at)
- CabinetRefreshToken.is_expired (expires_at)
- Ticket.is_user_reply_blocked (user_reply_block_until)
2026-02-18 10:44:13 +03:00
Fringg e512e5fe6e fix: handle naive datetimes in Subscription properties
Databases that haven't run the TIMESTAMPTZ migration return naive
datetimes from end_date. Comparing with datetime.now(UTC) raises
TypeError. Added _aware() helper to normalize naive→aware in
is_active, is_expired, should_be_expired, actual_status, days_left,
time_left_display, and extend_subscription.
2026-02-18 10:36:46 +03:00
Egor 799c83dd84 Merge pull request #2627 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.16.1
2026-02-18 10:29:59 +03:00
github-actions[bot] 4cc18cbc9a chore(main): release 3.16.1 2026-02-18 07:29:30 +00:00
Egor 4645be53cb Merge pull request #2626 from BEDOLAGA-DEV/dev
fix: add migration for partner system tables and columns
2026-02-18 10:29:04 +03:00
Fringg 79ea398d1d fix: add migration for partner system tables and columns
Existing databases stamped at 0001 (create_all checkfirst=True) are
missing new columns/tables from the partner system:
- users.partner_status
- broadcast_history.blocked_count
- advertising_campaigns.partner_user_id
- withdrawal_requests table
- partner_applications table

All checks are idempotent — safe for fresh and existing databases.
2026-02-18 10:26:07 +03:00
Egor 30b1402b54 Merge pull request #2625 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.16.0
2026-02-18 09:57:20 +03:00
github-actions[bot] 15d848c1ca chore(main): release 3.16.0 2026-02-18 06:56:54 +00:00
Egor c9877a3cbe Merge pull request #2624 from BEDOLAGA-DEV/dev
Dev
2026-02-18 09:56:08 +03:00
Fringg 68499ee043 chore: ruff format 2026-02-18 09:51:56 +03:00
Fringg bdb61613de fix: add missing payment providers to payment_utils and fix {total_amount} formatting
- Add freekassa, cloudpayments, kassa_ai to get_available_payment_methods(),
  is_payment_method_available(), get_payment_method_status(), and
  get_enabled_payment_methods_count()
- Fix cart reminder message showing literal {total_amount} in platega,
  stars, mulenpay, wata by adding .format() call
2026-02-18 09:50:36 +03:00
Fringg 59383bdbd8 feat: expose traffic_reset_mode in subscription response 2026-02-18 09:41:33 +03:00
Fringg 5d4a94b8ce feat: expose traffic_reset_mode in tariff API response 2026-02-18 09:36:36 +03:00
Fringg 0c07812ecc feat: add campaign_id to ReferralEarning for campaign attribution
Adds nullable FK campaign_id to referral_earnings table, enabling
direct campaign ROI analytics without JOINing through registrations.

- Model: campaign_id column + AdvertisingCampaign relationship
- CRUD: get_user_campaign_id() helper, campaign_id param in create_referral_earning
- Service: resolve campaign_id in all earning creation paths
- Cabinet API: campaign_name in earnings response
- Migration 0002: add column + deterministic backfill via DISTINCT ON
2026-02-18 09:12:01 +03:00
Fringg eb9dba3f47 fix: add selectinload for subscription in campaign user list
Prevents MissingGreenlet error when accessing user.subscription
in the admin campaign users filter view.
2026-02-18 08:42:53 +03:00
Fringg 6c4e035146 fix: correct subscription_service import in broadcast cleanup
Import SubscriptionService class and instantiate locally, matching
the pattern used throughout the codebase.
2026-02-18 08:39:50 +03:00
Fringg e78b1040a5 fix: prevent fileConfig from destroying structlog handlers
Only apply alembic.ini logging config when root logger has no handlers
(CLI mode). When running programmatically, structlog is already configured
and fileConfig would replace its handlers, breaking all logging.
2026-02-18 08:25:38 +03:00
Egor b6c7f91a7c Merge pull request #2623 from BEDOLAGA-DEV/refactor/alembic-migration
refactor: replace universal_migration.py with Alembic
2026-02-18 08:13:56 +03:00
Fringg e998059d81 style: format admin_campaigns, admin_partners, referral_withdrawal_service 2026-02-18 08:13:04 +03:00
Fringg 764e063bfe style: apply ruff formatting 2026-02-18 08:11:33 +03:00
Fringg 784616b349 refactor: replace universal_migration.py with Alembic
Remove the 7,791-line universal_migration.py and 16 incomplete individual
Alembic migrations. Replace with a single initial schema migration using
Base.metadata.create_all(checkfirst=True).

Changes:
- Add programmatic Alembic runner (app/database/migrations.py) with
  auto-stamp logic for existing databases transitioning from
  universal_migration
- Extract ensure_default_web_api_token() to web_api_token_service.py
- Extract sync_postgres_sequences() to database.py with SQL injection
  prevention via _quote_ident()
- Add HMAC token hashing support with backward-compatible dual-hash
  fallback and automatic rehashing
- Remove dead init_db() function and unused imports
- Add Makefile targets: migrate, migration, migrate-stamp, migrate-history
- Fix fileConfig() destroying structlog config (disable_existing_loggers)
- Remove duplicate migrations/alembic/alembic.ini with credentials
- Add script.py.mako template for future migration generation
- Update startup flow: alembic upgrade → sync sequences → ensure token
- Harden database.py: ParamSpec for retry decorator, safe URL logging,
  echo='debug' mode, execute_with_retry validation
- Update documentation references

31 files changed, 302 insertions(+), 9,226 deletions(-)
2026-02-18 08:10:20 +03:00
Fringg b4b10c998c fix: add blocked_count column migration to universal_migration.py
The column existed in the SQLAlchemy model and Alembic migration but was
missing from universal_migration.py which is used for auto-migrations on
startup, causing "column broadcast_history.blocked_count does not exist"
error in the broadcasts admin page.
2026-02-18 06:57:03 +03:00
Fringg 366df18c54 feat: enforce 1-to-1 partner-campaign binding with partner info in campaigns
- Add partner_user_id/partner_name to campaign list and detail responses
- Add partner_user_id to campaign create/update schemas
- Add GET /available-partners endpoint for partner dropdown
- Atomic assign with UPDATE...WHERE to prevent race conditions
- Validate partner exists and is approved in create/update
- Set updated_at on assign/unassign operations
- Eager-load partner relationship in campaign queries
2026-02-18 06:47:02 +03:00
Fringg 7883efc3d6 fix: return zeroed stats dict when withdrawal is disabled
can_request_withdrawal returned empty dict {} when withdrawal feature
was disabled, causing KeyError on 'total_earned' in withdrawal route.
2026-02-18 05:37:32 +03:00
Fringg 6881d97bbb feat: add admin partner settings API (withdrawal toggle, requisites text, partner visibility)
- GET/PATCH /admin/partners/settings endpoints with .env persistence
- New config: REFERRAL_WITHDRAWAL_REQUISITES_TEXT, REFERRAL_PARTNER_SECTION_VISIBLE
- Serve requisites_text in withdrawal balance and partner_section_visible in referral terms
- Sanitize newlines in requisites_text before .env write to prevent injection
2026-02-18 04:12:15 +03:00
Fringg 90278f1f5f style: fix ruff formatting in broadcast_service and tests 2026-02-17 18:50:25 +03:00
Fringg df5b1a072d fix: handle YooKassa NotFoundError gracefully in get_payment_info
Catch NotFoundError (404) separately from generic exceptions.
Old/expired payments return 404 from YooKassa API — this is expected
and should be logged as WARNING without traceback, not ERROR.
2026-02-17 18:46:32 +03:00
Fringg 10e231e52e feat: blocked user detection during broadcasts, filter blocked from all notifications
- Broadcast tri-state return: 'sent'/'blocked'/'failed' with blocked_count tracking
- Background cleanup: mark blocked users + disable their subscriptions + Remnawave
- blocked_count in BroadcastHistory model, schemas, API responses, admin UI
- Filter User.status==ACTIVE in subscription queries: get_expiring_subscriptions,
  get_expired_subscriptions, get_subscriptions_for_autopay,
  get_daily_subscriptions_for_charge, get_disabled_daily_subscriptions_for_resume
- Guard in notification_delivery_service.send_notification for BLOCKED/DELETED users
- Fix subscription tariff switch: preserve remaining days with total_seconds()
- Fix redundant local UTC imports across 16 files
- Fix test mocks: add **kwargs, correct assertion, remove dead expression
2026-02-17 18:37:25 +03:00
Fringg 7c20fde4e8 fix: medium-priority fixes for partner system
- replace unsafe referral code generator with unique DB-checked version
- remove dead code in get_global_partner_stats
- validate status filter params with Literal types in admin routes
2026-02-17 12:42:40 +03:00
Fringg fcf3a2c806 fix: resolve HIGH-priority performance and security issues in partner system
- fix N+1 query in money laundering analysis with GROUP BY batch query
- fix N+1 query in cabinet referral earnings with batch user fetch
- eliminate double balance stats computation in withdrawal flow
- replace in-memory referral counting with SQL COUNT/CASE aggregation
- fix HTML injection in admin Telegram notifications via html.escape()
- standardize return types for reject/complete withdrawal methods
2026-02-17 12:38:25 +03:00
Fringg 88997492c3 fix: critical security and data integrity fixes for partner system
- Add SELECT FOR UPDATE locking on all financial state transitions
  (withdrawal approve/reject/complete/create, partner approve/reject)
- Add html.escape() on all user-controlled values in email templates
- Wrap sync SMTP send_email in asyncio.to_thread to avoid blocking event loop
- Add missing database indexes on referral_earnings(user_id, referral_id),
  users(referred_by_id, partner_status), withdrawal_requests(user_id, status),
  advertising_campaigns(partner_user_id)
2026-02-17 12:28:30 +03:00
Fringg 327d4f4d15 feat: notify users on partner/withdrawal approve/reject
4 notification types via NotificationDeliveryService:
- Partner application approved/rejected
- Withdrawal request approved/rejected

Telegram + email + WebSocket routing handled automatically.
Email templates in ru/en/zh/ua.
2026-02-17 12:04:23 +03:00
Fringg cf7cc5a84e feat: add admin notifications for partner applications and withdrawals
Send notifications to admin chat when a partner application is submitted
or a withdrawal request is created, following existing notification pattern.
2026-02-17 11:48:38 +03:00
Fringg 28f524b762 fix: campaign web link uses ?campaign= param, not ?start=
The cabinet frontend captures ?campaign= from URL (campaign.ts utility),
not ?start=. Fixed the partner-facing link from /login?start= to /?campaign=.
2026-02-17 11:36:40 +03:00
Fringg c4dc43e054 feat: link campaign registrations to partner for referral earnings
Two separate fixes for bot and cabinet auth paths:

Bot (start.py): store referrer_id from campaign.partner_user_id in FSM
state, skip referral code prompt when partner already set.

Cabinet (auth.py): in _process_campaign_bonus, set user.referred_by_id
to campaign.partner_user_id and call process_referral_registration.

Both paths now correctly attribute campaign users to the partner,
enabling commission earnings from their future purchases.
2026-02-17 11:33:31 +03:00
Fringg 767e965028 feat: attribute campaign registrations to partner for referral earnings
When a user registers through a campaign link that has partner_user_id,
store that partner as referrer_id in FSM state. This connects the
campaign system to the referral earning system — the partner now earns
commissions from all purchases made by users who came through their
campaign links.

Changes in all registration paths:
- cmd_start: store referrer_id from campaign.partner_user_id
- language/rules/privacy handlers: skip referral code prompt when
  referrer_id already set from campaign
- channel check: pick up referrer_id from state instead of hardcoding None
2026-02-17 11:22:38 +03:00
Fringg d39063b22f fix: unassign all campaigns when revoking partner status
Previously revoke_partner only changed partner_status and commission,
leaving campaigns orphaned with invalid partner_user_id. Now sets
partner_user_id=NULL on all campaigns belonging to the revoked partner.
2026-02-17 11:11:25 +03:00
Fringg ea5d932476 feat: include partner campaigns in /partner/status response
Return assigned active campaigns with bonus info, deep_link and
web_link so the partner's referral page can display shareable links.
2026-02-17 10:45:11 +03:00
Fringg acc1323a54 fix: move PartnerStatus enum before User class to fix NameError
PartnerStatus was defined after the User class that references it,
causing a NameError on startup.
2026-02-17 09:56:11 +03:00
Fringg 58bfaeaddb feat: add partner system and withdrawal management to cabinet
- Partner application flow: user applies, admin reviews/approves/rejects
- Individual commission % per partner with admin management
- Campaign assignment/unassignment to partners
- Withdrawal system: balance check, create request, cancel
- Admin withdrawal management with risk scoring and fraud analysis
- Database migration: partner_applications table, user partner fields, campaign partner_user_id
- Pydantic schemas with proper validation bounds
- Batch user fetching to prevent N+1 queries
- Row locking on cancel to prevent race conditions
2026-02-17 09:51:36 +03:00
Fringg df5415f30b fix: reorder button_click_logs migration to nullify before ALTER TYPE
ALTER COLUMN user_id TYPE INTEGER failed with "integer out of range"
because the column contained telegram_id values (BIGINT) exceeding
INTEGER max. Swapped order: SET NULL first, then ALTER TYPE.
2026-02-17 08:19:21 +03:00
Egor 330d670f3f Merge pull request #2621 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.15.1
2026-02-17 07:55:52 +03:00
github-actions[bot] 41cb122a2f chore(main): release 3.15.1 2026-02-17 04:54:40 +00:00
Egor 1b3e6f2f11 Merge pull request #2620 from BEDOLAGA-DEV/dev
fix: add naive datetime guards to fromisoformat() in Redis cache readers
2026-02-17 07:54:20 +03:00
Fringg 6fa49485d9 fix: add naive datetime guards to fromisoformat() in Redis cache readers
Old Redis entries saved before utcnow→now(UTC) migration lack timezone
info, causing TypeError on subtraction with aware datetimes.
2026-02-17 07:52:26 +03:00
Egor 71aa023133 Merge pull request #2619 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.15.0
2026-02-17 07:25:32 +03:00
github-actions[bot] e567c02658 chore(main): release 3.15.0 2026-02-17 04:01:31 +00:00
Egor f393dc0840 Merge pull request #2618 from BEDOLAGA-DEV/dev
Dev
2026-02-17 07:01:07 +03:00
Fringg 5dc4b0ec15 chore: ruff format oauth.py, auth schemas, admin_notification_service 2026-02-17 06:57:30 +03:00
Fringg e68760cc66 fix: remove local UTC re-imports shadowing module-level import in purchase.py
Caused UnboundLocalError on datetime.now(UTC) at line 209 because
Python treats the function-local `from datetime import UTC` (lines 351, 362)
as a local variable declaration, making UTC unbound before those lines.
2026-02-17 06:45:46 +03:00
Fringg d9552799c1 feat: add web campaign links with bonus processing in auth flow
- Add web_link generation for campaigns (uses MINIAPP_CUSTOM_URL)
- Process campaign_slug in all auth endpoints (telegram, widget, email, oauth)
- Apply campaign bonus (balance/subscription/tariff) with SELECT FOR UPDATE lock
- Add rollback + user refresh on campaign bonus failure
- Fix N+1 query in campaign registrations (batch subscription check)
- Remove duplicate queries in get_campaign_statistics (~60 lines dead code)
- Simplify _store_refresh_token (remove TOCTOU pre-check, keep IntegrityError)
- Remove dead expression in campaign_service.py
- Align start_parameter max_length to 64 (matches DB column)
- Remove unused campaign_slug from EmailRegisterStandaloneRequest
2026-02-17 06:44:03 +03:00
Fringg c75ec0b22a fix: AttributeError in withdrawal admin notification (send_to_admins → send_admin_notification) 2026-02-17 05:23:49 +03:00
Fringg 27309f53d9 feat: add LOG_COLORS env setting to toggle console ANSI colors 2026-02-17 05:15:03 +03:00
Egor 4193f717ee Merge pull request #2617 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.14.1
2026-02-17 05:04:03 +03:00
github-actions[bot] d297985b0b chore(main): release 3.14.1 2026-02-17 02:03:34 +00:00
Egor 6dcf3a9f0d Merge pull request #2616 from BEDOLAGA-DEV/dev
Dev
2026-02-17 05:03:06 +03:00
Fringg 094609005a fix: add naive datetime guards to parsers and fix test datetime literals 2026-02-17 05:00:13 +03:00
Fringg eb18994b7d fix: complete datetime.utcnow() → datetime.now(UTC) migration
- Migrate 660+ datetime.utcnow() across 153 files to datetime.now(UTC)
- Migrate 30+ datetime.now() without UTC to datetime.now(UTC)
- Convert all 170 DateTime columns to DateTime(timezone=True)
- Add migrate_datetime_to_timestamptz() in universal_migration with SET LOCAL timezone='UTC' safety
- Remove 70+ .replace(tzinfo=None) workarounds
- Fix utcfromtimestamp → fromtimestamp(..., tz=UTC)
- Fix fromtimestamp() without tz= (system_logs, backup_service, referral_diagnostics)
- Fix fromisoformat/isoparse to ensure aware output (platega, yookassa, wata, miniapp, nalogo)
- Fix strptime() to add .replace(tzinfo=UTC) (backup_service, referral_diagnostics)
- Fix datetime.combine() to include tzinfo=UTC (remnawave_sync, traffic_monitoring)
- Fix datetime.max/datetime.min sentinels with .replace(tzinfo=UTC)
- Rename panel_datetime_to_naive_utc → panel_datetime_to_utc
- Remove DTZ003 from ruff ignore list
2026-02-17 04:45:40 +03:00
Fringg ff21b27b98 fix: address remaining abs() issues from review
- admin_traffic._get_bulk_spending: add func.abs() for SUBSCRIPTION_PAYMENT SUM
- get_user_total_spent_kopeks: move abs() from Python to SQL (per-row func.abs)
- referral_contest.total_outside: add abs() for mixed-type sum
- Revert func.abs() from generic by_type aggregation to preserve refund/withdrawal signs
2026-02-17 03:47:39 +03:00
Fringg 4247981c98 fix: normalize transaction amount signs across all aggregations
SUBSCRIPTION_PAYMENT transactions have inconsistent signs in DB
(some negative, some positive). Add func.abs()/abs() to all SUM
queries and display code to ensure correct totals regardless of sign.

Affected: admin statistics, referral contest stats, tariff revenue,
campaign stats, reporting service, admin renewal notifications.
2026-02-17 03:40:37 +03:00
Fringg c30972f6a7 fix: prevent negative amounts in spent display and balance history
SUBSCRIPTION_PAYMENT transactions are stored with negative amount_kopeks.
- get_user_total_spent_kopeks now returns abs() to fix "Потрачено: -155 ₽"
  and broken promo group threshold comparisons
- Balance history uses abs() before format_price to prevent "--85 ₽"
2026-02-17 03:36:56 +03:00
Egor 7628fb9f6e Merge pull request #2613 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.14.0
2026-02-16 19:26:11 +03:00
github-actions[bot] 4c48eadebc chore(main): release 3.14.0 2026-02-16 16:23:56 +00:00
Egor 6ea3860a2f Merge pull request #2612 from BEDOLAGA-DEV/dev
Dev
2026-02-16 19:23:30 +03:00
Fringg 1b8ef69a1b fix: NameError in set_user_devices_button — undefined action_text
Replaced undefined action_text with devices (the actual value being set).
Removed duplicate await callback.answer() call.
2026-02-16 19:09:52 +03:00
Fringg 9d710050ad feat: show all active webhook endpoints in startup log
Added missing webhook endpoints to the startup section:
Platega, CloudPayments, Kassa.ai, and RemnaWave webhook.
2026-02-16 19:08:49 +03:00
Fringg 491a7e1c42 fix: remove unused PaymentService from MonitoringService init
MonitoringService instantiated PaymentService() at module level during
import, triggering a debug log before structlog/logging were configured.
This caused [debug    ] with padded spaces (structlog default pad_level)
and appeared 7 seconds before the startup banner. The payment_service
attribute was never used in MonitoringService.
2026-02-16 19:02:57 +03:00
Fringg 7eb8d4e153 fix: force basicConfig to replace pre-existing handlers
logging.basicConfig() silently does nothing if the root logger already
has handlers. When import-time side effects trigger stdlib logging before
main() configures formatters, our ProcessorFormatter with pad_level=False
never gets applied — producing [debug    ] instead of [debug].
2026-02-16 18:49:39 +03:00
Fringg f63720467a refactor: improve log formatting — logger name prefix and table alignment
1. Add _prefix_logger_name processor that moves [module.name] before
   event text for consistent format: timestamp [level] [module] message
2. Fix startup summary table alignment by using display width calculation
   instead of len() — properly accounts for wide emoji and variation
   selectors that render as 2 terminal cells
2026-02-16 18:33:40 +03:00
Fringg 516be6e600 fix: sync support mode from cabinet admin to SupportSettingsService
Cabinet admin endpoint was setting settings.SUPPORT_SYSTEM_MODE directly
without updating SupportSettingsService JSON, causing bot to show stale
mode. Now routes through set_system_mode() which updates both stores.
2026-02-16 18:24:27 +03:00
Fringg 0807a9ff19 fix: sync SUPPORT_SYSTEM_MODE between SystemSettings and SupportSettings
When changing SUPPORT_SYSTEM_MODE via system settings admin panel, the
SupportSettingsService JSON cache was not updated, causing the old value
to take priority. Now both services stay in sync bidirectionally.
2026-02-16 18:22:44 +03:00
Fringg a93a32f3a7 fix: resolve MissingGreenlet error when accessing subscription.tariff
Add .selectinload(Subscription.tariff) chain to all User queries that
load subscriptions, preventing lazy loading of the tariff relationship
in async context. Also replace unsafe getattr(subscription, 'tariff')
with explicit async get_tariff_by_id() in handle_extend_subscription.
2026-02-16 17:54:43 +03:00
Egor 68de66f526 Merge pull request #2610 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.13.0
2026-02-16 10:12:33 +03:00
github-actions[bot] 15aba2b3db chore(main): release 3.13.0 2026-02-16 07:11:21 +00:00
Egor fa78fa6d09 Merge pull request #2609 from BEDOLAGA-DEV/dev
Dev
2026-02-16 10:10:52 +03:00
Fringg 11f8af003f fix: resolve exc_info for admin notifications, clean log formatting
- TelegramNotifierProcessor: resolve exc_info=True → sys.exc_info()
  tuple while still in except block, fixing "(no traceback available)"
- Use real exception type (e.g. TelegramBadRequest) instead of LogError
- Include user_id/username in admin notification context
- ConsoleRenderer: pad_level=False removes trailing spaces in [info]
- Strip [__main__] logger name from startup/timeline logs
2026-02-16 10:06:37 +03:00
Fringg 11ef714e0d fix: limit Rich traceback output to prevent console flood
RichTracebackFormatter defaults (show_locals=True, max_frames=100)
produced 5000+ line tracebacks on chained exceptions with aiogram.
Now: show_locals=False, max_frames=20, suppress aiogram/aiohttp frames.
2026-02-16 09:57:46 +03:00
Fringg 909a4039c4 fix: traceback in Telegram notifications + reduce log padding
- LoggingMiddleware: logger.error → logger.exception to include exc_info
  so TelegramNotifierProcessor can extract traceback for admin chat
- ConsoleRenderer: pad_event_to=0 to remove excessive whitespace
  in short event names (timeline markers like ┃, ┗)
2026-02-16 09:55:56 +03:00
Fringg bf646112df feat: colored console logs via structlog + rich + FORCE_COLOR
- Add rich dependency for colored tracebacks and console rendering
- Set FORCE_COLOR=1 in docker-compose for color output in containers
- Remove format_exc_info from processor chain — ConsoleRenderer now
  handles exc_info directly (Rich tracebacks on console, plain in files)
- Let ConsoleRenderer auto-detect colors via FORCE_COLOR env var
2026-02-16 09:43:41 +03:00
Fringg 8a6650e57c fix: suppress startup log noise (~350 lines → ~30)
- Suppress migration logger to WARNING during startup (main.py)
- Remove debug logs from get_traffic_packages() leaking before structlog init
- Downgrade handler registration logs to debug (start.py)
- Remove duplicate section headers from migration orchestrator
2026-02-16 09:34:17 +03:00
Fringg 25e8c9f8fc fix: use sync context manager for structlog bound_contextvars
bound_contextvars() returns a sync _GeneratorContextManager, not async.
Using `async with` caused TypeError crashing all web API requests.
2026-02-16 09:23:22 +03:00
Fringg 1f0fef114b refactor: complete structlog migration with contextvars, kwargs, and logging hardening
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding
  via structlog contextvars (aiogram) and http_method/http_path (FastAPI)
- Use bound_contextvars() context manager instead of clear_contextvars()
  to safely restore previous state instead of wiping all context
- Register ContextVarsMiddleware as outermost middleware (before GlobalError)
  so all error logs include user context
- Replace structlog.get_logger() with structlog.get_logger(__name__) across
  270 calls in 265 files for meaningful logger names
- Switch wrapper_class from BoundLogger to make_filtering_bound_logger()
  for pre-processor level filtering (performance optimization)
- Migrate 1411 %-style positional arg logger calls to structlog kwargs
  style across 161 files via AST script
- Migrate log_rotation_service.py from stdlib logging to structlog
- Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES
  and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking
  to Telegram notifications and general log files
- Fix LoggingMiddleware: add from_user null-safety for channel posts,
  switch time.time() to time.monotonic() for duration measurement
- Remove duplicate logger assignments in purchase.py, config.py,
  inline.py, and admin/payments.py
2026-02-16 09:18:12 +03:00
Egor be6036e879 Merge pull request #2607 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.12.1
2026-02-16 07:32:42 +03:00
github-actions[bot] bba85a309a chore(main): release 3.12.1 2026-02-16 04:32:16 +00:00
415 changed files with 15919 additions and 19910 deletions
+7 -6
View File
@@ -116,10 +116,8 @@ BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновле
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
# Обязательная подписка на канал
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
# Channel subscription settings (channels are managed via admin panel)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
@@ -371,7 +369,8 @@ REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# Показывать раздел партнёрки в кабинете
REFERRAL_PARTNER_SECTION_VISIBLE=true
# Уведомления
REFERRAL_NOTIFICATIONS_ENABLED=true
@@ -384,6 +383,8 @@ REFERRAL_WITHDRAWAL_ENABLED=false
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS=50000
# Интервал между запросами на вывод (дни)
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS=30
# Текст-подсказка для поля реквизитов при выводе (пустая строка = стандартный текст)
REFERRAL_WITHDRAWAL_REQUISITES_TEXT=
# Выводить только реферальный баланс (true) или весь баланс (false)
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE=true
# ID топика для уведомлений о заявках на вывод (0 = основной чат)
@@ -802,8 +803,6 @@ PRICE_ROUNDING_ENABLED=true
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
APP_CONFIG_PATH=app-config.json
ENABLE_DEEP_LINKS=true
APP_CONFIG_CACHE_TTL=3600
@@ -845,6 +844,8 @@ VERSION_CHECK_INTERVAL_HOURS=1
# ===== ЛОГИРОВАНИЕ =====
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
# ANSI-цвета в консоли (true — цветной вывод с Rich, false — plain-text)
LOG_COLORS=true
# === Ротация логов ===
# Включить новую систему ротации (по умолчанию старое поведение)
-1
View File
@@ -16,7 +16,6 @@
!uv.lock
!requirements.txt
!alembic.ini
!app-config.json
!release-please-config.json
!.release-please-manifest.json
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.12.0"
".": "3.18.0"
}
+226
View File
@@ -1,5 +1,231 @@
# Changelog
## [3.18.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.17.1...v3.18.0) (2026-02-24)
### New Features
* add ChatTypeFilterMiddleware to ignore group/forum messages ([25f014f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25f014fd8988b5513fba8fec4483981384687e96))
* add multi-channel mandatory subscription system ([8375d7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8375d7ecc5e54ea935a00175dd26f667eab95346))
* add required channels button to admin settings submenu in bot ([3af07ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3af07ff627fc354da4f8c41b0bd0575dddd9afa5))
* colored channel subscription buttons via Bot API 9.4 style ([0b3b2e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b3b2e5dc54d8b6b3ede883d5c0f5b91791b7b9b))
* rework guide mode with Remnawave API integration ([5a269b2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a269b249e8e6cad266822095676937481613f5f))
### Bug Fixes
* add missing CHANNEL_CHECK_NOT_SUBSCRIBED localization key ([a47ef67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a47ef67090c4e48f466286f7c676eeee0c61a4fb))
* address code review issues in guide mode rework ([fae6f71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fae6f71def421e319733e4edcf1ca80a2831b2ec))
* address security review findings ([6feec1e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6feec1eaa847644ba3402763a2ffefd8f770cc01))
* callback routing safety and cache invalidation order ([6a50013](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a50013c21de199df0ba0dab3600b693548b6c1e))
* correct broadcast button deep-links for cabinet mode ([e5fa45f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e5fa45f74f969b84f9f1388f8d4888d22c46d7e8))
* HTML-escape all externally-sourced text in guide messages ([711ec34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/711ec344c646844401f355695a7e8c0d4fb401ee))
* improve deduplication log message wording in monitoring service ([2aead9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2aead9a68b6bf274c8d1497c85f2ed4d4fc9c70b))
* invalidate app config cache on local file saves ([978726a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/978726a7856cf56257c49491afe569fa8c395eac))
* pre-existing bugs found during review ([1bb939f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bb939f63a360a687fafba26bc363024df0f6be0))
* remove [@username](https://github.com/username) channel ID input, auto-prefix -100 for bare digits ([a7db469](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7db469fd7603e7d8dac3076f5d633da654a3a57))
* restore RemnaWave config management endpoints ([6f473de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f473defef32a6d81cee55ef2cd397d536a784a7))
* translate required channels handler to Russian, add localization keys ([1bc9074](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bc9074c1bcdaba7215065c77aac9dd51db4d7c8))
### Refactoring
* remove legacy app-config.json system ([295d2e8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/295d2e877e43f48e9319ba0b01be959904637000))
## [3.17.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.17.0...v3.17.1) (2026-02-23)
### Bug Fixes
* add diagnostic logging for device_limit sync to RemnaWave ([97b3f89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97b3f899d12c4bf32b6229a3b595f1b9ad611096))
* add int32 overflow guards and strengthen auth validation ([50a931e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/50a931ec363d1842126b90098f93c6cae47a9fac))
* add missing broadcast_history columns and harden subscription logic ([d4c4a8a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d4c4a8a211eaf836024f8d9dcb725f25f514f05e))
* allow tariff switch when less than 1 day remains ([67f3547](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67f3547ae2f40153229d71c1abe7e1213466e5c3))
* cap expected_monthly_referrals to prevent int32 overflow ([2ef6185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2ef618571570edb6011a365af8aa9cd7e3348c2e))
* cross-validate Telegram identity on every authenticated request ([973b3d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/973b3d3d3ff80376c0fd19c531d7aac3ae751df8))
* handle RemnaWave API errors in traffic aggregation ([ed4624c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed4624c6649bdbc04bc850ef63e5c86e26a37ce4))
* migrate all remaining naive timestamp columns to timestamptz ([708bb9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/708bb9eec7ea4360b26709fb2a3f82dd139ed600))
* prevent partner self-referral via own campaign link ([115c0c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/115c0c84c0698591da75d7d3b8fbd8e0fc8541ea))
* protect active paid subscriptions from being disabled in RemnaWave ([1b6bbc7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b6bbc7131341b4afd739e4195f02aa956ead616))
* repair missing DB columns and make backup resilient to schema mismatches ([c20355b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c20355b06df13328f85cc5a6045b3e490419a30a))
* show negative amounts for withdrawals in admin transaction list ([5ee45f9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ee45f97d179ce2d32b3f19eeb6fd01989a30ca7))
* suppress web page preview when logo mode is disabled ([1f4430f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f4430f3af8f3efcc58ef7b562904adcb1640a44))
* uploaded backup restore button not triggering handler ([ebe5083](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebe508302b906f8b56cb230b934fb8566990c684))
* use aiogram 3.x bot.download() instead of document.download() ([205c8d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/205c8d987d93151a17aa0793cb51bd99917aea97))
## [3.17.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.3...v3.17.0) (2026-02-18)
### New Features
* add referral code tracking to all cabinet auth methods + email_templates migration ([18c2477](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18c24771737994f3ae1f832435ed2247ca625aab))
### Bug Fixes
* prevent 'caption is too long' error in logo mode ([6e28a1a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6e28a1a22b02055b357051dfecbee7fefbebc774))
* skip blocked users in trial notifications and broadcasts without DB status change ([493f315](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/493f315a65610826a04e04c3d2065e0b395426ed))
## [3.16.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.2...v3.16.3) (2026-02-18)
### Bug Fixes
* 3 user deletion bugs — type cast, inner savepoint, lazy load ([af31c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af31c551d2f23ef01425bdb2db8f255dbc3047e2))
* auth middleware catches all commit errors, not just connection errors ([6409b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6409b0c023cd7957c43d5c1c3d83e671ccaf959c))
* connected_squads stores UUIDs, not int IDs — use get_server_ids_by_uuids ([d7039d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7039d75a47fbf67436a9d39f2cd9f65f2646544))
* deadlock on user deletion + robust migration 0002 ([b7b83ab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7b83abb723913b3167e7462ff592a374c3f421b))
* eliminate deadlock by matching lock order with webhook ([d651a6c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d651a6c02f501b7a0ded570f2db6addcc16173a9))
* make migration 0002 robust with table existence checks ([f076269](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f076269c323726c683a38db092d907591a26e647))
* wrap user deletion steps in savepoints to prevent transaction cascade abort ([a38dfcb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a38dfcb75a47a185d979a8202f637d8b79812e67))
## [3.16.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.1...v3.16.2) (2026-02-18)
### Bug Fixes
* auto-convert naive datetimes to UTC-aware on model load ([f7d33a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7d33a7d2b31145a839ee54676816aa657ac90da))
* extend naive datetime guard to all model properties ([bd11801](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd11801467e917d76005d1a782c71f5ae4ffee6e))
* handle naive datetime in raw SQL row comparison (payment/common) ([38f3a9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38f3a9a16a24e85adf473f2150aad31574a87060))
* handle naive datetimes in Subscription properties ([e512e5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e512e5fe6e9009992b5bc8b9be7f53e0612f234a))
* use AwareDateTime TypeDecorator for all datetime columns ([a7f3d65](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7f3d652c51ecd653900a530b7d38feaf603ecf1))
## [3.16.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.0...v3.16.1) (2026-02-18)
### Bug Fixes
* add migration for partner system tables and columns ([4645be5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4645be53cbb3799aa6b2b6a623af30460357a554))
* add migration for partner system tables and columns ([79ea398](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79ea398d1db436a7812a799bf01b2c1c3b1b73be))
## [3.16.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.15.1...v3.16.0) (2026-02-18)
### New Features
* add admin notifications for partner applications and withdrawals ([cf7cc5a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf7cc5a84e295608009f255fcd0dcedb5a2a04a3))
* add admin partner settings API (withdrawal toggle, requisites text, partner visibility) ([6881d97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6881d97bbb1f6cd8ca3609c2d9286a6e4fb24fc3))
* add campaign_id to ReferralEarning for campaign attribution ([0c07812](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c07812ecc9502f54a7745a77b086fc52bdc0e34))
* add partner system and withdrawal management to cabinet ([58bfaea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58bfaeaddbcbb98cb67dbd507847a0e5c8d07809))
* attribute campaign registrations to partner for referral earnings ([767e965](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/767e9650285adc72b067b2c0b8a4d1ac5c5bba57))
* blocked user detection during broadcasts, filter blocked from all notifications ([10e231e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10e231e52e0dbabd9195a2df373b3c95129a5e4f))
* enforce 1-to-1 partner-campaign binding with partner info in campaigns ([366df18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/366df18c547047a7c69192c768970ebc6ee426fc))
* expose traffic_reset_mode in subscription response ([59383bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59383bdbd8c72428d151cb24d132452414b14fa3))
* expose traffic_reset_mode in tariff API response ([5d4a94b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5d4a94b8cea8f16f0b4c31e24a4695bee4c67af7))
* include partner campaigns in /partner/status response ([ea5d932](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ea5d932476553ad1750da3bebbd4b8f055478040))
* link campaign registrations to partner for referral earnings ([c4dc43e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4dc43e054e9faec2f9614fe51a64635f80c1796))
* notify users on partner/withdrawal approve/reject ([327d4f4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/327d4f4d1559e37dc591adbfd0c839d986d1068d))
### Bug Fixes
* add blocked_count column migration to universal_migration.py ([b4b10c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4b10c998cadbb879540e56dbd0e362b5497ee57))
* add missing payment providers to payment_utils and fix {total_amount} formatting ([bdb6161](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bdb61613de378efab4de6de98fde2de3b554c548))
* add selectinload for subscription in campaign user list ([eb9dba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb9dba3f4728b478f2206ff992700a9677f879c7))
* campaign web link uses ?campaign= param, not ?start= ([28f524b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28f524b7622ed975d2fece66edc94d9713354738))
* correct subscription_service import in broadcast cleanup ([6c4e035](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c4e035146934dffb576477cc75f7365b2f27b99))
* critical security and data integrity fixes for partner system ([8899749](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88997492c3534ea2f6e194c0382c77302557c2f3))
* handle YooKassa NotFoundError gracefully in get_payment_info ([df5b1a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5b1a072d99ff8aee0c94304b2a0214f0fcffe7))
* medium-priority fixes for partner system ([7c20fde](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c20fde4e887749d72280a8804467645e5bab416))
* move PartnerStatus enum before User class to fix NameError ([acc1323](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acc1323a542b8e92433cabf1334d2d98bfa21e21))
* prevent fileConfig from destroying structlog handlers ([e78b104](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e78b1040a50ac14759bceab396d0c3e34dd79cdd))
* reorder button_click_logs migration to nullify before ALTER TYPE ([df5415f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5415f30b2aae4412ff5fbd3cac8076128b818c))
* resolve HIGH-priority performance and security issues in partner system ([fcf3a2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcf3a2c8062752b2b1dc06b5993ac2d8ae80ee85))
* return zeroed stats dict when withdrawal is disabled ([7883efc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7883efc3d6e6d8bedf8e4b7d72634cbab6e2f3d7))
* unassign all campaigns when revoking partner status ([d39063b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d39063b22ffb6442e275db39704361cdb9251793))
### Refactoring
* replace universal_migration.py with Alembic ([b6c7f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6c7f91a7c79d108820c9f89c9070fde4843316c))
* replace universal_migration.py with Alembic ([784616b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/784616b349ef12b35ee021dd7a7b2a2ef9fc57f6))
## [3.15.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.15.0...v3.15.1) (2026-02-17)
### Bug Fixes
* add naive datetime guards to fromisoformat() in Redis cache readers ([1b3e6f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b3e6f2f11c20aa240da1beb11dd7dfb20dbe6e8))
* add naive datetime guards to fromisoformat() in Redis cache readers ([6fa4948](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6fa49485d9f1cd678cb5f9fa7d0375fd47643239))
## [3.15.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.14.1...v3.15.0) (2026-02-17)
### New Features
* add LOG_COLORS env setting to toggle console ANSI colors ([27309f5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27309f53d9fa0ba9a2ca07a65feed96bf38f470c))
* add web campaign links with bonus processing in auth flow ([d955279](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9552799c17a76e2cc2118699528c5b591bd97fb))
### Bug Fixes
* AttributeError in withdrawal admin notification (send_to_admins → send_admin_notification) ([c75ec0b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c75ec0b22a3f674d3e1a24b9d546eca1998701b3))
* remove local UTC re-imports shadowing module-level import in purchase.py ([e68760c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e68760cc668016209f4f19a2e08af8680343d6ed))
## [3.14.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.14.0...v3.14.1) (2026-02-17)
### Bug Fixes
* add naive datetime guards to parsers and fix test datetime literals ([0946090](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/094609005af7358bf5d34d252fc66685bd25751c))
* address remaining abs() issues from review ([ff21b27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff21b27b98bb5a7517e06057eb319c9f3ebb74c7))
* complete datetime.utcnow() → datetime.now(UTC) migration ([eb18994](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18994b7d34d777ca39d3278d509e41359e2a85))
* normalize transaction amount signs across all aggregations ([4247981](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4247981c98111af388c98628c1e61f0517c57417))
* prevent negative amounts in spent display and balance history ([c30972f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30972f6a7911a89a6c3f2080019ff465d11b597))
## [3.14.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.13.0...v3.14.0) (2026-02-16)
### New Features
* show all active webhook endpoints in startup log ([9d71005](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d710050ad40ba76a14aa6ace8e8a47f25cdde94))
### Bug Fixes
* force basicConfig to replace pre-existing handlers ([7eb8d4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7eb8d4e153bab640a5829f75bfa6f70df5763284))
* NameError in set_user_devices_button — undefined action_text ([1b8ef69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b8ef69a1bbb7d8d86827cf7aaa4f05cbf480d75))
* remove unused PaymentService from MonitoringService init ([491a7e1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/491a7e1c425a355e55b3020e2bcc7b96047bdf5e))
* resolve MissingGreenlet error when accessing subscription.tariff ([a93a32f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a93a32f3a7d1b259a2e24954ae5d2b7c966c5639))
* sync support mode from cabinet admin to SupportSettingsService ([516be6e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/516be6e600a08ad700d83b793dc64b2ca07bdf44))
* sync SUPPORT_SYSTEM_MODE between SystemSettings and SupportSettings ([0807a9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0807a9ff19d1eb4f1204f7cbeb1da1c1cfefe83a))
### Refactoring
* improve log formatting — logger name prefix and table alignment ([f637204](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f63720467a935bdaaa58bb34d588d65e46698f26))
## [3.13.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.12.1...v3.13.0) (2026-02-16)
### New Features
* colored console logs via structlog + rich + FORCE_COLOR ([bf64611](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf646112df02aa7aa7918d0513cb6968ceb7f378))
### Bug Fixes
* limit Rich traceback output to prevent console flood ([11ef714](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11ef714e0dde25a08711c0daeee943b6e71e20b7))
* resolve exc_info for admin notifications, clean log formatting ([11f8af0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11f8af003fc60384abafa2b670b89d6ad3ac57a4))
* suppress startup log noise (~350 lines → ~30) ([8a6650e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a6650e57cd8ea396d9b057a7753469947f38d29))
* traceback in Telegram notifications + reduce log padding ([909a403](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/909a4039c43b910761bd05c36e79c8e6773199db))
* use sync context manager for structlog bound_contextvars ([25e8c9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25e8c9f8fc4d2c66d5a1407d3de5c7402dc596da))
### Refactoring
* complete structlog migration with contextvars, kwargs, and logging hardening ([1f0fef1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f0fef114bd979b2b0d2bd38dde6ce05e7bba07b))
## [3.12.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.12.0...v3.12.1) (2026-02-16)
### Bug Fixes
* add /start burst rate-limit to prevent spam abuse ([61a9722](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61a97220d30031816ab23e33a46717e4895c0758))
* add promo code anti-abuse protections ([97ec39a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97ec39aa803f0e3f03fdcd482df0cbcb86fd1efd))
* handle TelegramBadRequest in ticket edit_message_text calls ([8e61fe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e61fe47746da2ac09c3ea8c4dbfc6be198e49e3))
* replace deprecated Query(regex=) with pattern= ([871ceb8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/871ceb866ccf1f3a770c7ef33406e1a43d0a7ff7))
## [3.12.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.11.0...v3.12.0) (2026-02-15)
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.12.0" # x-release-please-version
ARG VERSION="v3.18.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+16
View File
@@ -40,6 +40,22 @@ fix: ## Исправить код (ruff check --fix + format)
uv run ruff check . --fix
uv run ruff format .
.PHONY: migrate
migrate: ## Применить миграции (alembic upgrade head)
uv run alembic upgrade head
.PHONY: migration
migration: ## Создать миграцию (usage: make migration m="description")
uv run alembic revision --autogenerate -m "$(m)"
.PHONY: migrate-stamp
migrate-stamp: ## Пометить БД как актуальную (для существующих БД)
uv run alembic stamp head
.PHONY: migrate-history
migrate-history: ## Показать историю миграций
uv run alembic history --verbose
.PHONY: help
help: ## Показать список доступных команд
@echo ""
+1 -1
View File
@@ -2,7 +2,7 @@
script_location = migrations/alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
-658
View File
@@ -1,658 +0,0 @@
{
"config": {
"additionalLocales": [
"ru",
"zh",
"fa"
],
"branding": {
"name": "Subscription",
"logoUrl": "https://raw.githubusercontent.com/Fr1ngg/remnawave-bedolaga-telegram-bot/bf0c1ce711a26fa2f24559e7e4443820e68d758b/assets/bedolaga_app3.svg",
"supportUrl": "https://t.me"
}
},
"platforms": {
"ios": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "Open in App Store [EU]",
"fa": "باز کردن در App Store [EU]",
"ru": "Открыть в App Store [EU]",
"zh": "在 App Store 中打开 [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "Open in App Store [RU]",
"fa": "باز کردن در App Store [RU]",
"ru": "Открыть в App Store [RU]",
"zh": "在 App Store 中打开 [RU]"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "streisand",
"name": "Streisand",
"isFeatured": false,
"urlScheme": "streisand://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/streisand/id6450534064",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "shadowrocket",
"name": "Shadowrocket",
"isFeatured": false,
"urlScheme": "sub://",
"isNeedBase64Encoding": true,
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/shadowrocket/id932747118",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"android": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
"buttonText": {
"en": "Open in Google Play",
"fa": "باز کردن در Google Play",
"ru": "Открыть в Google Play",
"zh": "在 Google Play 中打开"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"fa": "برنامه را باز کنید و به سرور متصل شوید",
"ru": "Откройте приложение и подключитесь к серверу",
"zh": "打开应用并连接到服务器"
}
}
},
{
"id": "clash-meta",
"name": "Clash Meta",
"isFeatured": false,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/MetaCubeX/ClashMetaForAndroid/releases/download/v2.11.7/cmfa-2.11.7-meta-universal-release.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
},
{
"buttonLink": "https://f-droid.org/packages/com.github.metacubex.clash.meta/",
"buttonText": {
"en": "Open in F-Droid",
"fa": "در F-Droid باز کنید",
"ru": "Открыть в F-Droid",
"zh": "在 F-Droid 中打开"
}
}
],
"description": {
"en": "Download and install Clash Meta APK",
"fa": "دانلود و نصب Clash Meta APK",
"ru": "Скачайте и установите Clash Meta APK",
"zh": "下载并安装 Clash Meta APK"
}
},
"addSubscriptionStep": {
"description": {
"en": "Tap the button to import configuration",
"fa": "برای وارد کردن پیکربندی روی دکمه ضربه بزنید",
"ru": "Нажмите кнопку, чтобы импортировать конфигурацию",
"zh": "点击按钮导入配置"
}
},
"connectAndUseStep": {
"description": {
"en": "Open Clash Meta and tap on Connect",
"fa": "Clash Meta را باز کنید و روی اتصال ضربه بزنید",
"ru": "Откройте Clash Meta и нажмите Подключиться",
"zh": "打开 Clash Meta 并点击连接"
}
}
}
],
"macos": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выбрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
}
},
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"windows": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выبрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
}
},
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"linux": [],
"androidTV": [
{
"id": "new-app-androidtv-1760203310792",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Button TextGoogle Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
],
"appleTV": [
{
"id": "new-app-appletv-1760203488851",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
]
}
}
+28 -21
View File
@@ -1,6 +1,5 @@
import logging
import redis.asyncio as redis
import structlog
from aiogram import Bot, Dispatcher, types
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.storage.redis import RedisStorage
@@ -46,6 +45,7 @@ from app.handlers.admin import (
referrals as admin_referrals,
remnawave as admin_remnawave,
reports as admin_reports,
required_channels as admin_required_channels,
rules as admin_rules,
servers as admin_servers,
statistics as admin_statistics,
@@ -59,10 +59,13 @@ from app.handlers.admin import (
users as admin_users,
welcome_text as admin_welcome_text,
)
from app.handlers.channel_member import register_handlers as register_channel_member_handlers
from app.handlers.stars_payments import register_stars_handlers
from app.middlewares.auth import AuthMiddleware
from app.middlewares.blacklist import BlacklistMiddleware
from app.middlewares.button_stats import ButtonStatsMiddleware
from app.middlewares.chat_type_filter import ChatTypeFilterMiddleware
from app.middlewares.context_binding import ContextVarsMiddleware
from app.middlewares.global_error import GlobalErrorMiddleware
from app.middlewares.logging import LoggingMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
@@ -75,14 +78,14 @@ from app.utils.message_patch import patch_message_methods
patch_message_methods()
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def debug_callback_handler(callback: types.CallbackQuery):
logger.info('🔍 DEBUG CALLBACK:')
logger.info(f' - Data: {callback.data}')
logger.info(f' - User: {callback.from_user.id}')
logger.info(f' - Username: {callback.from_user.username}')
logger.info('Data', callback_data=callback.data)
logger.info('User', from_user_id=callback.from_user.id)
logger.info('Username', username=callback.from_user.username)
async def setup_bot() -> tuple[Bot, Dispatcher]:
@@ -90,7 +93,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
await cache.connect()
logger.info('Кеш инициализирован')
except Exception as e:
logger.warning(f'Кеш не инициализирован: {e}')
logger.warning('Кеш не инициализирован', error=e)
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -106,12 +109,18 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
storage = RedisStorage(redis_client)
logger.info('Подключено к Redis для FSM storage')
except Exception as e:
logger.warning(f'Не удалось подключиться к Redis: {e}')
logger.warning('Не удалось подключиться к Redis', error=e)
logger.info('Используется MemoryStorage для FSM')
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
dp.message.middleware(ContextVarsMiddleware())
dp.callback_query.middleware(ContextVarsMiddleware())
dp.pre_checkout_query.middleware(ContextVarsMiddleware())
chat_type_filter = ChatTypeFilterMiddleware()
dp.message.middleware(chat_type_filter)
dp.callback_query.middleware(chat_type_filter)
dp.message.middleware(GlobalErrorMiddleware())
dp.callback_query.middleware(GlobalErrorMiddleware())
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
@@ -132,15 +141,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(button_stats_middleware)
logger.info('📊 ButtonStatsMiddleware активирован')
if settings.CHANNEL_IS_REQUIRED_SUB:
from app.middlewares.channel_checker import ChannelCheckerMiddleware
from app.middlewares.channel_checker import ChannelCheckerMiddleware
channel_checker_middleware = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker_middleware)
dp.callback_query.middleware(channel_checker_middleware)
logger.info('🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован')
else:
logger.info('🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован')
channel_checker = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker)
dp.callback_query.middleware(channel_checker)
dp.message.middleware(AuthMiddleware())
dp.callback_query.middleware(AuthMiddleware())
dp.pre_checkout_query.middleware(AuthMiddleware())
@@ -191,6 +196,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_bulk_ban.register_bulk_ban_handlers(dp)
admin_blacklist.register_blacklist_handlers(dp)
admin_blocked_users.register_handlers(dp)
admin_required_channels.register_handlers(dp)
register_channel_member_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_contests.register_handlers(dp)
@@ -205,7 +212,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
await maintenance_service.start_monitoring()
logger.info('Мониторинг техработ запущен')
except Exception as e:
logger.error(f'Ошибка запуска мониторинга техработ: {e}')
logger.error('Ошибка запуска мониторинга техработ', error=e)
else:
logger.info('Мониторинг техработ отключен настройками')
@@ -236,7 +243,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
'Установите MINIAPP_CUSTOM_URL.'
)
elif settings.is_cabinet_mode():
logger.info(f'🏠 Режим Cabinet активен, базовый URL: {settings.MINIAPP_CUSTOM_URL}')
logger.info('🏠 Режим Cabinet активен, базовый URL', MINIAPP_CUSTOM_URL=settings.MINIAPP_CUSTOM_URL)
# Load per-section button styles cache
if settings.is_cabinet_mode():
@@ -245,7 +252,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
await load_button_styles_cache()
except Exception as e:
logger.warning(f'Failed to load button styles cache: {e}')
logger.warning('Failed to load button styles cache', error=e)
logger.info('Бот успешно настроен')
@@ -257,10 +264,10 @@ async def shutdown_bot():
await maintenance_service.stop_monitoring()
logger.info('Мониторинг техработ остановлен')
except Exception as e:
logger.error(f'Ошибка остановки мониторинга: {e}')
logger.error('Ошибка остановки мониторинга', error=e)
try:
await cache.close()
logger.info('Соединения с кешем закрыты')
except Exception as e:
logger.error(f'Ошибка закрытия кеша: {e}')
logger.error('Ошибка закрытия кеша', error=e)
+5 -5
View File
@@ -1,7 +1,7 @@
"""Email verification token generation and validation."""
import secrets
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from app.config import settings
@@ -24,7 +24,7 @@ def get_email_change_expires_at() -> datetime:
Datetime when the email change code expires
"""
minutes = settings.get_cabinet_email_change_code_expire_minutes()
return datetime.utcnow() + timedelta(minutes=minutes)
return datetime.now(UTC) + timedelta(minutes=minutes)
def generate_verification_token() -> str:
@@ -55,7 +55,7 @@ def get_verification_expires_at() -> datetime:
Datetime when the verification token expires
"""
hours = settings.get_cabinet_email_verification_expire_hours()
return datetime.utcnow() + timedelta(hours=hours)
return datetime.now(UTC) + timedelta(hours=hours)
def get_password_reset_expires_at() -> datetime:
@@ -66,7 +66,7 @@ def get_password_reset_expires_at() -> datetime:
Datetime when the password reset token expires
"""
hours = settings.get_cabinet_password_reset_expire_hours()
return datetime.utcnow() + timedelta(hours=hours)
return datetime.now(UTC) + timedelta(hours=hours)
def is_token_expired(expires_at: datetime | None) -> bool:
@@ -81,4 +81,4 @@ def is_token_expired(expires_at: datetime | None) -> bool:
"""
if expires_at is None:
return True
return datetime.utcnow() > expires_at
return datetime.now(UTC) > expires_at
+6 -6
View File
@@ -1,6 +1,6 @@
"""JWT token handling for cabinet authentication."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
import jwt
@@ -23,13 +23,13 @@ def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
Encoded JWT access token
"""
expire_minutes = settings.get_cabinet_access_token_expire_minutes()
expires = datetime.utcnow() + timedelta(minutes=expire_minutes)
expires = datetime.now(UTC) + timedelta(minutes=expire_minutes)
payload = {
'sub': str(user_id),
'type': 'access',
'exp': expires,
'iat': datetime.utcnow(),
'iat': datetime.now(UTC),
}
# Добавляем telegram_id только если он есть
@@ -51,13 +51,13 @@ def create_refresh_token(user_id: int) -> str:
Encoded JWT refresh token
"""
expire_days = settings.get_cabinet_refresh_token_expire_days()
expires = datetime.utcnow() + timedelta(days=expire_days)
expires = datetime.now(UTC) + timedelta(days=expire_days)
payload = {
'sub': str(user_id),
'type': 'refresh',
'exp': expires,
'iat': datetime.utcnow(),
'iat': datetime.now(UTC),
}
secret = settings.get_cabinet_jwt_secret()
@@ -108,4 +108,4 @@ def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, An
def get_refresh_token_expires_at() -> datetime:
"""Get the expiration datetime for a new refresh token."""
expire_days = settings.get_cabinet_refresh_token_expire_days()
return datetime.utcnow() + timedelta(days=expire_days)
return datetime.now(UTC) + timedelta(days=expire_days)
+2 -2
View File
@@ -1,18 +1,18 @@
"""OAuth 2.0 provider implementations for cabinet authentication."""
import logging
import secrets
from abc import ABC, abstractmethod
from typing import Any, TypedDict
import httpx
import structlog
from pydantic import BaseModel
from app.config import settings
from app.utils.cache import cache, cache_key
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
STATE_TTL_SECONDS = 600 # 10 minutes
+5 -5
View File
@@ -3,7 +3,7 @@
import hashlib
import hmac
import json
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
from urllib.parse import parse_qsl, unquote
@@ -34,8 +34,8 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
if auth_date:
try:
# Use UTC timestamp to avoid timezone issues
auth_time = datetime.utcfromtimestamp(int(auth_date))
age = (datetime.utcnow() - auth_time).total_seconds()
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
age = (datetime.now(UTC) - auth_time).total_seconds()
if age > max_age_seconds:
return False
except (ValueError, TypeError, OSError):
@@ -81,8 +81,8 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
if auth_date:
try:
# Use UTC timestamp to avoid timezone issues
auth_time = datetime.utcfromtimestamp(int(auth_date))
age = (datetime.utcnow() - auth_time).total_seconds()
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
age = (datetime.now(UTC) - auth_time).total_seconds()
if age > max_age_seconds:
return None
except (ValueError, TypeError, OSError):
+67 -45
View File
@@ -1,10 +1,7 @@
"""FastAPI dependencies for cabinet module."""
import asyncio
import logging
from aiogram import Bot
from fastapi import Depends, HTTPException, status
import structlog
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,23 +13,13 @@ from app.services.blacklist_service import blacklist_service
from app.services.maintenance_service import maintenance_service
from .auth.jwt_handler import get_token_payload
from .auth.telegram_auth import validate_telegram_init_data
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
security = HTTPBearer(auto_error=False)
# Кешированный Bot для проверки подписки на канал
_channel_check_bot: Bot | None = None
def _get_channel_check_bot() -> Bot:
"""Получить или создать Bot для проверки подписки на канал."""
global _channel_check_bot
if _channel_check_bot is None:
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
return _channel_check_bot
async def get_cabinet_db() -> AsyncSession:
"""Get database session for cabinet operations."""
@@ -44,6 +31,7 @@ async def get_cabinet_db() -> AsyncSession:
async def get_current_cabinet_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
@@ -51,6 +39,7 @@ async def get_current_cabinet_user(
Get current authenticated cabinet user from JWT token.
Args:
request: FastAPI request object (for reading X-Telegram-Init-Data header)
credentials: HTTP Bearer credentials
db: Database session
@@ -105,6 +94,34 @@ async def get_current_cabinet_user(
detail='User account is not active',
)
# Defense in depth: cross-validate Telegram identity.
# The frontend sends X-Telegram-Init-Data on every request.
# If the header is present and cryptographically valid, verify that
# the Telegram user ID matches the JWT user's telegram_id.
# This prevents cross-account token reuse when Telegram WebView
# shares localStorage across accounts on the same device.
init_data_raw = request.headers.get('X-Telegram-Init-Data')
if init_data_raw and user.telegram_id is not None:
# Use generous max_age: Telegram Desktop caches initData
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
if tg_user is None:
logger.warning(
'Telegram initData validation failed but header was present',
jwt_user_id=user.id,
)
elif tg_user.get('id') != user.telegram_id:
logger.warning(
'Telegram identity mismatch: JWT belongs to different user than current Telegram account',
jwt_user_id=user.id,
jwt_telegram_id=user.telegram_id,
init_data_telegram_id=tg_user.get('id'),
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Session belongs to a different Telegram account. Please restart the app.',
headers={'WWW-Authenticate': 'Bearer'},
)
# Check blacklist
if user.telegram_id is not None:
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
@@ -132,45 +149,37 @@ async def get_current_cabinet_user(
},
)
# Check required channel subscription - ТОЛЬКО для Telegram юзеров
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
# Пропускаем проверку для email-only юзеров (нет telegram_id)
# Check required channel subscription - Telegram users only
if settings.CHANNEL_IS_REQUIRED_SUB:
# Skip for email-only users (no telegram_id)
if user.telegram_id is not None:
# Проверяем админа по telegram_id ИЛИ email
# Skip admin check
is_admin = settings.is_admin(
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
)
if not is_admin:
try:
bot = _get_channel_check_bot()
chat_member = await asyncio.wait_for(
bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=user.telegram_id),
timeout=10.0,
)
# Не закрываем сессию - бот переиспользуется
from app.services.channel_subscription_service import channel_subscription_service
if chat_member.status not in ['member', 'administrator', 'creator']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to our channel to continue',
'channel_link': settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except TimeoutError:
logger.warning(f'Timeout checking channel subscription for user {user.telegram_id}')
# Don't block user if check times out
except Exception as e:
logger.warning(f'Failed to check channel subscription for user {user.telegram_id}: {e}')
# Don't block user if check fails
channels_with_status = await channel_subscription_service.get_channels_with_status(user.telegram_id)
is_subscribed = (
all(ch['is_subscribed'] for ch in channels_with_status) if channels_with_status else True
)
if not is_subscribed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to the required channels to continue',
'channels': channels_with_status,
},
)
return user
async def get_optional_cabinet_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User | None:
@@ -198,6 +207,19 @@ async def get_optional_cabinet_user(
if not user or user.status != 'active':
return None
# Cross-validate Telegram identity (same as get_current_cabinet_user)
init_data_raw = request.headers.get('X-Telegram-Init-Data')
if init_data_raw and user.telegram_id is not None:
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
if tg_user and tg_user.get('id') != user.telegram_id:
logger.warning(
'Telegram identity mismatch in optional auth',
jwt_user_id=user.id,
jwt_telegram_id=user.telegram_id,
init_data_telegram_id=tg_user.get('id'),
)
return None
return user
+11 -1
View File
@@ -7,7 +7,9 @@ from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router
from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_channels import router as admin_channels_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_partners import router as admin_partners_router
from .admin_payment_methods import router as admin_payment_methods_router
from .admin_payments import router as admin_payments_router
from .admin_pinned_messages import router as admin_pinned_messages_router
@@ -23,6 +25,7 @@ from .admin_traffic import router as admin_traffic_router
from .admin_updates import router as admin_updates_router
from .admin_users import router as admin_users_router
from .admin_wheel import router as admin_wheel_router
from .admin_withdrawals import router as admin_withdrawals_router
from .auth import router as auth_router
from .balance import router as balance_router
from .branding import router as branding_router
@@ -31,6 +34,7 @@ from .info import router as info_router
from .media import router as media_router
from .notifications import router as notifications_router
from .oauth import router as oauth_router
from .partner_application import router as partner_application_router
from .polls import router as polls_router
from .promo import router as promo_router
from .promocode import router as promocode_router
@@ -43,6 +47,7 @@ from .ticket_notifications import (
from .tickets import router as tickets_router
from .websocket import router as websocket_router
from .wheel import router as wheel_router
from .withdrawal import router as withdrawal_router
# Main cabinet router
@@ -54,6 +59,8 @@ router.include_router(oauth_router)
router.include_router(subscription_router)
router.include_router(balance_router)
router.include_router(referral_router)
router.include_router(partner_application_router)
router.include_router(withdrawal_router)
# Notifications router MUST be before tickets router to avoid route conflict
router.include_router(ticket_notifications_router)
router.include_router(tickets_router)
@@ -73,7 +80,6 @@ router.include_router(wheel_router)
router.include_router(admin_ticket_notifications_router)
router.include_router(admin_tickets_router)
router.include_router(admin_settings_router)
router.include_router(admin_apps_router)
router.include_router(admin_wheel_router)
router.include_router(admin_tariffs_router)
router.include_router(admin_servers_router)
@@ -83,6 +89,8 @@ router.include_router(admin_broadcasts_router)
router.include_router(admin_promocodes_router)
router.include_router(admin_promo_groups_router)
router.include_router(admin_campaigns_router)
router.include_router(admin_partners_router)
router.include_router(admin_withdrawals_router)
router.include_router(admin_users_router)
router.include_router(admin_payment_methods_router)
router.include_router(admin_payments_router)
@@ -93,6 +101,8 @@ router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
router.include_router(admin_pinned_messages_router)
router.include_router(admin_button_styles_router)
router.include_router(admin_channels_router)
router.include_router(admin_apps_router)
# WebSocket route
router.include_router(websocket_router)
+30 -446
View File
@@ -1,9 +1,8 @@
"""Admin routes for managing VPN applications in app-config.json."""
"""Admin routes for managing RemnaWave app configuration."""
import json
import logging
from pathlib import Path
import re
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,7 +15,7 @@ from app.services.system_settings_service import bot_configuration_service
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
@@ -24,424 +23,6 @@ router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
# ============ Schemas ============
class LocalizedText(BaseModel):
"""Localized text for multiple languages."""
en: str = ''
ru: str = ''
zh: str | None = ''
fa: str | None = ''
class AppButton(BaseModel):
"""Button with link and localized text."""
buttonLink: str
buttonText: LocalizedText
class AppStep(BaseModel):
"""Step with description and optional buttons/title."""
description: LocalizedText
buttons: list[AppButton] | None = None
title: LocalizedText | None = None
class AppDefinition(BaseModel):
"""VPN application definition."""
id: str
name: str
isFeatured: bool = False
urlScheme: str
isNeedBase64Encoding: bool | None = None
installationStep: AppStep
addSubscriptionStep: AppStep
connectAndUseStep: AppStep
additionalBeforeAddSubscriptionStep: AppStep | None = None
additionalAfterAddSubscriptionStep: AppStep | None = None
class PlatformApps(BaseModel):
"""Apps for a specific platform."""
platform: str
apps: list[AppDefinition]
class AppConfigBranding(BaseModel):
"""Branding configuration."""
name: str
logoUrl: str
supportUrl: str
class AppConfigConfig(BaseModel):
"""Top-level config section."""
additionalLocales: list[str]
branding: AppConfigBranding
class AppConfigResponse(BaseModel):
"""Full app config response."""
config: AppConfigConfig
platforms: dict[str, list[AppDefinition]]
class CreateAppRequest(BaseModel):
"""Request to create a new app."""
platform: str
app: AppDefinition
class UpdateAppRequest(BaseModel):
"""Request to update an app."""
app: AppDefinition
class ReorderAppsRequest(BaseModel):
"""Request to reorder apps in a platform."""
app_ids: list[str]
class UpdateBrandingRequest(BaseModel):
"""Request to update branding."""
branding: AppConfigBranding
# ============ Helpers ============
def _get_config_path() -> Path:
"""Get path to app-config.json."""
return Path(settings.get_app_config_path())
def _load_config() -> dict:
"""Load app config from file."""
config_path = _get_config_path()
if not config_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'App config file not found: {config_path}',
)
try:
with open(config_path, encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to parse app config: {e}',
)
def _save_config(config: dict) -> None:
"""Save app config to file."""
config_path = _get_config_path()
try:
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2, ensure_ascii=False)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to save app config: {e}',
)
VALID_PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']
# ============ Routes ============
@router.get('', response_model=AppConfigResponse)
async def get_app_config(
admin: User = Depends(get_current_admin_user),
):
"""Get full app configuration."""
config = _load_config()
return config
@router.get('/platforms', response_model=list[str])
async def get_platforms(
admin: User = Depends(get_current_admin_user),
):
"""Get list of available platforms."""
return VALID_PLATFORMS
@router.get('/platforms/{platform}', response_model=list[AppDefinition])
async def get_platform_apps(
platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Get apps for a specific platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}',
)
config = _load_config()
platforms = config.get('platforms', {})
return platforms.get(platform, [])
@router.post('/platforms/{platform}', response_model=AppDefinition)
async def create_app(
platform: str,
request: CreateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Create a new app for a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
if platform not in platforms:
platforms[platform] = []
# Check if app with same ID already exists
existing_ids = [app.get('id') for app in platforms[platform]]
if request.app.id in existing_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App with ID '{request.app.id}' already exists in {platform}",
)
# Add new app
app_dict = request.app.model_dump(exclude_none=True)
platforms[platform].append(app_dict)
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'")
return request.app
@router.put('/platforms/{platform}/{app_id}', response_model=AppDefinition)
async def update_app(
platform: str,
app_id: str,
request: UpdateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update an existing app."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Find and update app
app_index = None
for i, app in enumerate(apps):
if app.get('id') == app_id:
app_index = i
break
if app_index is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Update app
app_dict = request.app.model_dump(exclude_none=True)
apps[app_index] = app_dict
platforms[platform] = apps
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'")
return request.app
@router.delete('/platforms/{platform}/{app_id}')
async def delete_app(
platform: str,
app_id: str,
admin: User = Depends(get_current_admin_user),
):
"""Delete an app from a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Find and remove app
original_length = len(apps)
apps = [app for app in apps if app.get('id') != app_id]
if len(apps) == original_length:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
platforms[platform] = apps
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'")
return {'status': 'deleted', 'app_id': app_id}
@router.post('/platforms/{platform}/reorder')
async def reorder_apps(
platform: str,
request: ReorderAppsRequest,
admin: User = Depends(get_current_admin_user),
):
"""Reorder apps in a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Create a map of apps by ID
apps_map = {app.get('id'): app for app in apps}
# Verify all IDs exist
for app_id in request.app_ids:
if app_id not in apps_map:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Reorder apps
reordered_apps = [apps_map[app_id] for app_id in request.app_ids]
# Add any apps that weren't in the reorder list (shouldn't happen but just in case)
for app in apps:
if app.get('id') not in request.app_ids:
reordered_apps.append(app)
platforms[platform] = reordered_apps
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'")
return {'status': 'reordered', 'order': request.app_ids}
@router.put('/branding', response_model=AppConfigBranding)
async def update_branding(
request: UpdateBrandingRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update branding configuration."""
config = _load_config()
if 'config' not in config:
config['config'] = {}
config['config']['branding'] = request.branding.model_dump()
_save_config(config)
logger.info(f'Admin {admin.id} updated branding')
return request.branding
@router.get('/branding', response_model=AppConfigBranding)
async def get_branding(
admin: User = Depends(get_current_admin_user),
):
"""Get branding configuration."""
config = _load_config()
branding = config.get('config', {}).get('branding', {})
return branding
@router.post('/platforms/{platform}/copy/{app_id}')
async def copy_app_to_platform(
platform: str,
app_id: str,
target_platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Copy an app from one platform to another."""
if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid platform(s)',
)
config = _load_config()
platforms = config.get('platforms', {})
source_apps = platforms.get(platform, [])
# Find source app
source_app = None
for app in source_apps:
if app.get('id') == app_id:
source_app = app.copy()
break
if not source_app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Generate new ID for copied app
import time
new_id = f'{app_id}-copy-{int(time.time())}'
source_app['id'] = new_id
# Add to target platform
if target_platform not in platforms:
platforms[target_platform] = []
platforms[target_platform].append(source_app)
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'")
return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform}
# ============ RemnaWave Config Routes ============
class RemnaWaveConfigStatus(BaseModel):
"""Status of RemnaWave config integration."""
@@ -455,6 +36,11 @@ class UpdateRemnaWaveUuidRequest(BaseModel):
uuid: str | None = None
# ============ Helpers ============
_UUID_PATTERN = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
def _get_remnawave_config_uuid() -> str | None:
"""Get RemnaWave config UUID from system settings or env."""
try:
@@ -463,6 +49,9 @@ def _get_remnawave_config_uuid() -> str | None:
return settings.CABINET_REMNA_SUB_CONFIG
# ============ Routes ============
@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus)
async def get_remnawave_config_status(
admin: User = Depends(get_current_admin_user),
@@ -484,23 +73,22 @@ async def set_remnawave_config_uuid(
"""Set RemnaWave subscription config UUID."""
uuid_value = request.uuid.strip() if request.uuid else None
# Validate UUID format if provided
if uuid_value:
import re
uuid_pattern = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
if not uuid_pattern.match(uuid_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid UUID format',
)
if uuid_value and not _UUID_PATTERN.match(uuid_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid UUID format',
)
try:
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value)
await db.commit()
logger.info(f"Admin {admin.id} updated CABINET_REMNA_SUB_CONFIG to '{uuid_value}'")
from app.handlers.subscription.common import invalidate_app_config_cache
invalidate_app_config_cache()
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG', admin_id=admin.id, uuid_value=uuid_value)
except Exception as e:
logger.error(f'Error saving RemnaWave config UUID: {e}')
logger.error('Error saving RemnaWave config UUID', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to save configuration',
@@ -516,15 +104,12 @@ async def set_remnawave_config_uuid(
async def get_remnawave_subscription_config(
admin: User = Depends(get_current_admin_user),
):
"""
Fetch subscription page config from RemnaWave panel.
Uses CABINET_REMNA_SUB_CONFIG setting for the config UUID.
"""
"""Fetch subscription page config from RemnaWave panel."""
config_uuid = _get_remnawave_config_uuid()
if not config_uuid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='CABINET_REMNA_SUB_CONFIG is not configured',
detail='RemnaWave subscription config is not configured',
)
try:
@@ -534,10 +119,9 @@ async def get_remnawave_subscription_config(
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Subscription config '{config_uuid}' not found in RemnaWave",
detail='Subscription config not found',
)
# Return the raw config data from RemnaWave
return {
'uuid': config.uuid,
'name': config.name,
@@ -547,10 +131,10 @@ async def get_remnawave_subscription_config(
except HTTPException:
raise
except Exception as e:
logger.error(f'Error fetching RemnaWave config: {e}')
logger.error('Error fetching RemnaWave config', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch config from RemnaWave: {e!s}',
detail='Failed to fetch config from RemnaWave',
)
@@ -572,8 +156,8 @@ async def list_remnawave_subscription_configs(
for c in configs
]
except Exception as e:
logger.error(f'Error listing RemnaWave configs: {e}')
logger.error('Error listing RemnaWave configs', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch configs from RemnaWave: {e!s}',
detail='Failed to fetch configs from RemnaWave',
)
+15 -13
View File
@@ -1,8 +1,8 @@
"""Admin routes for Ban System monitoring in cabinet."""
import logging
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.config import settings
@@ -45,7 +45,7 @@ from ..schemas.ban_system import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/ban-system', tags=['Cabinet Admin Ban System'])
@@ -53,9 +53,11 @@ router = APIRouter(prefix='/admin/ban-system', tags=['Cabinet Admin Ban System']
def _get_ban_api() -> BanSystemAPI:
"""Get Ban System API instance."""
logger.debug(
f'Ban System check - enabled: {settings.is_ban_system_enabled()}, configured: {settings.is_ban_system_configured()}'
'Ban System check enabled: configured',
is_ban_system_enabled=settings.is_ban_system_enabled(),
is_ban_system_configured=settings.is_ban_system_configured(),
)
logger.debug(f'Ban System URL: {settings.get_ban_system_api_url()}')
logger.debug('Ban System URL', get_ban_system_api_url=settings.get_ban_system_api_url())
if not settings.is_ban_system_enabled():
raise HTTPException(
@@ -83,13 +85,13 @@ async def _api_request(api: BanSystemAPI, method: str, *args, **kwargs) -> Any:
func = getattr(api, method)
return await func(*args, **kwargs)
except BanSystemAPIError as e:
logger.error(f'Ban System API error: {e}')
logger.error('Ban System API error', error=e)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f'Ban System API error: {e.message}',
)
except Exception as e:
logger.error(f'Ban System unexpected error: {e}')
logger.error('Ban System unexpected error', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Internal error: {e!s}',
@@ -133,7 +135,7 @@ async def get_stats(
api = _get_ban_api()
data = await _api_request(api, 'get_stats')
logger.debug(f'Ban System raw stats: {data}')
logger.debug('Ban System raw stats', data=data)
# Extract punishment stats
punishment_stats = data.get('punishment_stats') or {}
@@ -364,7 +366,7 @@ async def unban_user(
api = _get_ban_api()
try:
await _api_request(api, 'enable_user', user_id=user_id)
logger.info(f'Admin {admin.id} unbanned user {user_id} in Ban System')
logger.info('Admin unbanned user in Ban System', admin_id=admin.id, user_id=user_id)
return UnbanResponse(success=True, message='User unbanned successfully')
except HTTPException:
raise
@@ -387,7 +389,7 @@ async def ban_user(
minutes=request.minutes,
reason=request.reason,
)
logger.info(f'Admin {admin.id} banned user {request.username}: {request.reason}')
logger.info('Admin banned user', admin_id=admin.id, username=request.username, reason=request.reason)
return UnbanResponse(success=True, message='User banned successfully')
except HTTPException:
raise
@@ -819,7 +821,7 @@ async def set_setting(
api = _get_ban_api()
data = await _api_request(api, 'set_setting', key=key, value=value)
logger.info(f'Admin {admin.id} changed Ban System setting {key} to {value}')
logger.info('Admin changed Ban System setting to', admin_id=admin.id, key=key, value=value)
return _parse_setting_response(key, data)
@@ -833,7 +835,7 @@ async def toggle_setting(
api = _get_ban_api()
data = await _api_request(api, 'toggle_setting', key=key)
logger.info(f'Admin {admin.id} toggled Ban System setting {key}')
logger.info('Admin toggled Ban System setting', admin_id=admin.id, key=key)
return _parse_setting_response(key, data, default_type='bool')
@@ -850,7 +852,7 @@ async def whitelist_add(
api = _get_ban_api()
try:
await _api_request(api, 'whitelist_add', username=request.username)
logger.info(f'Admin {admin.id} added {request.username} to Ban System whitelist')
logger.info('Admin added to Ban System whitelist', admin_id=admin.id, username=request.username)
return UnbanResponse(success=True, message=f'User {request.username} added to whitelist')
except HTTPException:
raise
@@ -867,7 +869,7 @@ async def whitelist_remove(
api = _get_ban_api()
try:
await _api_request(api, 'whitelist_remove', username=request.username)
logger.info(f'Admin {admin.id} removed {request.username} from Ban System whitelist')
logger.info('Admin removed from Ban System whitelist', admin_id=admin.id, username=request.username)
return UnbanResponse(success=True, message=f'User {request.username} removed from whitelist')
except HTTPException:
raise
+23 -13
View File
@@ -1,8 +1,8 @@
"""Admin routes for broadcasts in cabinet."""
import logging
from datetime import datetime
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -40,7 +40,7 @@ from ..schemas.broadcasts import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/broadcasts', tags=['Cabinet Admin Broadcasts'])
@@ -118,9 +118,10 @@ EMAIL_FILTER_GROUPS = {
def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
"""Serialize broadcast to response model."""
blocked = broadcast.blocked_count or 0
progress = 0.0
if broadcast.total_count > 0:
progress = round((broadcast.sent_count + broadcast.failed_count) / broadcast.total_count * 100, 1)
progress = round((broadcast.sent_count + broadcast.failed_count + blocked) / broadcast.total_count * 100, 1)
return BroadcastResponse(
id=broadcast.id,
@@ -133,6 +134,7 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
total_count=broadcast.total_count,
sent_count=broadcast.sent_count,
failed_count=broadcast.failed_count,
blocked_count=blocked,
status=broadcast.status,
admin_id=broadcast.admin_id,
admin_name=broadcast.admin_name,
@@ -255,7 +257,7 @@ async def get_filters(
try:
count = await get_target_users_count(db, key)
except Exception as e:
logger.warning(f'Failed to get count for filter {key}: {e}')
logger.warning('Failed to get count for filter', key=key, error=e)
count = 0
filters.append(
BroadcastFilter(
@@ -272,7 +274,7 @@ async def get_filters(
try:
count = await get_target_users_count(db, key)
except Exception as e:
logger.warning(f'Failed to get count for custom filter {key}: {e}')
logger.warning('Failed to get count for custom filter', key=key, error=e)
count = 0
custom_filters.append(
BroadcastFilter(
@@ -367,7 +369,7 @@ async def preview_broadcast(
try:
count = await get_target_users_count(db, request.target)
except Exception as e:
logger.error(f'Failed to get count for target {request.target}: {e}')
logger.error('Failed to get count for target', target=request.target, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to count recipients',
@@ -450,7 +452,9 @@ async def create_broadcast(
await broadcast_service.start_broadcast(broadcast.id, config)
await db.refresh(broadcast)
logger.info(f"Admin {admin.id} created broadcast {broadcast.id} for target '{request.target}'")
logger.info(
'Admin created broadcast for target', admin_id=admin.id, broadcast_id=broadcast.id, target=request.target
)
return _serialize_broadcast(broadcast)
@@ -494,7 +498,7 @@ async def get_email_filters(
try:
count = await _get_email_filter_count(db, key)
except Exception as e:
logger.warning(f'Failed to get count for email filter {key}: {e}')
logger.warning('Failed to get count for email filter', key=key, error=e)
count = 0
filters.append(
@@ -532,7 +536,7 @@ async def preview_email_broadcast(
try:
count = await _get_email_filter_count(db, request.target)
except Exception as e:
logger.error(f'Failed to get email count for target {request.target}: {e}')
logger.error('Failed to get email count for target', target=request.target, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to count email recipients',
@@ -661,7 +665,13 @@ async def create_combined_broadcast(
await db.refresh(broadcast)
logger.info(f"Admin {admin.id} created {request.channel} broadcast {broadcast.id} for target '{request.target}'")
logger.info(
'Admin created broadcast for target',
admin_id=admin.id,
channel=request.channel,
broadcast_id=broadcast.id,
target=request.target,
)
return _serialize_broadcast(broadcast)
@@ -716,11 +726,11 @@ async def stop_broadcast(
broadcast.status = 'cancelling'
else:
broadcast.status = 'cancelled'
broadcast.completed_at = datetime.utcnow()
broadcast.completed_at = datetime.now(UTC)
await db.commit()
await db.refresh(broadcast)
logger.info(f'Admin {admin.id} stopped broadcast {broadcast_id}')
logger.info('Admin stopped broadcast', admin_id=admin.id, broadcast_id=broadcast_id)
return _serialize_broadcast(broadcast)
+6 -4
View File
@@ -1,8 +1,8 @@
"""Admin routes for per-section cabinet button style configuration."""
import json
import logging
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,7 +20,7 @@ from app.utils.button_styles_cache import (
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles'])
@@ -234,7 +234,9 @@ async def update_button_styles(
# Refresh in-process cache
await load_button_styles_cache()
logger.info('Admin %s updated button styles for sections: %s', admin.telegram_id, changed_sections)
logger.info(
'Admin updated button styles for sections', telegram_id=admin.telegram_id, changed_sections=changed_sections
)
return _build_response(current)
@@ -248,6 +250,6 @@ async def reset_button_styles(
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
await load_button_styles_cache()
logger.info('Admin %s reset button styles to defaults', admin.telegram_id)
logger.info('Admin reset button styles to defaults', telegram_id=admin.telegram_id)
return _build_response(DEFAULT_BUTTON_STYLES)
+91 -14
View File
@@ -1,7 +1,8 @@
"""Admin routes for managing advertising campaigns in cabinet."""
import logging
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,7 +22,9 @@ from app.database.crud.campaign import (
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import get_all_tariffs
from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
PartnerStatus,
Subscription,
Tariff,
User,
@@ -29,6 +32,7 @@ from app.database.models import (
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.campaigns import (
AvailablePartnerItem,
CampaignCreateRequest,
CampaignDetailResponse,
CampaignListItem,
@@ -45,7 +49,7 @@ from ..schemas.campaigns import (
from ..schemas.tariffs import TariffListItem
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/campaigns', tags=['Cabinet Admin Campaigns'])
@@ -58,6 +62,22 @@ def _get_deep_link(start_parameter: str) -> str:
return f'?start={start_parameter}'
def _get_web_link(start_parameter: str) -> str | None:
"""Generate web link for campaign."""
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
if base_url:
return f'{base_url}/?campaign={start_parameter}'
return None
def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
"""Get partner display name from campaign."""
if not campaign.partner_user_id or not campaign.partner:
return None
partner = campaign.partner
return partner.first_name or partner.username or f'#{partner.id}'
@router.get('/overview', response_model=CampaignsOverviewResponse)
async def get_overview(
admin: User = Depends(get_current_admin_user),
@@ -133,6 +153,26 @@ async def get_available_tariffs(
]
@router.get('/available-partners', response_model=list[AvailablePartnerItem])
async def get_available_partners(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of approved partners for campaign partner selector."""
result = await db.execute(
select(User).where(User.partner_status == PartnerStatus.APPROVED.value).order_by(User.first_name, User.username)
)
partners = result.scalars().all()
return [
AvailablePartnerItem(
user_id=p.id,
username=p.username,
first_name=p.first_name,
)
for p in partners
]
@router.get('', response_model=CampaignListResponse)
async def list_campaigns(
include_inactive: bool = True,
@@ -159,6 +199,8 @@ async def list_campaigns(
registrations_count=stats['registrations'],
total_revenue_kopeks=stats['total_revenue_kopeks'],
conversion_rate=stats['conversion_rate'],
partner_user_id=campaign.partner_user_id,
partner_name=_get_partner_name(campaign),
created_at=campaign.created_at,
)
)
@@ -202,10 +244,13 @@ async def get_campaign(
tariff_id=campaign.tariff_id,
tariff_duration_days=campaign.tariff_duration_days,
tariff=tariff_info,
partner_user_id=campaign.partner_user_id,
partner_name=_get_partner_name(campaign),
created_by=campaign.created_by,
created_at=campaign.created_at,
updated_at=campaign.updated_at,
deep_link=_get_deep_link(campaign.start_parameter),
web_link=_get_web_link(campaign.start_parameter),
)
@@ -249,6 +294,7 @@ async def get_campaign_stats(
conversion_rate=stats['conversion_rate'],
trial_conversion_rate=stats['trial_conversion_rate'],
deep_link=_get_deep_link(campaign.start_parameter),
web_link=_get_web_link(campaign.start_parameter),
)
@@ -289,19 +335,22 @@ async def get_campaign_registrations(
)
total = count_result.scalar() or 0
items = []
for reg, user in rows:
# Check if user has subscription
# Batch query: find which users have active subscriptions (avoids N+1)
user_ids = [user.id for _reg, user in rows]
active_sub_user_ids: set[int] = set()
if user_ids:
sub_result = await db.execute(
select(Subscription)
select(Subscription.user_id)
.where(
Subscription.user_id == user.id,
Subscription.user_id.in_(user_ids),
Subscription.status == 'active',
)
.limit(1)
.distinct()
)
has_sub = sub_result.scalar_one_or_none() is not None
active_sub_user_ids = set(sub_result.scalars().all())
items = []
for reg, user in rows:
items.append(
CampaignRegistrationItem(
id=reg.id,
@@ -316,7 +365,7 @@ async def get_campaign_registrations(
tariff_duration_days=reg.tariff_duration_days,
created_at=reg.created_at,
user_balance_kopeks=user.balance_kopeks or 0,
has_subscription=has_sub,
has_subscription=user.id in active_sub_user_ids,
has_paid=user.has_had_paid_subscription or False,
)
)
@@ -359,6 +408,15 @@ async def create_new_campaign(
detail='Tariff not found',
)
# Validate partner exists and is approved
if request.partner_user_id is not None:
partner_user = await db.get(User, request.partner_user_id)
if not partner_user or partner_user.partner_status != 'approved':
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Partner not found or not approved',
)
campaign = await create_campaign(
db,
name=request.name,
@@ -373,12 +431,13 @@ async def create_new_campaign(
tariff_id=request.tariff_id,
tariff_duration_days=request.tariff_duration_days,
is_active=request.is_active,
partner_user_id=request.partner_user_id,
)
# Reload to get tariff relationship
campaign = await get_campaign_by_id(db, campaign.id)
logger.info(f'Admin {admin.id} created campaign {campaign.id}: {campaign.name}')
logger.info('Admin created campaign', admin_id=admin.id, campaign_id=campaign.id, campaign_name=campaign.name)
return await get_campaign(campaign.id, admin, db)
@@ -444,10 +503,28 @@ async def update_existing_campaign(
if request.tariff_duration_days is not None:
updates['tariff_duration_days'] = request.tariff_duration_days
# Handle partner_user_id separately (allows explicit None to unassign)
partner_changed = False
if 'partner_user_id' in request.model_fields_set:
new_partner_id = request.partner_user_id
if new_partner_id is not None:
partner_user = await db.get(User, new_partner_id)
if not partner_user or partner_user.partner_status != 'approved':
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Partner not found or not approved',
)
campaign.partner_user_id = new_partner_id
campaign.updated_at = datetime.now(UTC)
partner_changed = True
if updates:
await update_campaign(db, campaign, **updates)
elif partner_changed:
await db.commit()
await db.refresh(campaign)
logger.info(f'Admin {admin.id} updated campaign {campaign_id}')
logger.info('Admin updated campaign', admin_id=admin.id, campaign_id=campaign_id)
return await get_campaign(campaign_id, admin, db)
@@ -475,7 +552,7 @@ async def delete_existing_campaign(
)
await delete_campaign(db, campaign)
logger.info(f'Admin {admin.id} deleted campaign {campaign_id}: {campaign.name}')
logger.info('Admin deleted campaign', admin_id=admin.id, campaign_id=campaign_id, campaign_name=campaign.name)
return {'message': 'Campaign deleted successfully'}
@@ -498,7 +575,7 @@ async def toggle_campaign(
await update_campaign(db, campaign, is_active=new_status)
status_text = 'activated' if new_status else 'deactivated'
logger.info(f'Admin {admin.id} {status_text} campaign {campaign_id}')
logger.info('Admin campaign', admin_id=admin.id, status_text=status_text, campaign_id=campaign_id)
return CampaignToggleResponse(
id=campaign_id,
+91
View File
@@ -0,0 +1,91 @@
"""Admin API for managing required channels."""
import structlog
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.required_channel import (
add_channel,
delete_channel,
get_all_channels,
toggle_channel,
update_channel,
)
from app.database.models import User
from app.services.channel_subscription_service import channel_subscription_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.channel import (
ChannelCreateRequest,
ChannelListResponse,
ChannelResponse,
ChannelUpdateRequest,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/channel-subscriptions', tags=['Cabinet Admin Channels'])
@router.get('', response_model=ChannelListResponse)
async def list_channels(
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> ChannelListResponse:
channels = await get_all_channels(db)
return ChannelListResponse(
items=[ChannelResponse.model_validate(ch) for ch in channels],
total=len(channels),
)
@router.post('', response_model=ChannelResponse, status_code=201)
async def create_channel(
data: ChannelCreateRequest,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> ChannelResponse:
ch = await add_channel(db, channel_id=data.channel_id, channel_link=data.channel_link, title=data.title)
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.patch('/{channel_db_id}', response_model=ChannelResponse)
async def update_channel_endpoint(
channel_db_id: int,
data: ChannelUpdateRequest,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> ChannelResponse:
update_data = data.model_dump(exclude_unset=True)
ch = await update_channel(db, channel_db_id, **update_data)
if not ch:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.post('/{channel_db_id}/toggle', response_model=ChannelResponse)
async def toggle_channel_endpoint(
channel_db_id: int,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> ChannelResponse:
ch = await toggle_channel(db, channel_db_id)
if not ch:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.delete('/{channel_db_id}', status_code=204)
async def delete_channel_endpoint(
channel_db_id: int,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> None:
ok = await delete_channel(db, channel_db_id)
if not ok:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
+13 -16
View File
@@ -1,9 +1,9 @@
"""Admin routes for managing email notification templates."""
import asyncio
import logging
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,7 +20,7 @@ from ..services.email_template_overrides import (
from ..services.email_templates import EmailNotificationTemplates
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/email-templates', tags=['Admin Email Templates'])
@@ -505,10 +505,7 @@ async def update_template(
)
logger.info(
'Админ %s обновил email шаблон %s/%s',
admin.id,
notification_type,
language,
'Админ обновил email шаблон /', admin_id=admin.id, notification_type=notification_type, language=language
)
return {'status': 'ok', 'template': result}
@@ -533,10 +530,10 @@ async def reset_template(
if deleted:
logger.info(
'Админ %s сбросил email шаблон %s/%s к дефолту',
admin.id,
notification_type,
language,
'Админ сбросил email шаблон / к дефолту',
admin_id=admin.id,
notification_type=notification_type,
language=language,
)
return {'status': 'ok', 'was_custom': deleted}
@@ -657,7 +654,7 @@ async def send_test_email(
body_html=body_html,
)
except Exception as e:
logger.error('Ошибка отправки тестового email: %s', e)
logger.error('Ошибка отправки тестового email', e=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to send test email: {e!s}',
@@ -670,11 +667,11 @@ async def send_test_email(
)
logger.info(
'Админ %s отправил тестовый email %s/%s на %s',
admin.id,
notification_type,
language,
to_email,
'Админ отправил тестовый email / на',
admin_id=admin.id,
notification_type=notification_type,
language=language,
to_email=to_email,
)
return {'status': 'ok', 'sent_to': to_email}
+582
View File
@@ -0,0 +1,582 @@
"""Admin routes for managing partners in cabinet."""
from datetime import UTC, datetime
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import desc, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import (
AdvertisingCampaign,
PartnerApplication,
PartnerStatus,
ReferralEarning,
User,
)
from app.services.partner_application_service import partner_application_service
from app.services.partner_stats_service import PartnerStatsService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.partners import (
AdminApproveRequest,
AdminPartnerApplicationItem,
AdminPartnerApplicationsResponse,
AdminPartnerDetailResponse,
AdminPartnerItem,
AdminPartnerListResponse,
AdminRejectRequest,
AdminUpdateCommissionRequest,
CampaignSummary,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/partners', tags=['Cabinet Admin Partners'])
# ==================== Settings ====================
class PartnerSettingsResponse(BaseModel):
withdrawal_enabled: bool
withdrawal_min_amount_kopeks: int
withdrawal_cooldown_days: int
withdrawal_requisites_text: str
partner_section_visible: bool
referral_program_enabled: bool
class PartnerSettingsUpdateRequest(BaseModel):
withdrawal_enabled: bool | None = None
withdrawal_min_amount_kopeks: int | None = Field(None, ge=0, le=100_000_000)
withdrawal_cooldown_days: int | None = Field(None, ge=0, le=365)
withdrawal_requisites_text: str | None = Field(None, max_length=2000)
partner_section_visible: bool | None = None
referral_program_enabled: bool | None = None
def _build_partner_settings_response() -> PartnerSettingsResponse:
return PartnerSettingsResponse(
withdrawal_enabled=settings.REFERRAL_WITHDRAWAL_ENABLED,
withdrawal_min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
withdrawal_cooldown_days=settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS,
withdrawal_requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
referral_program_enabled=settings.REFERRAL_PROGRAM_ENABLED,
)
@router.get('/settings', response_model=PartnerSettingsResponse)
async def get_partner_settings(
admin: User = Depends(get_current_admin_user),
):
"""Get partner system settings."""
return _build_partner_settings_response()
@router.patch('/settings', response_model=PartnerSettingsResponse)
async def update_partner_settings(
request: PartnerSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update partner system settings."""
from pathlib import Path
# Update in-memory settings
if request.withdrawal_enabled is not None:
settings.REFERRAL_WITHDRAWAL_ENABLED = request.withdrawal_enabled
if request.withdrawal_min_amount_kopeks is not None:
settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS = request.withdrawal_min_amount_kopeks
if request.withdrawal_cooldown_days is not None:
settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS = request.withdrawal_cooldown_days
if request.withdrawal_requisites_text is not None:
settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT = request.withdrawal_requisites_text
if request.partner_section_visible is not None:
settings.REFERRAL_PARTNER_SECTION_VISIBLE = request.partner_section_visible
if request.referral_program_enabled is not None:
settings.REFERRAL_PROGRAM_ENABLED = request.referral_program_enabled
# Persist to .env file
try:
env_file = Path('.env')
if env_file.exists():
lines = env_file.read_text().splitlines()
updates: dict[str, str] = {}
if request.withdrawal_enabled is not None:
updates['REFERRAL_WITHDRAWAL_ENABLED'] = str(request.withdrawal_enabled).lower()
if request.withdrawal_min_amount_kopeks is not None:
updates['REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS'] = str(request.withdrawal_min_amount_kopeks)
if request.withdrawal_cooldown_days is not None:
updates['REFERRAL_WITHDRAWAL_COOLDOWN_DAYS'] = str(request.withdrawal_cooldown_days)
if request.withdrawal_requisites_text is not None:
# Sanitize: replace newlines to prevent .env injection
sanitized = (
request.withdrawal_requisites_text.replace('\r\n', ' ').replace('\n', ' ').replace('\r', ' ')
)
updates['REFERRAL_WITHDRAWAL_REQUISITES_TEXT'] = sanitized
if request.partner_section_visible is not None:
updates['REFERRAL_PARTNER_SECTION_VISIBLE'] = str(request.partner_section_visible).lower()
if request.referral_program_enabled is not None:
updates['REFERRAL_PROGRAM_ENABLED'] = str(request.referral_program_enabled).lower()
new_lines = []
updated_keys: set[str] = set()
for line in lines:
updated = False
for key, value in updates.items():
if line.startswith(f'{key}='):
new_lines.append(f'{key}={value}')
updated_keys.add(key)
updated = True
break
if not updated:
new_lines.append(line)
for key, value in updates.items():
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n')
logger.info('Updated partner settings in .env file', admin_id=admin.id)
except Exception as e:
logger.warning('Failed to update .env file', error=e)
return _build_partner_settings_response()
# ==================== Applications (static paths first) ====================
@router.get('/applications', response_model=AdminPartnerApplicationsResponse)
async def list_applications(
application_status: Literal['pending', 'approved', 'rejected', 'none'] | None = Query(None, alias='status'),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List partner applications."""
applications, total = await partner_application_service.get_all_applications(
db, status=application_status, limit=limit, offset=offset
)
# Batch-fetch users to avoid N+1
user_ids = list({app.user_id for app in applications})
if user_ids:
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
else:
users_map = {}
items = []
for app in applications:
user = users_map.get(app.user_id)
items.append(
AdminPartnerApplicationItem(
id=app.id,
user_id=app.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
company_name=app.company_name,
website_url=app.website_url,
telegram_channel=app.telegram_channel,
description=app.description,
expected_monthly_referrals=app.expected_monthly_referrals,
status=app.status,
admin_comment=app.admin_comment,
approved_commission_percent=app.approved_commission_percent,
created_at=app.created_at,
processed_at=app.processed_at,
)
)
return AdminPartnerApplicationsResponse(items=items, total=total)
@router.post('/applications/{application_id}/approve')
async def approve_application(
application_id: int,
request: AdminApproveRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a partner application."""
success, error = await partner_application_service.approve_application(
db,
application_id=application_id,
admin_id=admin.id,
commission_percent=request.commission_percent,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about approval
try:
from aiogram import Bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
application = await db.get(PartnerApplication, application_id)
user = await db.get(User, application.user_id) if application else None
if user:
comment_text = f'\n{request.comment}' if request.comment else ''
tg_message = (
f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}'
)
bot = Bot(token=settings.BOT_TOKEN)
try:
await notification_delivery_service.notify_partner_approved(
user=user,
commission_percent=request.commission_percent,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send partner approval notification', error=e)
return {'success': True}
@router.post('/applications/{application_id}/reject')
async def reject_application(
application_id: int,
request: AdminRejectRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a partner application."""
success, error = await partner_application_service.reject_application(
db,
application_id=application_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about rejection
try:
from aiogram import Bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
application = await db.get(PartnerApplication, application_id)
user = await db.get(User, application.user_id) if application else None
if user:
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
try:
await notification_delivery_service.notify_partner_rejected(
user=user,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send partner rejection notification', error=e)
return {'success': True}
# ==================== Stats (static paths) ====================
@router.get('/stats')
async def get_partner_stats(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get overall partner statistics."""
total_partners = await db.execute(
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
)
pending_apps = await db.execute(
select(func.count())
.select_from(PartnerApplication)
.where(PartnerApplication.status == PartnerStatus.PENDING.value)
)
total_referrals = await db.execute(select(func.count()).select_from(User).where(User.referred_by_id.isnot(None)))
total_earnings = await db.execute(select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
return {
'total_partners': total_partners.scalar() or 0,
'pending_applications': pending_apps.scalar() or 0,
'total_referrals': total_referrals.scalar() or 0,
'total_earnings_kopeks': total_earnings.scalar() or 0,
}
# ==================== Partners list ====================
@router.get('', response_model=AdminPartnerListResponse)
async def list_partners(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List approved partners."""
count_result = await db.execute(
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
)
total = count_result.scalar() or 0
result = await db.execute(
select(User)
.where(User.partner_status == PartnerStatus.APPROVED.value)
.order_by(desc(User.created_at))
.offset(offset)
.limit(limit)
)
partners = result.scalars().all()
# Batch-fetch earnings and referral counts to avoid N+1
partner_ids = [u.id for u in partners]
earnings_map: dict[int, int] = {}
referral_count_map: dict[int, int] = {}
if partner_ids:
earnings_result = await db.execute(
select(ReferralEarning.user_id, func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.user_id.in_(partner_ids))
.group_by(ReferralEarning.user_id)
)
earnings_map = {row[0]: int(row[1]) for row in earnings_result.all()}
referral_result = await db.execute(
select(User.referred_by_id, func.count())
.where(User.referred_by_id.in_(partner_ids))
.group_by(User.referred_by_id)
)
referral_count_map = {row[0]: row[1] for row in referral_result.all()}
items = []
for user in partners:
items.append(
AdminPartnerItem(
user_id=user.id,
username=user.username,
first_name=user.first_name,
telegram_id=user.telegram_id,
commission_percent=user.referral_commission_percent,
total_referrals=referral_count_map.get(user.id, 0),
total_earnings_kopeks=earnings_map.get(user.id, 0),
balance_kopeks=user.balance_kopeks,
partner_status=user.partner_status,
created_at=user.created_at,
)
)
return AdminPartnerListResponse(items=items, total=total)
# ==================== Partner detail (parametric paths last) ====================
@router.get('/{user_id}', response_model=AdminPartnerDetailResponse)
async def get_partner_detail(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed partner info."""
user = await db.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Пользователь не найден',
)
stats = await PartnerStatsService.get_referrer_detailed_stats(db, user_id)
# Get assigned campaigns
campaigns_result = await db.execute(
select(AdvertisingCampaign).where(AdvertisingCampaign.partner_user_id == user_id)
)
campaigns = campaigns_result.scalars().all()
campaign_list = [
CampaignSummary(
id=c.id,
name=c.name,
start_parameter=c.start_parameter,
is_active=c.is_active,
)
for c in campaigns
]
summary = stats['summary']
earnings = stats['earnings']
return AdminPartnerDetailResponse(
user_id=user.id,
username=user.username,
first_name=user.first_name,
telegram_id=user.telegram_id,
commission_percent=user.referral_commission_percent,
partner_status=user.partner_status,
balance_kopeks=user.balance_kopeks,
total_referrals=summary['total_referrals'],
paid_referrals=summary['paid_referrals'],
active_referrals=summary['active_referrals'],
earnings_all_time=earnings['all_time_kopeks'],
earnings_today=earnings['today_kopeks'],
earnings_week=earnings['week_kopeks'],
earnings_month=earnings['month_kopeks'],
conversion_to_paid=summary['conversion_to_paid_percent'],
campaigns=campaign_list,
created_at=user.created_at,
)
@router.patch('/{user_id}/commission')
async def update_commission(
user_id: int,
request: AdminUpdateCommissionRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update partner commission percent."""
user = await db.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Пользователь не найден',
)
if user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Пользователь не является партнёром',
)
old_commission = user.referral_commission_percent
user.referral_commission_percent = request.commission_percent
await db.commit()
logger.info(
'Комиссия партнёра обновлена',
user_id=user_id,
old_commission=old_commission,
new_commission=request.commission_percent,
admin_id=admin.id,
)
return {'success': True, 'commission_percent': request.commission_percent}
@router.post('/{user_id}/revoke')
async def revoke_partner(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Revoke partner status."""
success, error = await partner_application_service.revoke_partner(db, user_id=user_id, admin_id=admin.id)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
return {'success': True}
@router.post('/{user_id}/campaigns/{campaign_id}/assign')
async def assign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Assign a campaign to a partner."""
campaign = await db.get(AdvertisingCampaign, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Кампания не найдена',
)
user = await db.get(User, user_id)
if not user or user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Пользователь не является партнёром',
)
# Atomic check-and-set to prevent race conditions
result = await db.execute(
update(AdvertisingCampaign)
.where(
AdvertisingCampaign.id == campaign_id,
or_(
AdvertisingCampaign.partner_user_id.is_(None),
AdvertisingCampaign.partner_user_id == user_id,
),
)
.values(partner_user_id=user_id, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Кампания уже привязана к другому партнёру',
)
await db.commit()
return {'success': True}
@router.post('/{user_id}/campaigns/{campaign_id}/unassign')
async def unassign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unassign a campaign from a partner."""
campaign = await db.get(AdvertisingCampaign, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Кампания не найдена',
)
if campaign.partner_user_id != user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Кампания не привязана к этому партнёру',
)
campaign.partner_user_id = None
campaign.updated_at = datetime.now(UTC)
await db.commit()
return {'success': True}
+4 -4
View File
@@ -1,8 +1,8 @@
"""Admin routes for payment method configuration in cabinet."""
import logging
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,7 +20,7 @@ from app.services.payment_method_config_service import (
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/payment-methods', tags=['Cabinet Admin Payment Methods'])
@@ -168,7 +168,7 @@ async def update_payment_methods_order(
):
"""Batch update sort order for payment methods."""
await update_sort_order(db, request.method_ids)
logger.info(f'Admin {admin.id} updated payment methods order: {request.method_ids}')
logger.info('Admin updated payment methods order', admin_id=admin.id, method_ids=request.method_ids)
return {'success': True}
@@ -222,7 +222,7 @@ async def update_payment_method(
detail=f'Payment method not found: {method_id}',
)
logger.info(f'Admin {admin.id} updated payment method config: {method_id}')
logger.info('Admin updated payment method config', admin_id=admin.id, method_id=method_id)
defaults = _get_method_defaults()
return _enrich_config(config, defaults)
+10 -3
View File
@@ -1,9 +1,9 @@
"""Admin routes for payment verification in cabinet."""
import logging
import math
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,7 +22,7 @@ from app.services.payment_verification_service import (
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/payments', tags=['Cabinet Admin Payments'])
@@ -406,7 +406,14 @@ async def check_payment_status(
if status_changed:
_, new_status_text = _get_status_info(updated)
message = f'Статус обновлён: {new_status_text}'
logger.info(f'Admin {admin.id} checked payment {method}/{payment_id}: {old_status} -> {updated.status}')
logger.info(
'Admin checked payment /',
admin_id=admin.id,
method=method,
payment_id=payment_id,
old_status=old_status,
status=updated.status,
)
else:
message = 'Статус не изменился'
+29 -14
View File
@@ -1,9 +1,9 @@
"""Admin routes for pinned messages in cabinet."""
import logging
import time
from datetime import datetime
from datetime import UTC, datetime
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -34,7 +34,7 @@ from ..schemas.pinned_messages import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/pinned-messages', tags=['Cabinet Admin Pinned Messages'])
@@ -186,7 +186,9 @@ async def create_pinned_message(
if payload.broadcast:
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} created pinned message #{msg.id} (broadcast={payload.broadcast})')
logger.info(
'Admin created pinned message # (broadcast=)', admin_id=admin.id, message_id=msg.id, broadcast=payload.broadcast
)
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
@@ -225,11 +227,11 @@ async def update_pinned_message(
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.utcnow()
msg.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(msg)
logger.info(f'Admin {admin.id} updated pinned message #{message_id}')
logger.info('Admin updated pinned message #', admin_id=admin.id, message_id=message_id)
return _serialize_pinned_message(msg)
@@ -253,7 +255,7 @@ async def update_pinned_message_settings(
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.utcnow()
msg.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(msg)
@@ -273,7 +275,7 @@ async def deactivate_active_message(
if not msg:
return None
logger.info(f'Admin {admin.id} deactivated pinned message #{msg.id}')
logger.info('Admin deactivated pinned message #', admin_id=admin.id, message_id=msg.id)
return _serialize_pinned_message(msg)
@@ -288,7 +290,12 @@ async def unpin_active_message(
unpinned_count, failed_count, was_active = await unpin_active_pinned_message(_get_bot(), db)
if was_active:
logger.info(f'Admin {admin.id} unpinned active message: unpinned={unpinned_count}, failed={failed_count}')
logger.info(
'Admin unpinned active message: unpinned=, failed',
admin_id=admin.id,
unpinned_count=unpinned_count,
failed_count=failed_count,
)
return PinnedMessageUnpinResponse(
unpinned_count=unpinned_count,
@@ -325,11 +332,11 @@ async def activate_pinned_message(
await db.execute(
update(PinnedMessage)
.where(PinnedMessage.is_active.is_(True))
.values(is_active=False, updated_at=datetime.utcnow())
.values(is_active=False, updated_at=datetime.now(UTC))
)
msg.is_active = True
msg.updated_at = datetime.utcnow()
msg.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(msg)
@@ -339,7 +346,9 @@ async def activate_pinned_message(
if broadcast:
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} activated pinned message #{message_id} (broadcast={broadcast})')
logger.info(
'Admin activated pinned message # (broadcast=)', admin_id=admin.id, message_id=message_id, broadcast=broadcast
)
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
@@ -364,7 +373,13 @@ async def broadcast_message(
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} broadcast pinned message #{message_id}: sent={sent_count}, failed={failed_count}')
logger.info(
'Admin broadcast pinned message #: sent=, failed',
admin_id=admin.id,
message_id=message_id,
sent_count=sent_count,
failed_count=failed_count,
)
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
@@ -394,4 +409,4 @@ async def delete_pinned_message(
await db.delete(msg)
await db.commit()
logger.info(f'Admin {admin.id} deleted pinned message #{message_id}')
logger.info('Admin deleted pinned message #', admin_id=admin.id, message_id=message_id)
+5 -13
View File
@@ -3,10 +3,10 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from typing import Any
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -37,7 +37,7 @@ from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/promo-offers', tags=['Admin Promo Offers'])
@@ -430,7 +430,7 @@ async def _send_promo_notifications(
async def send_single(user: User, offer: DiscountOffer) -> bool:
# Skip email-only users (no telegram_id)
if not user.telegram_id:
logger.debug(f'Skipping promo notification for email-only user {user.id}')
logger.debug('Skipping promo notification for email-only user', user_id=user.id)
return False
async with semaphore:
@@ -459,18 +459,10 @@ async def _send_promo_notifications(
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
logger.warning(
'Failed to send promo notification to user %s: %s',
user.telegram_id,
exc,
)
logger.warning('Failed to send promo notification to user', telegram_id=user.telegram_id, exc=exc)
return False
except Exception as exc:
logger.error(
'Error sending promo notification to user %s: %s',
user.telegram_id,
exc,
)
logger.error('Error sending promo notification to user', telegram_id=user.telegram_id, exc=exc)
return False
# Send in batches
+2 -2
View File
@@ -162,9 +162,9 @@ def _normalize_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is not None and value.utcoffset() is not None:
return value.astimezone(UTC).replace(tzinfo=None)
return value.astimezone(UTC)
if value.tzinfo is not None:
return value.replace(tzinfo=None)
return value
return value
+37 -22
View File
@@ -1,9 +1,9 @@
"""Admin routes for RemnaWave management in cabinet."""
import logging
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -76,7 +76,7 @@ except Exception:
remnawave_sync_service = None
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/remnawave', tags=['Cabinet Admin RemnaWave'])
@@ -109,7 +109,10 @@ def _parse_datetime(value: Any) -> datetime | None:
return value
if isinstance(value, str):
try:
return datetime.fromisoformat(value)
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed
except ValueError:
return None
return None
@@ -338,7 +341,7 @@ async def get_node_usage(
service = _get_service()
_ensure_configured(service)
end_dt = end or datetime.utcnow()
end_dt = end or datetime.now(UTC)
start_dt = start or (end_dt - timedelta(days=7))
if start_dt >= end_dt:
@@ -380,7 +383,9 @@ async def perform_node_action(
}
if success:
logger.info(f'Admin {admin.telegram_id} performed {payload.action} on node {node_uuid}')
logger.info(
'Admin performed on node', telegram_id=admin.telegram_id, action=payload.action, node_uuid=node_uuid
)
return NodeActionResponse(
success=True,
message=messages.get(payload.action, 'Action completed'),
@@ -403,7 +408,7 @@ async def restart_all_nodes(
success = await service.restart_all_nodes()
if success:
logger.info(f'Admin {admin.telegram_id} restarted all nodes')
logger.info('Admin restarted all nodes', telegram_id=admin.telegram_id)
return NodeActionResponse(success=True, message='All nodes restart initiated')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -510,7 +515,9 @@ async def create_squad(
squad_uuid = await service.create_squad(payload.name, payload.inbound_uuids)
if squad_uuid:
logger.info(f'Admin {admin.telegram_id} created squad {payload.name} ({squad_uuid})')
logger.info(
'Admin created squad', telegram_id=admin.telegram_id, payload_name=payload.name, squad_uuid=squad_uuid
)
return SquadOperationResponse(
success=True,
message='Squad created successfully',
@@ -545,7 +552,7 @@ async def update_squad(
)
if success:
logger.info(f'Admin {admin.telegram_id} updated squad {squad_uuid}')
logger.info('Admin updated squad', telegram_id=admin.telegram_id, squad_uuid=squad_uuid)
return SquadOperationResponse(success=True, message='Squad updated')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -594,7 +601,7 @@ async def perform_squad_action(
message = 'Inbounds updated' if success else 'Failed to update inbounds'
if success:
logger.info(f'Admin {admin.telegram_id} performed {action} on squad {squad_uuid}')
logger.info('Admin performed on squad', telegram_id=admin.telegram_id, action=action, squad_uuid=squad_uuid)
return SquadOperationResponse(success=success, message=message)
@@ -611,7 +618,7 @@ async def delete_squad(
success = await service.delete_squad(squad_uuid)
if success:
logger.info(f'Admin {admin.telegram_id} deleted squad {squad_uuid}')
logger.info('Admin deleted squad', telegram_id=admin.telegram_id, squad_uuid=squad_uuid)
return SquadOperationResponse(success=True, message='Squad deleted')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -699,7 +706,9 @@ async def migrate_squad_users(
error=result.get('error'),
)
logger.info(f'Admin {admin.telegram_id} migrated users from {source_uuid} to {target_uuid}')
logger.info(
'Admin migrated users from to', telegram_id=admin.telegram_id, source_uuid=source_uuid, target_uuid=target_uuid
)
return MigrationResponse(
success=True,
@@ -782,14 +791,14 @@ async def toggle_auto_sync(
if payload.enabled and not current_status.enabled:
# Enable - would need to update settings and refresh schedule
remnawave_sync_service.schedule_refresh(run_immediately=True)
logger.info(f'Admin {admin.telegram_id} enabled auto sync')
logger.info('Admin enabled auto sync', telegram_id=admin.telegram_id)
return SyncResponse(
success=True,
message='Auto sync enabled and scheduled',
)
if not payload.enabled and current_status.enabled:
# Disable - would need to update settings and stop scheduler
logger.info(f'Admin {admin.telegram_id} disabled auto sync')
logger.info('Admin disabled auto sync', telegram_id=admin.telegram_id)
return SyncResponse(
success=True,
message='Auto sync setting change requested. Restart may be required.',
@@ -811,7 +820,7 @@ async def run_auto_sync_now(
detail='Auto sync service is not available',
)
logger.info(f'Admin {admin.telegram_id} triggered manual sync')
logger.info('Admin triggered manual sync', telegram_id=admin.telegram_id)
result = await remnawave_sync_service.run_sync_now(reason='manual')
return AutoSyncRunResponse(
@@ -839,7 +848,7 @@ async def sync_from_panel(
try:
stats = await service.sync_users_from_panel(db, payload.mode)
logger.info(f'Admin {admin.telegram_id} synced from panel (mode: {payload.mode})')
logger.info('Admin synced from panel (mode: )', telegram_id=admin.telegram_id, mode=payload.mode)
return SyncResponse(
success=True,
message='Sync from panel completed',
@@ -862,7 +871,7 @@ async def sync_to_panel(
_ensure_configured(service)
stats = await service.sync_users_to_panel(db)
logger.info(f'Admin {admin.telegram_id} synced to panel')
logger.info('Admin synced to panel', telegram_id=admin.telegram_id)
return SyncResponse(
success=True,
@@ -892,9 +901,15 @@ async def sync_servers(
try:
await cache.delete_pattern('available_countries*')
except Exception as e:
logger.warning(f'Failed to clear countries cache: {e}')
logger.warning('Failed to clear countries cache', error=e)
logger.info(f'Admin {admin.telegram_id} synced servers: created={created}, updated={updated}, removed={removed}')
logger.info(
'Admin synced servers: created=, updated=, removed',
telegram_id=admin.telegram_id,
created=created,
updated=updated,
removed=removed,
)
return SyncResponse(
success=True,
@@ -918,7 +933,7 @@ async def validate_subscriptions(
_ensure_configured(service)
stats = await service.validate_and_fix_subscriptions(db)
logger.info(f'Admin {admin.telegram_id} validated subscriptions')
logger.info('Admin validated subscriptions', telegram_id=admin.telegram_id)
return SyncResponse(
success=True,
@@ -937,7 +952,7 @@ async def cleanup_subscriptions(
_ensure_configured(service)
stats = await service.cleanup_orphaned_subscriptions(db)
logger.info(f'Admin {admin.telegram_id} cleaned up subscriptions')
logger.info('Admin cleaned up subscriptions', telegram_id=admin.telegram_id)
return SyncResponse(
success=True,
@@ -956,7 +971,7 @@ async def sync_subscription_statuses(
_ensure_configured(service)
stats = await service.sync_subscription_statuses(db)
logger.info(f'Admin {admin.telegram_id} synced subscription statuses')
logger.info('Admin synced subscription statuses', telegram_id=admin.telegram_id)
return SyncResponse(
success=True,
+7 -8
View File
@@ -1,7 +1,6 @@
"""Admin routes for managing servers in cabinet."""
import logging
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import String, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -31,7 +30,7 @@ from ..schemas.servers import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/servers', tags=['Cabinet Admin Servers'])
@@ -184,7 +183,7 @@ async def update_existing_server(
if request.promo_group_ids is not None:
await update_server_squad_promo_groups(db, server_id, request.promo_group_ids)
logger.info(f'Admin {admin.id} updated server {server_id}')
logger.info('Admin updated server', admin_id=admin.id, server_id=server_id)
return await get_server(server_id, admin, db)
@@ -207,7 +206,7 @@ async def toggle_server(
await update_server_squad(db, server_id, is_available=new_status)
status_text = 'enabled' if new_status else 'disabled'
logger.info(f'Admin {admin.id} {status_text} server {server_id}')
logger.info('Admin server', admin_id=admin.id, status_text=status_text, server_id=server_id)
return ServerToggleResponse(
id=server_id,
@@ -234,7 +233,7 @@ async def toggle_server_trial(
await update_server_squad(db, server_id, is_trial_eligible=new_status)
status_text = 'enabled for trial' if new_status else 'disabled for trial'
logger.info(f'Admin {admin.id} {status_text} server {server_id}')
logger.info('Admin server', admin_id=admin.id, status_text=status_text, server_id=server_id)
return ServerTrialToggleResponse(
id=server_id,
@@ -311,7 +310,7 @@ async def sync_servers(
# Sync with database
created, updated, removed = await sync_with_remnawave(db, squads)
logger.info(f'Admin {admin.id} synced servers: +{created} ~{updated} -{removed}')
logger.info('Admin synced servers: + ~', admin_id=admin.id, created=created, updated=updated, removed=removed)
return ServerSyncResponse(
created=created,
@@ -323,7 +322,7 @@ async def sync_servers(
except HTTPException:
raise
except Exception as e:
logger.error(f'Failed to sync servers: {e}')
logger.error('Failed to sync servers', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Sync failed: {e!s}',
+4 -4
View File
@@ -1,8 +1,8 @@
"""Admin settings routes for cabinet - system configuration management."""
import logging
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,7 +16,7 @@ from app.services.system_settings_service import (
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/settings', tags=['Admin Settings'])
@@ -248,7 +248,7 @@ async def update_setting(
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
await db.commit()
logger.info(f'Admin {admin.telegram_id} updated setting {key} to {value}')
logger.info('Admin updated setting to', telegram_id=admin.telegram_id, key=key, value=value)
return _serialize_definition(definition)
@@ -270,5 +270,5 @@ async def reset_setting(
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
await db.commit()
logger.info(f'Admin {admin.telegram_id} reset setting {key}')
logger.info('Admin reset setting', telegram_id=admin.telegram_id, key=key)
return _serialize_definition(definition)
+23 -21
View File
@@ -1,10 +1,10 @@
"""Admin routes for statistics dashboard in cabinet."""
import logging
import sys
import time
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import and_, func, select
@@ -29,7 +29,7 @@ from app.services.version_service import version_service
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
_start_time = time.time()
@@ -258,7 +258,7 @@ async def get_dashboard_stats(
sub_stats = await get_subscriptions_statistics(db)
# Get financial statistics
now = datetime.utcnow()
now = datetime.now(UTC)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
trans_stats = await get_transactions_statistics(db, month_start, now)
@@ -317,7 +317,7 @@ async def get_dashboard_stats(
)
except Exception as e:
logger.error(f'Failed to get dashboard stats: {e}')
logger.error('Failed to get dashboard stats', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load dashboard statistics',
@@ -349,7 +349,7 @@ async def get_system_info(
subscriptions_active=subscriptions_active,
)
except Exception as e:
logger.error(f'Failed to get system info: {e}')
logger.error('Failed to get system info', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load system information',
@@ -364,7 +364,7 @@ async def get_nodes_status(
try:
return await _get_nodes_overview()
except Exception as e:
logger.error(f'Failed to get nodes status: {e}')
logger.error('Failed to get nodes status', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load nodes status',
@@ -382,7 +382,7 @@ async def restart_node(
success = await service.manage_node(node_uuid, 'restart')
if success:
logger.info(f'Admin {admin.id} restarted node {node_uuid}')
logger.info('Admin restarted node', admin_id=admin.id, node_uuid=node_uuid)
return {'success': True, 'message': 'Node restart initiated'}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -391,7 +391,7 @@ async def restart_node(
except HTTPException:
raise
except Exception as e:
logger.error(f'Failed to restart node {node_uuid}: {e}')
logger.error('Failed to restart node', node_uuid=node_uuid, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to restart node',
@@ -420,7 +420,7 @@ async def toggle_node(
success = await service.manage_node(node_uuid, action)
if success:
logger.info(f'Admin {admin.id} {action}d node {node_uuid}')
logger.info('Admin d node', admin_id=admin.id, action=action, node_uuid=node_uuid)
return {'success': True, 'message': f'Node {action}d', 'is_disabled': not is_disabled}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -429,7 +429,7 @@ async def toggle_node(
except HTTPException:
raise
except Exception as e:
logger.error(f'Failed to toggle node {node_uuid}: {e}')
logger.error('Failed to toggle node', node_uuid=node_uuid, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to toggle node',
@@ -480,7 +480,7 @@ async def _get_nodes_overview() -> NodesOverview:
nodes=node_statuses,
)
except Exception as e:
logger.warning(f'Failed to get nodes from RemnaWave: {e}')
logger.warning('Failed to get nodes from RemnaWave', error=e)
# Return empty data if RemnaWave is unavailable
return NodesOverview(
total=0,
@@ -503,7 +503,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
logger.info('📊 Нет тарифов в системе, пропускаем статистику')
return None
now = datetime.utcnow()
now = datetime.now(UTC)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
@@ -560,7 +560,9 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
)
purchased_month = month_result.scalar() or 0
logger.info(f"📊 Тариф '{tariff.name}': активных={active_count}, триал={trial_count}")
logger.info(
'📊 Тариф активных=, триал', tariff_name=tariff.name, active_count=active_count, trial_count=trial_count
)
tariff_items.append(
TariffStatItem(
@@ -576,7 +578,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
total_tariff_subscriptions += active_count
logger.info(f'📊 Всего подписок по тарифам: {total_tariff_subscriptions}')
logger.info('📊 Всего подписок по тарифам', total_tariff_subscriptions=total_tariff_subscriptions)
return TariffStats(
tariffs=tariff_items,
@@ -584,7 +586,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
)
except Exception as e:
logger.error(f'Failed to get tariff stats: {e}', exc_info=True)
logger.error('Failed to get tariff stats', error=e, exc_info=True)
return None
@@ -599,7 +601,7 @@ async def get_top_referrers(
):
"""Get top referrers with earnings breakdown by period."""
try:
now = datetime.utcnow()
now = datetime.now(UTC)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
@@ -800,7 +802,7 @@ async def get_top_referrers(
)
except Exception as e:
logger.error(f'Failed to get top referrers: {e}', exc_info=True)
logger.error('Failed to get top referrers', error=e, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load referrers statistics',
@@ -857,7 +859,7 @@ async def get_top_campaigns(
)
except Exception as e:
logger.error(f'Failed to get top campaigns: {e}', exc_info=True)
logger.error('Failed to get top campaigns', error=e, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load campaigns statistics',
@@ -872,7 +874,7 @@ async def get_recent_payments(
):
"""Get recent payments with user info."""
try:
now = datetime.utcnow()
now = datetime.now(UTC)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
@@ -996,7 +998,7 @@ async def get_recent_payments(
)
except Exception as e:
logger.error(f'Failed to get recent payments: {e}', exc_info=True)
logger.error('Failed to get recent payments', error=e, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load recent payments',
+15 -10
View File
@@ -1,7 +1,6 @@
"""Admin routes for managing tariffs in cabinet."""
import logging
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -38,7 +37,7 @@ from ..schemas.tariffs import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/tariffs', tags=['Cabinet Admin Tariffs'])
@@ -169,7 +168,7 @@ async def update_tariff_order(
await reorder_tariffs(db, request.tariff_ids)
await db.commit()
logger.info(f'Admin {admin.id} updated tariff order: {request.tariff_ids}')
logger.info('Admin updated tariff order', admin_id=admin.id, tariff_ids=request.tariff_ids)
return {'message': 'Tariff order updated successfully'}
@@ -295,7 +294,7 @@ async def create_new_tariff(
traffic_reset_mode=request.traffic_reset_mode,
)
logger.info(f'Admin {admin.id} created tariff {tariff.id}: {tariff.name}')
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
@@ -390,7 +389,7 @@ async def update_existing_tariff(
if request.promo_group_ids is not None:
await set_tariff_promo_groups(db, tariff, request.promo_group_ids)
logger.info(f'Admin {admin.id} updated tariff {tariff_id}')
logger.info('Admin updated tariff', admin_id=admin.id, tariff_id=tariff_id)
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
@@ -414,7 +413,13 @@ async def delete_existing_tariff(
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
await delete_tariff(db, tariff)
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name} (affected subscriptions: {subs_count})')
logger.info(
'Admin deleted tariff (affected subscriptions: )',
admin_id=admin.id,
tariff_id=tariff_id,
tariff_name=tariff.name,
subs_count=subs_count,
)
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
@@ -440,7 +445,7 @@ async def toggle_tariff(
await update_tariff(db, tariff, is_active=new_status)
status_text = 'activated' if new_status else 'deactivated'
logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}')
logger.info('Admin tariff', admin_id=admin.id, status_text=status_text, tariff_id=tariff_id)
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
@@ -483,7 +488,7 @@ async def toggle_trial_tariff(
await update_tariff(db, tariff, is_trial_available=new_status)
status_text = 'set as trial' if new_status else 'removed from trial'
logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}')
logger.info('Admin tariff', admin_id=admin.id, status_text=status_text, tariff_id=tariff_id)
return TariffTrialResponse(
id=tariff_id,
@@ -530,7 +535,7 @@ async def get_tariff_stats(
# Calculate revenue from subscription payments for users on this tariff
revenue_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
.join(Subscription, Transaction.user_id == Subscription.user_id)
.where(
Subscription.tariff_id == tariff_id,
+13 -13
View File
@@ -1,9 +1,9 @@
"""Admin tickets routes for cabinet."""
import logging
import math
from datetime import datetime
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import desc, func, select
@@ -20,7 +20,7 @@ from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.tickets import TicketMessageResponse
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/tickets', tags=['Cabinet Admin Tickets'])
@@ -269,7 +269,7 @@ async def update_ticket_settings(
if request.sla_reminder_cooldown_minutes is not None:
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
if request.support_system_mode is not None:
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
SupportSettingsService.set_system_mode(request.support_system_mode.strip().lower())
# Update cabinet notification settings
if request.cabinet_user_notifications_enabled is not None:
@@ -317,7 +317,7 @@ async def update_ticket_settings(
env_file.write_text('\n'.join(new_lines) + '\n')
logger.info('Updated ticket settings in .env file')
except Exception as e:
logger.warning(f'Failed to update .env file: {e}')
logger.warning('Failed to update .env file', error=e)
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
@@ -447,13 +447,13 @@ async def reply_to_ticket(
user_id=ticket.user_id,
message_text=request.message,
is_from_admin=True,
created_at=datetime.utcnow(),
created_at=datetime.now(UTC),
)
db.add(message)
# Update ticket status to answered
ticket.status = 'answered'
ticket.updated_at = datetime.utcnow()
ticket.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(message)
@@ -473,11 +473,11 @@ async def reply_to_ticket(
await notify_user_about_ticket_reply(bot, ticket, request.message, db)
except Exception as e:
logger.warning(f'Failed to notify user about ticket reply: {e}')
logger.warning('Failed to notify user about ticket reply', error=e)
finally:
await bot.session.close()
except Exception as e:
logger.warning(f'Failed to send Telegram notification: {e}')
logger.warning('Failed to send Telegram notification', error=e)
# Уведомить пользователя в кабинете
try:
@@ -488,7 +488,7 @@ async def reply_to_ticket(
# Отправить WebSocket уведомление
await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or '')[:100])
except Exception as e:
logger.warning(f'Failed to create cabinet notification for admin reply: {e}')
logger.warning('Failed to create cabinet notification for admin reply', error=e)
return _message_to_response(message)
@@ -522,9 +522,9 @@ async def update_ticket_status(
)
ticket.status = request.status
ticket.updated_at = datetime.utcnow()
ticket.updated_at = datetime.now(UTC)
if request.status == 'closed':
ticket.closed_at = datetime.utcnow()
ticket.closed_at = datetime.now(UTC)
else:
ticket.closed_at = None
@@ -581,7 +581,7 @@ async def update_ticket_priority(
)
ticket.priority = request.priority
ticket.updated_at = datetime.utcnow()
ticket.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(ticket)
+12 -6
View File
@@ -3,10 +3,10 @@
import asyncio
import csv
import io
import logging
import time
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -32,7 +32,7 @@ from ..schemas.traffic import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/traffic', tags=['Admin Traffic'])
@@ -99,7 +99,13 @@ async def _aggregate_traffic(
user_uuids_set = set(user_uuids)
async with service.get_api_client() as api:
nodes = await api.get_all_nodes()
try:
nodes = await api.get_all_nodes()
except Exception:
logger.warning('Failed to fetch nodes for traffic aggregation', exc_info=True)
# Cache empty result to avoid hammering the failing API
_traffic_cache[cache_key] = (now, {}, [])
return {}, []
# Fetch per-node user stats — O(nodes) calls instead of O(users)
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
@@ -110,7 +116,7 @@ async def _aggregate_traffic(
stats = await api.get_bandwidth_stats_node_users_legacy(node.uuid, start_str, end_str)
return node.uuid, stats
except Exception:
logger.warning('Failed to get traffic for node %s', node.name, exc_info=True)
logger.warning('Failed to get traffic for node', node_name=node.name, exc_info=True)
return node.uuid, None
results = await asyncio.gather(*(fetch_node_users(n) for n in nodes))
@@ -383,7 +389,7 @@ async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int,
if not user_ids:
return {}
result = await db.execute(
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
.where(
and_(
Transaction.user_id.in_(user_ids),
@@ -685,7 +691,7 @@ async def export_traffic_csv(
caption=f'Traffic usage report ({period_label})\nUsers: {len(rows)}',
)
except Exception:
logger.error('Failed to send CSV to admin %s', admin.telegram_id, exc_info=True)
logger.error('Failed to send CSV to admin', telegram_id=admin.telegram_id, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to send CSV report. Please try again later.',
+8 -8
View File
@@ -1,9 +1,9 @@
"""Admin routes for version and release information."""
import logging
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import aiohttp
import structlog
from fastapi import APIRouter, Depends
from pydantic import BaseModel
@@ -13,7 +13,7 @@ from app.services.version_service import version_service
from ..dependencies import get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/updates', tags=['Cabinet Admin Updates'])
@@ -53,7 +53,7 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
global _cabinet_last_check
if not force and _cabinet_cache.get('releases') and _cabinet_last_check:
if datetime.now() - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
if datetime.now(UTC) - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
return _cabinet_cache['releases']
url = f'https://api.github.com/repos/{CABINET_REPO}/releases'
@@ -75,16 +75,16 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
}
)
_cabinet_cache['releases'] = releases
_cabinet_last_check = datetime.now()
logger.info('Fetched %d cabinet releases from GitHub', len(releases))
_cabinet_last_check = datetime.now(UTC)
logger.info('Fetched cabinet releases from GitHub', releases_count=len(releases))
return releases
logger.warning('GitHub API returned status %d for cabinet releases', response.status)
logger.warning('GitHub API returned status for cabinet releases', response_status=response.status)
return _cabinet_cache.get('releases', [])
except TimeoutError:
logger.warning('Timeout fetching cabinet releases from GitHub')
return _cabinet_cache.get('releases', [])
except Exception as e:
logger.error('Error fetching cabinet releases: %s', e)
logger.error('Error fetching cabinet releases', e=e)
return _cabinet_cache.get('releases', [])
+180 -125
View File
@@ -1,8 +1,8 @@
"""Admin routes for managing users in cabinet."""
import logging
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Integer, and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -35,7 +35,7 @@ from app.database.models import (
User,
UserStatus,
)
from app.utils.timezone import panel_datetime_to_naive_utc
from app.utils.timezone import panel_datetime_to_utc
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.users import (
@@ -91,7 +91,7 @@ from ..schemas.users import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/users', tags=['Cabinet Admin Users'])
@@ -144,9 +144,9 @@ def _build_subscription_info(subscription: Subscription, tariff_name: str | None
is_active = False
if subscription.end_date:
delta = subscription.end_date - datetime.utcnow()
delta = subscription.end_date - datetime.now(UTC)
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.now(UTC)
return UserSubscriptionInfo(
id=subscription.id,
@@ -174,7 +174,7 @@ async def _build_subscription_info_async(db: AsyncSession, subscription: Subscri
tariff_name = tariff.name
# Fetch traffic purchases
now = datetime.utcnow()
now = datetime.now(UTC)
tp_query = (
select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == subscription.id)
@@ -219,19 +219,19 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
service = RemnaWaveService()
if not service.is_configured:
logger.warning(f'Remnawave not configured, skipping panel sync for user {user.id}')
logger.warning('Remnawave not configured, skipping panel sync for user', user_id=user.id)
return {'skipped': True, 'reason': 'Remnawave not configured'}
is_active = (
subscription.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
and subscription.end_date
and subscription.end_date > datetime.utcnow()
and subscription.end_date > datetime.now(UTC)
)
panel_status = PanelUserStatus.ACTIVE if is_active else PanelUserStatus.DISABLED
expire_at = subscription.end_date
if expire_at and expire_at <= datetime.utcnow():
expire_at = datetime.utcnow() + timedelta(minutes=1)
if expire_at and expire_at <= datetime.now(UTC):
expire_at = datetime.now(UTC) + timedelta(minutes=1)
username = settings.format_remnawave_username(
full_name=user.full_name,
@@ -260,7 +260,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
if panel_uuid:
existing_user = await api.get_user_by_uuid(panel_uuid)
if not existing_user:
logger.warning(f'User {user.id} has stale remnawave_uuid {panel_uuid}, clearing')
logger.warning('User has stale remnawave_uuid clearing', user_id=user.id, panel_uuid=panel_uuid)
panel_uuid = None
user.remnawave_uuid = None
@@ -299,7 +299,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
try:
await api.update_user(**update_kwargs)
changes['action'] = 'updated'
logger.info(f'Updated user {user.id} in Remnawave panel')
logger.info('Updated user in Remnawave panel', user_id=user.id)
except Exception as update_error:
if hasattr(update_error, 'status_code') and update_error.status_code == 404:
panel_uuid = None # Will create new
@@ -310,7 +310,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
# Create new user
create_kwargs = {
'username': username,
'expire_at': expire_at or (datetime.utcnow() + timedelta(days=30)),
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
'status': panel_status,
'traffic_limit_bytes': traffic_limit_bytes,
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
@@ -328,15 +328,15 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
subscription.subscription_url = new_panel_user.subscription_url
changes['action'] = 'created'
changes['panel_uuid'] = new_panel_user.uuid
logger.info(f'Created user {user.id} in Remnawave panel: {new_panel_user.uuid}')
logger.info('Created user in Remnawave panel', user_id=user.id, uuid=new_panel_user.uuid)
user.last_remnawave_sync = datetime.utcnow()
user.last_remnawave_sync = datetime.now(UTC)
await db.commit()
return changes
except Exception as e:
logger.error(f'Error syncing user {user.id} to panel: {e}')
logger.error('Error syncing user to panel', user_id=user.id, error=e)
return {'error': str(e)}
@@ -421,7 +421,7 @@ async def get_users_stats(
func.cast(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > datetime.utcnow(),
Subscription.end_date > datetime.now(UTC),
),
Integer,
)
@@ -431,7 +431,7 @@ async def get_users_stats(
func.cast(
or_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= datetime.utcnow(),
Subscription.end_date <= datetime.now(UTC),
),
Integer,
)
@@ -456,7 +456,7 @@ async def get_users_stats(
avg_balance = int(balance_row.avg or 0) if balance_row else 0
# Get activity stats
now = datetime.utcnow()
now = datetime.now(UTC)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
@@ -575,12 +575,14 @@ async def get_user_detail(
transactions_result = await db.execute(transactions_q)
transactions = transactions_result.scalars().all()
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
recent_transactions = [
UserTransactionItem(
id=t.id,
type=t.type,
amount_kopeks=t.amount_kopeks,
amount_rubles=t.amount_kopeks / 100,
amount_kopeks=-t.amount_kopeks if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=-t.amount_kopeks / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
@@ -709,7 +711,7 @@ async def get_user_panel_info(
last_node_name = node.node_name
break
except Exception:
logger.warning(f'Failed to resolve node name for user {user_id}')
logger.warning('Failed to resolve node name for user', user_id=user_id)
return UserPanelInfoResponse(
found=True,
@@ -728,7 +730,7 @@ async def get_user_panel_info(
)
except Exception as e:
logger.error(f'Error getting panel info for user {user_id}: {e}')
logger.error('Error getting panel info for user', user_id=user_id, error=e)
return UserPanelInfoResponse(found=False)
@@ -756,7 +758,7 @@ async def get_user_node_usage(
if not service.is_configured:
return UserNodeUsageResponse(items=[])
end_date = datetime.utcnow()
end_date = datetime.now(UTC)
start_date = end_date - timedelta(days=30)
start_str = start_date.strftime('%Y-%m-%d')
end_str = end_date.strftime('%Y-%m-%d')
@@ -812,7 +814,7 @@ async def get_user_node_usage(
return UserNodeUsageResponse(items=items, categories=categories)
except Exception as e:
logger.error(f'Error getting node usage for user {user_id}: {e}')
logger.error('Error getting node usage for user', user_id=user_id, error=e)
return UserNodeUsageResponse(items=[])
@@ -877,8 +879,12 @@ async def update_user_balance(
await db.refresh(user)
logger.info(
f'Admin {admin.id} updated balance for user {user_id}: '
f'{old_balance} -> {user.balance_kopeks} ({request.amount_kopeks:+d})'
'Admin updated balance for user',
admin_id=admin.id,
user_id=user_id,
old_balance=old_balance,
balance_kopeks=user.balance_kopeks,
amount_kopeks=format(request.amount_kopeks, '+d'),
)
return UpdateBalanceResponse(
@@ -962,7 +968,7 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, new_sub)
logger.info(f'Admin {admin.id} created subscription for user {user_id}')
logger.info('Admin created subscription for user', admin_id=admin.id, user_id=user_id)
return UpdateSubscriptionResponse(
success=True,
@@ -989,7 +995,9 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} extended subscription for user {user_id} by {request.days} days')
logger.info(
'Admin extended subscription for user by days', admin_id=admin.id, user_id=user_id, days=request.days
)
return UpdateSubscriptionResponse(
success=True,
@@ -1005,7 +1013,7 @@ async def update_user_subscription(
)
subscription.end_date = request.end_date
if request.end_date > datetime.utcnow():
if request.end_date > datetime.now(UTC):
subscription.status = SubscriptionStatus.ACTIVE.value
else:
subscription.status = SubscriptionStatus.EXPIRED.value
@@ -1016,7 +1024,7 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} set end_date for user {user_id} subscription')
logger.info('Admin set end_date for user subscription', admin_id=admin.id, user_id=user_id)
return UpdateSubscriptionResponse(
success=True,
@@ -1050,7 +1058,7 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} changed tariff for user {user_id} to {tariff.name}')
logger.info('Admin changed tariff for user to', admin_id=admin.id, user_id=user_id, tariff_name=tariff.name)
return UpdateSubscriptionResponse(
success=True,
@@ -1071,7 +1079,7 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} updated traffic for user {user_id}')
logger.info('Admin updated traffic for user', admin_id=admin.id, user_id=user_id)
return UpdateSubscriptionResponse(
success=True,
@@ -1091,7 +1099,7 @@ async def update_user_subscription(
await db.refresh(subscription)
state = 'enabled' if request.autopay_enabled else 'disabled'
logger.info(f'Admin {admin.id} {state} autopay for user {user_id}')
logger.info('Admin autopay for user', admin_id=admin.id, state=state, user_id=user_id)
return UpdateSubscriptionResponse(
success=True,
@@ -1101,14 +1109,14 @@ async def update_user_subscription(
if request.action == 'cancel':
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.end_date = datetime.utcnow()
subscription.end_date = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} cancelled subscription for user {user_id}')
logger.info('Admin cancelled subscription for user', admin_id=admin.id, user_id=user_id)
return UpdateSubscriptionResponse(
success=True,
@@ -1118,16 +1126,16 @@ async def update_user_subscription(
if request.action == 'activate':
subscription.status = SubscriptionStatus.ACTIVE.value
if subscription.end_date and subscription.end_date <= datetime.utcnow():
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
# Extend by 30 days if expired
subscription.end_date = datetime.utcnow() + timedelta(days=30)
subscription.end_date = datetime.now(UTC) + timedelta(days=30)
await db.commit()
await db.refresh(subscription)
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} activated subscription for user {user_id}')
logger.info('Admin activated subscription for user', admin_id=admin.id, user_id=user_id)
return UpdateSubscriptionResponse(
success=True,
@@ -1151,7 +1159,7 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} added {request.traffic_gb} GB traffic for user {user_id}')
logger.info('Admin added traffic for user', admin_id=admin.id, traffic_gb=request.traffic_gb, user_id=user_id)
return UpdateSubscriptionResponse(
success=True,
@@ -1190,7 +1198,7 @@ async def update_user_subscription(
await db.delete(traffic_purchase)
# Recalculate traffic_reset_at from remaining active purchases
now = datetime.utcnow()
now = datetime.now(UTC)
remaining_query = select(TrafficPurchase).where(
TrafficPurchase.subscription_id == subscription.id,
TrafficPurchase.expires_at > now,
@@ -1211,7 +1219,11 @@ async def update_user_subscription(
await _sync_subscription_to_panel(db, user, subscription)
logger.info(
f'Admin {admin.id} removed traffic purchase {request.traffic_purchase_id} ({removed_gb} GB) for user {user_id}'
'Admin removed traffic purchase ( GB) for user',
admin_id=admin.id,
traffic_purchase_id=request.traffic_purchase_id,
removed_gb=removed_gb,
user_id=user_id,
)
return UpdateSubscriptionResponse(
@@ -1234,7 +1246,9 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} set device limit to {request.device_limit} for user {user_id}')
logger.info(
'Admin set device limit to for user', admin_id=admin.id, device_limit=request.device_limit, user_id=user_id
)
return UpdateSubscriptionResponse(
success=True,
@@ -1376,7 +1390,7 @@ async def update_user_status(
)
user.status = new_status
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
@@ -1384,7 +1398,7 @@ async def update_user_status(
if request.reason:
action += f' (reason: {request.reason})'
logger.info(f'Admin {admin.id} changed status for user {user_id}: {action}')
logger.info('Admin changed status for user', admin_id=admin.id, user_id=user_id, action=action)
return UpdateUserStatusResponse(
success=True,
@@ -1444,13 +1458,16 @@ async def update_user_restrictions(
if request.restriction_reason is not None:
user.restriction_reason = request.restriction_reason
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
logger.info(
f'Admin {admin.id} updated restrictions for user {user_id}: '
f'topup={user.restriction_topup}, subscription={user.restriction_subscription}'
'Admin updated restrictions for user topup=, subscription',
admin_id=admin.id,
user_id=user_id,
restriction_topup=user.restriction_topup,
restriction_subscription=user.restriction_subscription,
)
return UpdateRestrictionsResponse(
@@ -1496,12 +1513,16 @@ async def update_user_promo_group(
promo_group_name = promo_group.name
user.promo_group_id = new_promo_group_id
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
logger.info(
f'Admin {admin.id} changed promo group for user {user_id}: {old_promo_group_id} -> {new_promo_group_id}'
'Admin changed promo group for user',
admin_id=admin.id,
user_id=user_id,
old_promo_group_id=old_promo_group_id,
new_promo_group_id=new_promo_group_id,
)
return UpdatePromoGroupResponse(
@@ -1533,11 +1554,15 @@ async def update_user_referral_commission(
old_commission = user.referral_commission_percent
user.referral_commission_percent = request.commission_percent
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
logger.info(
f'Admin {admin.id} changed referral commission for user {user_id}: {old_commission} -> {request.commission_percent}'
'Admin changed referral commission for user',
admin_id=admin.id,
user_id=user_id,
old_commission=old_commission,
commission_percent=request.commission_percent,
)
return UpdateReferralCommissionResponse(
@@ -1600,7 +1625,7 @@ async def get_user_devices(
)
except Exception as e:
logger.error(f'Error fetching devices for user {user_id}: {e}')
logger.error('Error fetching devices for user', user_id=user_id, error=e)
return UserDevicesResponse()
@@ -1627,12 +1652,12 @@ async def delete_user_device(
success = await api.remove_device(user.remnawave_uuid, hwid)
if success:
logger.info(f'Admin {admin.id} deleted device {hwid} for user {user_id}')
logger.info('Admin deleted device for user', admin_id=admin.id, hwid=hwid, user_id=user_id)
return DeleteDeviceResponse(success=True, message='Device deleted', deleted_hwid=hwid)
return DeleteDeviceResponse(success=False, message='Failed to delete device')
except Exception as e:
logger.error(f'Error deleting device {hwid} for user {user_id}: {e}')
logger.error('Error deleting device for user', hwid=hwid, user_id=user_id, error=e)
return DeleteDeviceResponse(success=False, message=str(e))
@@ -1672,11 +1697,11 @@ async def reset_user_devices(
except Exception:
pass
logger.info(f'Admin {admin.id} reset devices for user {user_id}: {deleted}/{total}')
logger.info('Admin reset devices for user /', admin_id=admin.id, user_id=user_id, deleted=deleted, total=total)
return ResetDevicesResponse(success=True, message=f'Deleted {deleted}/{total} devices', deleted_count=deleted)
except Exception as e:
logger.error(f'Error resetting devices for user {user_id}: {e}')
logger.error('Error resetting devices for user', user_id=user_id, error=e)
return ResetDevicesResponse(success=False, message=str(e))
@@ -1713,7 +1738,7 @@ async def delete_user(
action = 'permanently deleted'
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} {action} user {user_id}{reason_text}')
logger.info('Admin user', admin_id=admin.id, action=action, user_id=user_id, reason_text=reason_text)
return DeleteUserResponse(
success=True,
@@ -1748,15 +1773,18 @@ async def full_delete_user(
panel_error: str | None = None
deleted_from_panel = False
# Pre-fetch admin.id to avoid MissingGreenlet after transaction rollback
admin_id_val = admin.id
# UserService.delete_user_account handles both bot DB and Remnawave panel
user_service = UserService()
success = await user_service.delete_user_account(db, user_id, admin.id)
success = await user_service.delete_user_account(db, user_id, admin_id_val)
if success:
deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} fully deleted user {user_id}{reason_text}')
logger.info('Admin fully deleted user', admin_id=admin_id_val, user_id=user_id, reason_text=reason_text)
return FullDeleteUserResponse(
success=success,
@@ -1793,33 +1821,42 @@ async def reset_user_trial(
# Delete subscription if exists
if user.subscription:
# Deactivate in Remnawave panel first
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
from app.database.crud.subscription import is_active_paid_subscription
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f'Disabled Remnawave user {user.remnawave_uuid} for trial reset')
except Exception as e:
logger.warning(f'Failed to disable Remnawave user during trial reset: {e}')
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск удаления подписки и RemnaWave: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
else:
# Deactivate in Remnawave panel first
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
# Delete subscription from database
from sqlalchemy import delete
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
subscription_id = user.subscription.id
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
# Delete subscription from database
from sqlalchemy import delete
subscription_id = user.subscription.id
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
# Reset trial flag
user.has_used_trial = False
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} reset trial for user {user_id}{reason_text}')
logger.info('Admin reset trial for user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
return ResetTrialResponse(
success=True,
@@ -1863,6 +1900,21 @@ async def reset_user_subscription(
panel_deactivated=False,
)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск сброса подписки: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
return ResetSubscriptionResponse(
success=False,
message='Cannot reset active paid subscription. Subscription is still active and paid.',
subscription_deleted=False,
panel_deactivated=False,
)
# Deactivate in Remnawave panel if requested
if request.deactivate_in_panel and user.remnawave_uuid:
try:
@@ -1871,10 +1923,10 @@ async def reset_user_subscription(
subscription_service = SubscriptionService()
panel_deactivated = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
if panel_deactivated:
logger.info(f'Disabled Remnawave user {user.remnawave_uuid} for subscription reset')
logger.info('Disabled Remnawave user for subscription reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
panel_error = str(e)
logger.warning(f'Failed to disable Remnawave user during subscription reset: {e}')
logger.warning('Failed to disable Remnawave user during subscription reset', error=e)
# Delete subscription from database
from sqlalchemy import delete
@@ -1884,11 +1936,11 @@ async def reset_user_subscription(
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} reset subscription for user {user_id}{reason_text}')
logger.info('Admin reset subscription for user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
return ResetSubscriptionResponse(
success=True,
@@ -1925,34 +1977,42 @@ async def disable_user(
panel_deactivated = False
panel_error: str | None = None
# Deactivate subscription in panel
if user.remnawave_uuid:
# Deactivate subscription in panel (skip if active paid subscription)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск отключения RemnaWave: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
elif user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
panel_deactivated = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
if panel_deactivated:
logger.info(f'Disabled Remnawave user {user.remnawave_uuid}')
logger.info('Disabled Remnawave user', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
panel_error = str(e)
logger.warning(f'Failed to disable Remnawave user: {e}')
logger.warning('Failed to disable Remnawave user', error=e)
# Deactivate subscription in bot database
if user.subscription:
# Deactivate subscription in bot database (skip if active paid subscription)
if user.subscription and not is_active_paid_subscription(user.subscription):
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, user.subscription)
subscription_deactivated = True
logger.info(f'Deactivated subscription for user {user_id}')
logger.info('Deactivated subscription for user', user_id=user_id)
# Block user account
user.status = UserStatus.BLOCKED.value
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} disabled user {user_id}{reason_text}')
logger.info('Admin disabled user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
return DisableUserResponse(
success=True,
@@ -2039,12 +2099,14 @@ async def get_user_transactions(
result = await db.execute(query)
transactions = result.scalars().all()
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
items = [
UserTransactionItem(
id=t.id,
type=t.type,
amount_kopeks=t.amount_kopeks,
amount_rubles=t.amount_kopeks / 100,
amount_kopeks=-t.amount_kopeks if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=-t.amount_kopeks / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
@@ -2156,13 +2218,8 @@ async def get_user_sync_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
)
# Panel returns local time with misleading +00:00 offset
panel_end_utc = panel_datetime_to_naive_utc(panel_expire_at)
bot_end_utc = bot_sub_end_date if bot_sub_end_date.tzinfo else bot_sub_end_date
panel_end_utc = panel_datetime_to_utc(panel_expire_at)
diff_seconds = abs((bot_end_utc - panel_end_utc).total_seconds())
# Allow for timezone offset (3 hours = MSK) and small sync delays
@@ -2199,7 +2256,7 @@ async def get_user_sync_status(
differences.append(f'Squads mismatch ({", ".join(squad_diff_parts)})')
except Exception as e:
logger.warning(f'Failed to get panel data for user {user_id}: {e}')
logger.warning('Failed to get panel data for user', user_id=user_id, error=e)
differences.append(f'Error fetching panel data: {e!s}')
return PanelSyncStatusResponse(
@@ -2296,7 +2353,7 @@ async def sync_user_from_panel(
short_uuid=panel_user.short_uuid,
username=panel_user.username,
status=panel_user.status.value if panel_user.status else None,
expire_at=panel_datetime_to_naive_utc(panel_user.expire_at) if panel_user.expire_at else None,
expire_at=panel_datetime_to_utc(panel_user.expire_at) if panel_user.expire_at else None,
traffic_limit_gb=panel_user.traffic_limit_bytes / (1024**3) if panel_user.traffic_limit_bytes else 0,
traffic_used_gb=panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes else 0,
device_limit=panel_user.hwid_device_limit or 1,
@@ -2315,13 +2372,10 @@ async def sync_user_from_panel(
# Update end date (normalize timezone)
if panel_user.expire_at:
# Panel returns local time with misleading +00:00 offset
panel_expire_utc = panel_datetime_to_naive_utc(panel_user.expire_at)
panel_expire_utc = panel_datetime_to_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
)
if sub_end_naive != panel_expire_utc:
sub_end_utc = sub.end_date if sub.end_date and sub.end_date.tzinfo else sub.end_date
if sub_end_utc != panel_expire_utc:
changes['end_date'] = {
'old': sub.end_date.isoformat() if sub.end_date else None,
'new': panel_expire_utc.isoformat(),
@@ -2330,7 +2384,7 @@ async def sync_user_from_panel(
# Update status
panel_status_str = panel_user.status.value if panel_user.status else 'DISABLED'
now = datetime.utcnow()
now = datetime.now(UTC)
# 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:
@@ -2387,9 +2441,8 @@ async def sync_user_from_panel(
panel_traffic_limit = (
int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100
)
# Panel returns local time with misleading +00:00 offset
panel_expire_naive = panel_datetime_to_naive_utc(panel_user.expire_at)
days_remaining = max(1, (panel_expire_naive - datetime.utcnow()).days)
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
days_remaining = max(1, (panel_expire_utc - datetime.now(UTC)).days)
new_sub = await create_paid_subscription(
db=db,
@@ -2404,12 +2457,14 @@ async def sync_user_from_panel(
changes['subscription_created'] = True
# Update last sync time
user.last_remnawave_sync = datetime.utcnow()
user.updated_at = datetime.utcnow()
user.last_remnawave_sync = datetime.now(UTC)
user.updated_at = datetime.now(UTC)
await db.commit()
logger.info(f'Admin {admin.id} synced user {user_id} from panel. Changes: {list(changes.keys())}')
logger.info(
'Admin synced user from panel. Changes', admin_id=admin.id, user_id=user_id, value=list(changes.keys())
)
return SyncFromPanelResponse(
success=True,
@@ -2422,7 +2477,7 @@ async def sync_user_from_panel(
except HTTPException:
raise
except Exception as e:
logger.error(f'Error syncing user {user_id} from panel: {e}')
logger.error('Error syncing user from panel', user_id=user_id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Sync error: {e!s}',
@@ -2477,14 +2532,14 @@ async def sync_user_to_panel(
is_active = (
sub.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
and sub.end_date
and sub.end_date > datetime.utcnow()
and sub.end_date > datetime.now(UTC)
)
panel_status = PanelUserStatus.ACTIVE if is_active else PanelUserStatus.DISABLED
# Ensure expire_at is in future for panel
expire_at = sub.end_date
if expire_at and expire_at <= datetime.utcnow():
expire_at = datetime.utcnow() + timedelta(minutes=1)
if expire_at and expire_at <= datetime.now(UTC):
expire_at = datetime.now(UTC) + timedelta(minutes=1)
username = settings.format_remnawave_username(
full_name=user.full_name,
@@ -2510,7 +2565,7 @@ async def sync_user_to_panel(
if panel_uuid:
existing_user = await api.get_user_by_uuid(panel_uuid)
if not existing_user:
logger.warning(f'User {user.id} has stale remnawave_uuid {panel_uuid}, clearing')
logger.warning('User has stale remnawave_uuid clearing', user_id=user.id, panel_uuid=panel_uuid)
panel_uuid = None
user.remnawave_uuid = None
@@ -2570,7 +2625,7 @@ async def sync_user_to_panel(
# Create new user in panel
create_kwargs = {
'username': username,
'expire_at': expire_at or (datetime.utcnow() + timedelta(days=30)),
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
'status': panel_status,
'traffic_limit_bytes': traffic_limit_bytes,
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
@@ -2595,12 +2650,12 @@ async def sync_user_to_panel(
action = 'created'
# Update last sync time
user.last_remnawave_sync = datetime.utcnow()
user.updated_at = datetime.utcnow()
user.last_remnawave_sync = datetime.now(UTC)
user.updated_at = datetime.now(UTC)
await db.commit()
logger.info(f'Admin {admin.id} synced user {user_id} to panel. Action: {action}')
logger.info('Admin synced user to panel. Action', admin_id=admin.id, user_id=user_id, action=action)
return SyncToPanelResponse(
success=True,
@@ -2614,7 +2669,7 @@ async def sync_user_to_panel(
except HTTPException:
raise
except Exception as e:
logger.error(f'Error syncing user {user_id} to panel: {e}')
logger.error('Error syncing user to panel', user_id=user_id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Sync error: {e!s}',
+7 -7
View File
@@ -2,10 +2,10 @@
API роуты колеса удачи для администраторов.
"""
import logging
import math
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -35,7 +35,7 @@ from app.database.models import User
from app.services.wheel_service import wheel_service
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/wheel', tags=['Admin Fortune Wheel'])
@@ -107,7 +107,7 @@ async def update_admin_wheel_config(
config = await update_wheel_config(db, **update_data)
logger.info(f'🎡 Admin {admin.telegram_id} updated wheel config: {update_data}')
logger.info('🎡 Admin updated wheel config', telegram_id=admin.telegram_id, update_data=update_data)
# Возвращаем полную конфигурацию
prizes = await get_wheel_prizes(db, config.id, active_only=False)
@@ -211,7 +211,7 @@ async def create_prize(
promo_traffic_gb=request.promo_traffic_gb,
)
logger.info(f'🎁 Admin {admin.telegram_id} created prize: {prize.display_name}')
logger.info('🎁 Admin created prize', telegram_id=admin.telegram_id, display_name=prize.display_name)
return WheelPrizeAdminResponse(
id=prize.id,
@@ -261,7 +261,7 @@ async def update_prize(
detail='Prize not found',
)
logger.info(f'🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}')
logger.info('🎁 Admin updated prize', telegram_id=admin.telegram_id, prize_id=prize_id, update_data=update_data)
return WheelPrizeAdminResponse(
id=prize.id,
@@ -298,7 +298,7 @@ async def delete_prize_endpoint(
detail='Prize not found',
)
logger.info(f'🗑️ Admin {admin.telegram_id} deleted prize {prize_id}')
logger.info('🗑️ Admin deleted prize', telegram_id=admin.telegram_id, prize_id=prize_id)
@router.post('/prizes/reorder', status_code=status.HTTP_200_OK)
@@ -309,7 +309,7 @@ async def reorder_prizes(
):
"""Переупорядочить призы."""
await reorder_wheel_prizes(db, request.prize_ids)
logger.info(f'🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}')
logger.info('🔄 Admin reordered prizes', telegram_id=admin.telegram_id, prize_ids=request.prize_ids)
return {'success': True}
+302
View File
@@ -0,0 +1,302 @@
"""Admin routes for managing withdrawal requests in cabinet."""
import json
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
ReferralEarning,
User,
WithdrawalRequest,
WithdrawalRequestStatus,
)
from app.services.referral_withdrawal_service import referral_withdrawal_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.withdrawals import (
AdminApproveWithdrawalRequest,
AdminRejectWithdrawalRequest,
AdminWithdrawalDetailResponse,
AdminWithdrawalItem,
AdminWithdrawalListResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/withdrawals', tags=['Cabinet Admin Withdrawals'])
def _get_risk_level(risk_score: int) -> str:
"""Get risk level from score."""
if risk_score >= 70:
return 'critical'
if risk_score >= 50:
return 'high'
if risk_score >= 30:
return 'medium'
return 'low'
@router.get('', response_model=AdminWithdrawalListResponse)
async def list_withdrawals(
withdrawal_status: Literal['pending', 'approved', 'rejected', 'completed', 'cancelled'] | None = Query(
None, alias='status'
),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all withdrawal requests."""
query = select(WithdrawalRequest)
count_query = select(func.count()).select_from(WithdrawalRequest)
if withdrawal_status:
query = query.where(WithdrawalRequest.status == withdrawal_status)
count_query = count_query.where(WithdrawalRequest.status == withdrawal_status)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Pending stats
pending_count_result = await db.execute(
select(func.count())
.select_from(WithdrawalRequest)
.where(WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value)
)
pending_count = pending_count_result.scalar() or 0
pending_total_result = await db.execute(
select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value
)
)
pending_total = pending_total_result.scalar() or 0
query = query.order_by(desc(WithdrawalRequest.created_at)).offset(offset).limit(limit)
result = await db.execute(query)
withdrawals = result.scalars().all()
# Batch-fetch users to avoid N+1
user_ids = list({w.user_id for w in withdrawals})
if user_ids:
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
else:
users_map = {}
items = []
for w in withdrawals:
user = users_map.get(w.user_id)
items.append(
AdminWithdrawalItem(
id=w.id,
user_id=w.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
amount_kopeks=w.amount_kopeks,
amount_rubles=w.amount_kopeks / 100,
status=w.status,
risk_score=w.risk_score or 0,
risk_level=_get_risk_level(w.risk_score or 0),
payment_details=w.payment_details,
admin_comment=w.admin_comment,
created_at=w.created_at,
processed_at=w.processed_at,
)
)
return AdminWithdrawalListResponse(
items=items,
total=total,
pending_count=pending_count,
pending_total_kopeks=pending_total,
)
@router.get('/{withdrawal_id}', response_model=AdminWithdrawalDetailResponse)
async def get_withdrawal_detail(
withdrawal_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed withdrawal request with risk analysis."""
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Заявка не найдена',
)
user = await db.get(User, withdrawal.user_id)
# Parse risk analysis
risk_analysis = None
if withdrawal.risk_analysis:
try:
risk_analysis = json.loads(withdrawal.risk_analysis)
except (json.JSONDecodeError, TypeError):
pass
# Get referral stats
referral_count = await db.execute(
select(func.count()).select_from(User).where(User.referred_by_id == withdrawal.user_id)
)
total_earnings = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == withdrawal.user_id
)
)
return AdminWithdrawalDetailResponse(
id=withdrawal.id,
user_id=withdrawal.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
amount_kopeks=withdrawal.amount_kopeks,
amount_rubles=withdrawal.amount_kopeks / 100,
status=withdrawal.status,
risk_score=withdrawal.risk_score or 0,
risk_level=_get_risk_level(withdrawal.risk_score or 0),
risk_analysis=risk_analysis,
payment_details=withdrawal.payment_details,
admin_comment=withdrawal.admin_comment,
balance_kopeks=user.balance_kopeks if user else 0,
total_referrals=referral_count.scalar() or 0,
total_earnings_kopeks=total_earnings.scalar() or 0,
created_at=withdrawal.created_at,
processed_at=withdrawal.processed_at,
)
@router.post('/{withdrawal_id}/approve')
async def approve_withdrawal(
withdrawal_id: int,
request: AdminApproveWithdrawalRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a withdrawal request."""
success, error = await referral_withdrawal_service.approve_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about approval
try:
from aiogram import Bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
user = await db.get(User, withdrawal.user_id) if withdrawal else None
if user and withdrawal:
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
comment_text = f'\n{request.comment}' if request.comment else ''
tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
try:
await notification_delivery_service.notify_withdrawal_approved(
user=user,
amount_kopeks=withdrawal.amount_kopeks,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send withdrawal approval notification', error=e)
return {'success': True}
@router.post('/{withdrawal_id}/reject')
async def reject_withdrawal(
withdrawal_id: int,
request: AdminRejectWithdrawalRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a withdrawal request."""
success, error = await referral_withdrawal_service.reject_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error or 'Не удалось отклонить заявку',
)
# Notify user about rejection
try:
from aiogram import Bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
user = await db.get(User, withdrawal.user_id) if withdrawal else None
if user and withdrawal:
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
try:
await notification_delivery_service.notify_withdrawal_rejected(
user=user,
amount_kopeks=withdrawal.amount_kopeks,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send withdrawal rejection notification', error=e)
return {'success': True}
@router.post('/{withdrawal_id}/complete')
async def complete_withdrawal(
withdrawal_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark a withdrawal as completed (money transferred)."""
success, error = await referral_withdrawal_service.complete_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error or 'Не удалось завершить заявку',
)
return {'success': True}
+208 -42
View File
@@ -2,15 +2,19 @@
import asyncio
import hashlib
import logging
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.campaign import (
get_campaign_by_start_parameter,
get_campaign_registration_by_user,
)
from app.database.crud.user import (
clear_email_change_pending,
create_user,
@@ -23,9 +27,10 @@ from app.database.crud.user import (
verify_and_apply_email_change,
)
from app.database.models import CabinetRefreshToken, User
from app.services.campaign_service import AdvertisingCampaignService
from app.services.disposable_email_service import disposable_email_service
from app.services.referral_service import process_referral_registration
from app.utils.timezone import panel_datetime_to_naive_utc
from app.utils.timezone import panel_datetime_to_utc
from ..auth import (
create_access_token,
@@ -49,6 +54,7 @@ from ..auth.jwt_handler import get_refresh_token_expires_at
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.auth import (
AuthResponse,
CampaignBonusInfo,
EmailChangeRequest,
EmailChangeResponse,
EmailChangeVerifyRequest,
@@ -69,7 +75,7 @@ from ..services.email_service import email_service
from ..services.email_template_overrides import get_rendered_override
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/auth', tags=['Cabinet Auth'])
@@ -118,12 +124,6 @@ async def _store_refresh_token(
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
expires_at = get_refresh_token_expires_at()
# Check if token already exists (handles race conditions)
existing = await db.execute(select(CabinetRefreshToken).where(CabinetRefreshToken.token_hash == token_hash))
if existing.scalar_one_or_none():
# Token already stored, skip
return
token_record = CabinetRefreshToken(
user_id=user_id,
token_hash=token_hash,
@@ -133,9 +133,104 @@ async def _store_refresh_token(
db.add(token_record)
try:
await db.commit()
except Exception:
# Handle race condition if token was inserted between check and insert
except IntegrityError:
await db.rollback()
logger.debug('Refresh token already exists (duplicate)', user_id=user_id)
async def _process_campaign_bonus(
db: AsyncSession,
user: User,
campaign_slug: str | None,
) -> CampaignBonusInfo | None:
"""Process campaign bonus for user during auth. Never raises."""
if not campaign_slug:
return None
try:
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
if not campaign:
return None
# Skip if user IS the campaign partner — prevent self-referral
if campaign.partner_user_id and campaign.partner_user_id == user.id:
logger.debug(
'Skipping campaign attribution: user is the campaign partner',
user_id=user.id,
campaign_id=campaign.id,
)
return None
# Lock user row to prevent concurrent bonus application (race condition)
await db.execute(select(User).where(User.id == user.id).with_for_update())
existing = await get_campaign_registration_by_user(db, user.id)
if existing:
logger.debug('User already has campaign registration', user_id=user.id)
return None
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
if campaign.partner_user_id and not user.referred_by_id:
user.referred_by_id = campaign.partner_user_id
await db.flush()
try:
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=None)
logger.info(
'Referral set from campaign partner',
user_id=user.id,
partner_user_id=campaign.partner_user_id,
campaign_id=campaign.id,
)
except Exception as e:
logger.error('Failed to process referral from campaign partner', error=e)
service = AdvertisingCampaignService()
result = await service.apply_campaign_bonus(db, user, campaign)
if not result.success:
return None
# Refresh user to get updated balance after bonus
await db.refresh(user)
return CampaignBonusInfo(
campaign_name=campaign.name,
bonus_type=result.bonus_type or campaign.bonus_type,
balance_kopeks=result.balance_kopeks,
subscription_days=result.subscription_days,
tariff_name=result.tariff_name,
)
except Exception:
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
try:
await db.rollback()
# Re-fetch user so session stays usable for the caller
await db.refresh(user)
except Exception:
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
return None
async def _process_referral_code(
db: AsyncSession,
user: User,
referral_code: str | None,
) -> None:
"""Set referred_by_id for user if referral_code is valid. Never raises."""
if not referral_code or user.referred_by_id:
return
try:
referrer = await get_user_by_referral_code(db, referral_code)
if not referrer:
return
if referrer.id == user.id:
return
if referrer.email and user.email and referrer.email.lower() == user.email.lower():
return
user.referred_by_id = referrer.id
await db.flush()
await process_referral_registration(db, user.id, referrer.id, bot=None)
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
except Exception as e:
logger.error('Failed to process referral code', error=e, referral_code=referral_code)
async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -> None:
@@ -158,12 +253,12 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
panel_users = await api.get_user_by_email(user.email)
if not panel_users:
logger.debug(f'No subscription found in panel for email: {user.email}')
logger.debug('No subscription found in panel for email', email=user.email)
return
# Take first user if multiple found
panel_user = panel_users[0]
logger.info(f'Found subscription in panel for email {user.email}: {panel_user.uuid}')
logger.info('Found subscription in panel for email', email=user.email, uuid=panel_user.uuid)
# Link user to panel
user.remnawave_uuid = panel_user.uuid
@@ -175,7 +270,7 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
existing_sub = await get_subscription_by_user_id(db, user.id)
# Parse panel data — panel returns local time with misleading +00:00 offset
expire_at = panel_datetime_to_naive_utc(panel_user.expire_at)
expire_at = panel_datetime_to_utc(panel_user.expire_at)
traffic_limit_gb = panel_user.traffic_limit_bytes // (1024**3) if panel_user.traffic_limit_bytes > 0 else 0
traffic_used_gb = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes > 0 else 0
@@ -186,7 +281,7 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
device_limit = panel_user.hwid_device_limit or 1
# Determine status — expire_at is now naive UTC
current_time = datetime.now(UTC).replace(tzinfo=None)
current_time = datetime.now(UTC)
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
sub_status = SubscriptionStatus.ACTIVE
@@ -208,7 +303,10 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
existing_sub.device_limit = device_limit
existing_sub.is_trial = False # Panel subscription is not trial
logger.info(
f'Updated subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
'Updated subscription for email user squads: devices',
email=user.email,
connected_squads=connected_squads,
device_limit=device_limit,
)
else:
# Create new subscription (expire_at and current_time already naive UTC)
@@ -228,13 +326,16 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
)
db.add(new_sub)
logger.info(
f'Created subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
'Created subscription for email user squads: devices',
email=user.email,
connected_squads=connected_squads,
device_limit=device_limit,
)
await db.commit()
except Exception as e:
logger.warning(f'Failed to sync subscription from panel for {user.email}: {e}')
logger.warning('Failed to sync subscription from panel for', email=user.email, error=e)
# Don't rollback - it detaches user object and breaks subsequent operations
# The sync is non-critical, main verification already succeeded
@@ -273,9 +374,19 @@ async def auth_telegram(
tg_last_name = user_data.get('last_name')
tg_language = user_data.get('language_code', 'ru')
# Resolve referral code to referrer ID for new users
referrer_id = None
if request.referral_code and not user:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
if not user:
# Create new user from Telegram initData
logger.info(f'Creating new user from cabinet (initData): telegram_id={telegram_id}')
logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
user = await create_user(
db=db,
telegram_id=telegram_id,
@@ -283,8 +394,9 @@ async def auth_telegram(
first_name=tg_first_name,
last_name=tg_last_name,
language=tg_language,
referred_by_id=referrer_id,
)
logger.info(f'User created successfully: id={user.id}, telegram_id={user.telegram_id}')
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
else:
# Update user info from initData (like bot middleware does)
updated = False
@@ -298,7 +410,7 @@ async def auth_telegram(
user.last_name = tg_last_name
updated = True
if updated:
logger.info(f'User {user.id} profile updated from initData')
logger.info('User profile updated from initData', user_id=user.id)
if user.status != 'active':
raise HTTPException(
@@ -307,7 +419,7 @@ async def auth_telegram(
)
# Update last login
user.cabinet_last_login = datetime.utcnow()
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = _create_auth_response(user)
@@ -315,6 +427,14 @@ async def auth_telegram(
# Store refresh token
await _store_refresh_token(db, user.id, response.refresh_token)
# Process referral code (before campaign bonus, which may also set referrer)
await _process_referral_code(db, user, request.referral_code)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
return response
@@ -329,7 +449,7 @@ async def auth_telegram_widget(
This endpoint validates data from Telegram Login Widget and returns
JWT tokens for authenticated access.
"""
widget_data = request.model_dump()
widget_data = request.model_dump(exclude={'campaign_slug', 'referral_code'})
if not validate_telegram_login_widget(widget_data):
raise HTTPException(
@@ -339,9 +459,21 @@ async def auth_telegram_widget(
user = await get_user_by_telegram_id(db, request.id)
# Resolve referral code to referrer ID for new users
referrer_id = None
if request.referral_code and not user:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
if not user:
# Create new user from Telegram data
logger.info(f'Creating new user from cabinet: telegram_id={request.id}, username={request.username}')
logger.info(
'Creating new user from cabinet: telegram_id=, username', request_id=request.id, username=request.username
)
user = await create_user(
db=db,
telegram_id=request.id,
@@ -349,8 +481,9 @@ async def auth_telegram_widget(
first_name=request.first_name,
last_name=request.last_name,
language='ru',
referred_by_id=referrer_id,
)
logger.info(f'User created successfully: id={user.id}, telegram_id={user.telegram_id}')
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
if user.status != 'active':
raise HTTPException(
@@ -366,12 +499,20 @@ async def auth_telegram_widget(
if request.last_name != user.last_name:
user.last_name = request.last_name
user.cabinet_last_login = datetime.utcnow()
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = _create_auth_response(user)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process referral code (before campaign bonus, which may also set referrer)
await _process_referral_code(db, user, request.referral_code)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
return response
@@ -485,7 +626,7 @@ async def register_email_standalone(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid test email password',
)
logger.info(f'Test email registration: {request.email}')
logger.info('Test email registration', email=request.email)
# Check for disposable email
if disposable_email_service.is_disposable(request.email):
@@ -512,11 +653,17 @@ async def register_email_standalone(
if referrer:
# Защита от самореферала - нельзя регистрироваться по своему же коду
if referrer.email and referrer.email.lower() == request.email.lower():
logger.warning(f'Self-referral attempt blocked: email={request.email}, code={request.referral_code}')
logger.warning(
'Self-referral attempt blocked: email=, code',
email=request.email,
referral_code=request.referral_code,
)
referrer = None
else:
logger.info(
f'Found referrer for email registration: referrer_id={referrer.id}, code={request.referral_code}'
'Found referrer for email registration: referrer_id=, code',
referrer_id=referrer.id,
referral_code=request.referral_code,
)
# Создать пользователя
@@ -532,9 +679,9 @@ async def register_email_standalone(
# Для тестового email - автоматически верифицировать
if is_test_email:
user.email_verified = True
user.email_verified_at = datetime.utcnow()
user.email_verified_at = datetime.now(UTC)
await db.commit()
logger.info(f'Test email auto-verified: {request.email}, user_id={user.id}')
logger.info('Test email auto-verified: user_id', email=request.email, user_id=user.id)
else:
# Сгенерировать токен верификации
verification_token = generate_verification_token()
@@ -579,9 +726,11 @@ async def register_email_standalone(
if referrer:
try:
await process_referral_registration(db, user.id, referrer.id, bot=None)
logger.info(f'Processed referral registration: user_id={user.id}, referrer_id={referrer.id}')
logger.info(
'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id
)
except Exception as e:
logger.error(f'Failed to process referral registration: {e}')
logger.error('Failed to process referral registration', error=e)
# Не прерываем регистрацию из-за ошибки реферальной системы
# Для тестового email - сразу можно логиниться (уже verified)
@@ -617,10 +766,10 @@ async def verify_email(
# Mark email as verified
user.email_verified = True
user.email_verified_at = datetime.utcnow()
user.email_verified_at = datetime.now(UTC)
user.email_verification_token = None
user.email_verification_expires = None
user.cabinet_last_login = datetime.utcnow()
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
@@ -631,6 +780,11 @@ async def verify_email(
response = _create_auth_response(user)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
return response
@@ -724,7 +878,7 @@ async def login_email(
if not user:
# For test email - auto-create user if not exists
if is_test_email and settings.validate_test_email_password(request.email, request.password):
logger.info(f'Test email login - creating new user: {request.email}')
logger.info('Test email login creating new user', email=request.email)
password_hash = hash_password(request.password)
user = await create_user_by_email(
db=db,
@@ -734,7 +888,7 @@ async def login_email(
language='ru',
)
user.email_verified = True
user.email_verified_at = datetime.utcnow()
user.email_verified_at = datetime.now(UTC)
await db.commit()
else:
raise HTTPException(
@@ -767,12 +921,17 @@ async def login_email(
detail='User account is not active',
)
user.cabinet_last_login = datetime.utcnow()
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = _create_auth_response(user)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
return response
@@ -855,7 +1014,7 @@ async def logout(
token_record = result.scalar_one_or_none()
if token_record:
token_record.revoked_at = datetime.utcnow()
token_record.revoked_at = datetime.now(UTC)
await db.commit()
return {'message': 'Logged out successfully'}
@@ -1051,9 +1210,16 @@ async def request_email_change(
custom_body_html=custom_body,
)
except Exception as e:
logger.error(f'Failed to send verification email to {request.new_email} for user {user.id}: {e}')
logger.error(
'Failed to send verification email to for user',
new_email=request.new_email,
user_id=user.id,
error=e,
)
logger.info(f'Unverified email replaced for user {user.id}: {old_email} -> {request.new_email}')
logger.info(
'Unverified email replaced for user', user_id=user.id, old_email=old_email, new_email=request.new_email
)
return EmailChangeResponse(
message='Email replaced, verification sent to new address',
@@ -1104,7 +1270,7 @@ async def request_email_change(
detail='Email service is not configured',
)
logger.info(f'Email change requested for user {user.id}: {user.email} -> {request.new_email}')
logger.info('Email change requested for user', user_id=user.id, email=user.email, new_email=request.new_email)
return EmailChangeResponse(
message='Verification code sent to new email',
+10 -8
View File
@@ -1,11 +1,11 @@
"""Balance and payment routes for cabinet."""
import logging
import math
import time
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
import httpx
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -41,7 +41,7 @@ from ..schemas.balance import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/balance', tags=['Cabinet Balance'])
@@ -249,7 +249,7 @@ async def create_stars_invoice(
if stars_amount <= 0:
stars_amount = 1
except Exception as e:
logger.error(f'Error calculating Stars amount: {e}')
logger.error('Error calculating Stars amount', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to calculate Stars amount',
@@ -279,7 +279,7 @@ async def create_stars_invoice(
result = response.json()
if not result.get('ok'):
logger.error(f'Telegram API error: {result}')
logger.error('Telegram API error', result=result)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Stars invoice',
@@ -287,8 +287,10 @@ async def create_stars_invoice(
invoice_url = result['result']
logger.info(
f'Created Stars invoice for balance top-up: user={user.id}, '
f'amount={request.amount_kopeks} kopeks, stars={stars_amount}'
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
user_id=user.id,
amount_kopeks=request.amount_kopeks,
stars_amount=stars_amount,
)
return StarsInvoiceResponse(
@@ -298,7 +300,7 @@ async def create_stars_invoice(
)
except httpx.HTTPError as e:
logger.error(f'HTTP error creating Stars invoice: {e}')
logger.error('HTTP error creating Stars invoice', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to connect to Telegram API',
@@ -710,7 +712,7 @@ async def create_topup(
except HTTPException:
raise
except Exception as e:
logger.error(f'Payment creation error: {e}')
logger.error('Payment creation error', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create payment. Please try again later.',
+13 -13
View File
@@ -1,10 +1,10 @@
"""Branding routes for cabinet - logo, project name, and theme colors management."""
import json
import logging
import os
from pathlib import Path
import structlog
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
@@ -17,7 +17,7 @@ from app.database.models import SystemSetting, User
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/branding', tags=['Branding'])
@@ -307,7 +307,7 @@ async def update_branding_name(
await set_setting_value(db, BRANDING_NAME_KEY, name)
logger.info(f'Admin {admin.telegram_id} updated branding name to: {name}')
logger.info('Admin updated branding name to', telegram_id=admin.telegram_id, name=name)
# Return updated branding
custom_logo = has_custom_logo()
@@ -368,7 +368,7 @@ async def upload_logo(
# Mark that we have a custom logo
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
logger.info(f'Admin {admin.telegram_id} uploaded new logo: {logo_path}')
logger.info('Admin uploaded new logo', telegram_id=admin.telegram_id, logo_path=logo_path)
# Get current name for response
name = await get_setting_value(db, BRANDING_NAME_KEY)
@@ -398,7 +398,7 @@ async def delete_logo(
# Update setting
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
logger.info(f'Admin {admin.telegram_id} deleted custom logo')
logger.info('Admin deleted custom logo', telegram_id=admin.telegram_id)
# Get current name for response
name = await get_setting_value(db, BRANDING_NAME_KEY)
@@ -486,7 +486,7 @@ async def update_theme_colors(
# Save to database
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(current_colors))
logger.info(f'Admin {admin.telegram_id} updated theme colors: {list(update_data.keys())}')
logger.info('Admin updated theme colors', telegram_id=admin.telegram_id, value=list(update_data.keys()))
return ThemeColorsResponse(**current_colors)
@@ -500,7 +500,7 @@ async def reset_theme_colors(
# Save default colors
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(DEFAULT_THEME_COLORS))
logger.info(f'Admin {admin.telegram_id} reset theme colors to defaults')
logger.info('Admin reset theme colors to defaults', telegram_id=admin.telegram_id)
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
@@ -558,7 +558,7 @@ async def update_enabled_themes(
# Save to database
await set_setting_value(db, ENABLED_THEMES_KEY, json.dumps(current_themes))
logger.info(f'Admin {admin.telegram_id} updated enabled themes: {current_themes}')
logger.info('Admin updated enabled themes', telegram_id=admin.telegram_id, current_themes=current_themes)
return EnabledThemesResponse(**current_themes)
@@ -593,7 +593,7 @@ async def update_animation_enabled(
"""Update animation enabled setting. Admin only."""
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set animation enabled: {payload.enabled}')
logger.info('Admin set animation enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return AnimationEnabledResponse(enabled=payload.enabled)
@@ -628,7 +628,7 @@ async def update_fullscreen_enabled(
"""Update fullscreen enabled setting. Admin only."""
await set_setting_value(db, FULLSCREEN_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set fullscreen enabled: {payload.enabled}')
logger.info('Admin set fullscreen enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return FullscreenEnabledResponse(enabled=payload.enabled)
@@ -664,7 +664,7 @@ async def update_email_auth_enabled(
"""Update email auth enabled setting. Admin only."""
await set_setting_value(db, EMAIL_AUTH_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set email auth enabled: {payload.enabled}')
logger.info('Admin set email auth enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return EmailAuthEnabledResponse(enabled=payload.enabled)
@@ -719,7 +719,7 @@ async def update_analytics_counters(
if payload.google_ads_label is not None:
await set_setting_value(db, GOOGLE_ADS_LABEL_KEY, payload.google_ads_label.strip())
logger.info(f'Admin {admin.telegram_id} updated analytics counters')
logger.info('Admin updated analytics counters', telegram_id=admin.telegram_id)
# Return current state
yandex_id = await get_setting_value(db, YANDEX_METRIKA_ID_KEY) or ''
@@ -764,6 +764,6 @@ async def update_lite_mode_enabled(
"""Update lite mode enabled setting. Admin only."""
await set_setting_value(db, LITE_MODE_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set lite mode enabled: {payload.enabled}')
logger.info('Admin set lite mode enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return LiteModeEnabledResponse(enabled=payload.enabled)
+7 -7
View File
@@ -1,10 +1,10 @@
"""Contests routes for cabinet - user participation in games/contests."""
import logging
import random
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -30,7 +30,7 @@ from app.services.contest_rotation_service import (
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/contests', tags=['Cabinet Contests'])
@@ -102,11 +102,11 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
return 'Error: subscription not found'
subscription.end_date = subscription.end_date + timedelta(days=days)
subscription.updated_at = datetime.utcnow()
subscription.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
logger.info(f'🎁 Extended subscription for user {user_id} by {days} days (contest prize)')
logger.info('🎁 Extended subscription for user by days (contest prize)', user_id=user_id, days=days)
return f'Subscription extended by {days} days'
if prize_type == 'balance':
@@ -125,10 +125,10 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
await db.commit()
await db.refresh(user)
logger.info(f'🎁 Added {amount} to balance for user {user_id} (contest prize)')
logger.info('🎁 Added to balance for user (contest prize)', amount=amount, user_id=user_id)
return f'Balance increased by {amount}'
logger.warning(f'Unknown prize type: {prize_type}')
logger.warning('Unknown prize type', prize_type=prize_type)
return f"Prize type '{prize_type}' not supported"
+2 -3
View File
@@ -1,7 +1,6 @@
"""Info pages routes for cabinet - FAQ, rules, privacy policy, etc."""
import logging
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,7 +15,7 @@ from app.services.public_offer_service import PublicOfferService
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/info', tags=['Cabinet Info'])
+10 -5
View File
@@ -1,8 +1,8 @@
"""Media upload/download routes for cabinet tickets."""
import logging
import mimetypes
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -16,7 +16,7 @@ from app.database.models import User
from ..dependencies import get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/media', tags=['Cabinet Media'])
@@ -125,7 +125,12 @@ async def upload_media(
media_url = _build_media_url(request, media.file_id)
logger.info(f'User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}')
logger.info(
'User uploaded',
telegram_id=user.telegram_id,
media_type_normalized=media_type_normalized,
file_id=media.file_id,
)
return MediaUploadResponse(
media_type=media_type_normalized,
@@ -136,7 +141,7 @@ async def upload_media(
except HTTPException:
raise
except Exception as error:
logger.error(f'Failed to upload media for user {user.telegram_id}: {error}')
logger.error('Failed to upload media for user', telegram_id=user.telegram_id, error=error)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to upload media',
@@ -187,7 +192,7 @@ async def download_media(
except HTTPException:
raise
except Exception as error:
logger.error(f'Failed to download media {file_id}: {error}')
logger.error('Failed to download media', file_id=file_id, error=error)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to download media',
+4 -4
View File
@@ -1,9 +1,9 @@
"""Notification settings routes for cabinet."""
import logging
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import structlog
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,7 +13,7 @@ from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/notifications', tags=['Cabinet Notifications'])
@@ -112,7 +112,7 @@ async def update_notification_settings(
user.notification_settings = {}
user.notification_settings = new_settings
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
+58 -14
View File
@@ -1,8 +1,8 @@
"""OAuth 2.0 authentication routes for cabinet."""
import logging
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,6 +12,7 @@ from app.database.crud.user import (
create_user_by_oauth,
get_user_by_email,
get_user_by_oauth_provider,
get_user_by_referral_code,
set_user_oauth_provider_id,
)
from app.database.models import User
@@ -24,20 +25,35 @@ from ..auth.oauth_providers import (
)
from ..dependencies import get_cabinet_db
from ..schemas.auth import AuthResponse
from .auth import _create_auth_response, _store_refresh_token
from .auth import _create_auth_response, _process_campaign_bonus, _store_refresh_token
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
async def _finalize_oauth_login(db: AsyncSession, user: User, provider: str) -> AuthResponse:
async def _finalize_oauth_login(
db: AsyncSession,
user: User,
provider: str,
campaign_slug: str | None = None,
referral_code: str | None = None,
) -> AuthResponse:
"""Update last login, create tokens, store refresh token."""
user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None)
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
auth_response = _create_auth_response(user)
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
# Process referral code (before campaign bonus, which may also set referrer)
from .auth import _process_referral_code, _user_to_response
await _process_referral_code(db, user, referral_code)
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
if auth_response.campaign_bonus:
auth_response.user = _user_to_response(user)
return auth_response
@@ -61,6 +77,10 @@ class OAuthAuthorizeResponse(BaseModel):
class OAuthCallbackRequest(BaseModel):
code: str = Field(..., description='Authorization code from provider')
state: str = Field(..., description='CSRF state token')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
# --- Endpoints ---
@@ -120,7 +140,7 @@ async def oauth_callback(
try:
token_data = await oauth_provider.exchange_code(request.code)
except Exception as exc:
logger.error('OAuth code exchange failed for %s: %s', provider, exc)
logger.error('OAuth code exchange failed for', provider=provider, exc=exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to exchange authorization code',
@@ -130,7 +150,7 @@ async def oauth_callback(
try:
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
except Exception as exc:
logger.error('OAuth user info fetch failed for %s: %s', provider, exc)
logger.error('OAuth user info fetch failed for', provider=provider, exc=exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to fetch user information from provider',
@@ -139,18 +159,41 @@ async def oauth_callback(
# 5. Find user by provider ID
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
if user:
logger.info('OAuth login via %s for existing user %s', provider, user.id)
return await _finalize_oauth_login(db, user, provider)
logger.info('OAuth login via for existing user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
# 6. Find user by email (if verified) and link provider
if user_info.email and user_info.email_verified:
user = await get_user_by_email(db, user_info.email)
if user:
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
logger.info('OAuth login via %s linked to existing email user %s', provider, user.id)
return await _finalize_oauth_login(db, user, provider)
logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
# 7. Create new user
# 7. Resolve referral code for new user
referrer_id = None
if request.referral_code:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
# Self-referral protection by email
if (
user_info.email
and user_info.email_verified
and referrer.email
and referrer.email.lower() == user_info.email.lower()
):
logger.warning(
'Self-referral attempt blocked via OAuth',
referral_code=request.referral_code,
email=user_info.email,
)
else:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code during OAuth', referral_code=request.referral_code, error=e)
# 8. Create new user
user = await create_user_by_oauth(
db=db,
provider=provider,
@@ -160,6 +203,7 @@ async def oauth_callback(
first_name=user_info.first_name,
last_name=user_info.last_name,
username=user_info.username,
referred_by_id=referrer_id,
)
logger.info('OAuth new user created via %s with id=%s', provider, user.id)
return await _finalize_oauth_login(db, user, provider)
logger.info('OAuth new user created via with id', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
+162
View File
@@ -0,0 +1,162 @@
"""User-facing partner application routes for cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import AdvertisingCampaign, User
from app.services.partner_application_service import partner_application_service
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.partners import (
PartnerApplicationInfo,
PartnerApplicationRequest,
PartnerCampaignInfo,
PartnerStatusResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/referral/partner', tags=['Cabinet Partner'])
def _get_campaign_deep_link(start_parameter: str) -> str | None:
"""Generate Telegram deep link for campaign."""
bot_username = settings.get_bot_username()
if bot_username:
return f'https://t.me/{bot_username}?start={start_parameter}'
return None
def _get_campaign_web_link(start_parameter: str) -> str | None:
"""Generate web link for campaign."""
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
if base_url:
return f'{base_url}/?campaign={start_parameter}'
return None
@router.get('/status', response_model=PartnerStatusResponse)
async def get_partner_status(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get partner status and latest application for current user."""
latest_app = await partner_application_service.get_latest_application(db, user.id)
app_info = None
if latest_app:
app_info = PartnerApplicationInfo(
id=latest_app.id,
status=latest_app.status,
company_name=latest_app.company_name,
website_url=latest_app.website_url,
telegram_channel=latest_app.telegram_channel,
description=latest_app.description,
expected_monthly_referrals=latest_app.expected_monthly_referrals,
admin_comment=latest_app.admin_comment,
approved_commission_percent=latest_app.approved_commission_percent,
created_at=latest_app.created_at,
processed_at=latest_app.processed_at,
)
commission = user.referral_commission_percent
if commission is None and user.is_partner:
commission = settings.REFERRAL_COMMISSION_PERCENT
# Fetch campaigns assigned to this partner
campaigns: list[PartnerCampaignInfo] = []
if user.is_partner:
result = await db.execute(
select(AdvertisingCampaign).where(
AdvertisingCampaign.partner_user_id == user.id,
AdvertisingCampaign.is_active.is_(True),
)
)
for c in result.scalars().all():
campaigns.append(
PartnerCampaignInfo(
id=c.id,
name=c.name,
start_parameter=c.start_parameter,
bonus_type=c.bonus_type,
balance_bonus_kopeks=c.balance_bonus_kopeks or 0,
subscription_duration_days=c.subscription_duration_days,
subscription_traffic_gb=c.subscription_traffic_gb,
deep_link=_get_campaign_deep_link(c.start_parameter),
web_link=_get_campaign_web_link(c.start_parameter),
)
)
return PartnerStatusResponse(
partner_status=user.partner_status,
commission_percent=commission,
latest_application=app_info,
campaigns=campaigns,
)
@router.post('/apply', response_model=PartnerApplicationInfo)
async def apply_for_partner(
request: PartnerApplicationRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Submit partner application."""
application, error = await partner_application_service.submit_application(
db,
user_id=user.id,
company_name=request.company_name,
website_url=request.website_url,
telegram_channel=request.telegram_channel,
description=request.description,
expected_monthly_referrals=request.expected_monthly_referrals,
)
if not application:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Уведомляем админов о новой заявке
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_partner_application_notification(
user=user,
application_data={
'company_name': request.company_name,
'telegram_channel': request.telegram_channel,
'website_url': request.website_url,
'description': request.description,
'expected_monthly_referrals': request.expected_monthly_referrals,
},
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for partner application', error=e)
return PartnerApplicationInfo(
id=application.id,
status=application.status,
company_name=application.company_name,
website_url=application.website_url,
telegram_channel=application.telegram_channel,
description=application.description,
expected_monthly_referrals=application.expected_monthly_referrals,
admin_comment=application.admin_comment,
approved_commission_percent=application.approved_commission_percent,
created_at=application.created_at,
processed_at=application.processed_at,
)
+5 -5
View File
@@ -1,8 +1,8 @@
"""Polls routes for cabinet - user participation in polls/surveys."""
import logging
from datetime import datetime
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
@@ -20,7 +20,7 @@ from app.services.poll_service import get_next_question, get_question_option, re
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/polls', tags=['Cabinet Polls'])
@@ -247,7 +247,7 @@ async def start_poll(
# Mark as started if not already
if not response.started_at:
response.started_at = datetime.utcnow()
response.started_at = datetime.now(UTC)
await db.commit()
# Get next unanswered question
@@ -346,7 +346,7 @@ async def answer_question(
)
# Poll completed
response.completed_at = datetime.utcnow()
response.completed_at = datetime.now(UTC)
await db.commit()
# Award reward if any
+7 -7
View File
@@ -1,9 +1,9 @@
"""Promo offers routes for cabinet - personal discounts and offers."""
import logging
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import and_, select
@@ -22,7 +22,7 @@ from app.services.promo_offer_service import promo_offer_service
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/promo', tags=['Cabinet Promo'])
@@ -112,7 +112,7 @@ async def get_promo_offers(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available promo offers for the user."""
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(DiscountOffer)
@@ -151,7 +151,7 @@ async def get_active_discount(
expires_at = user.promo_offer_discount_expires_at
source = user.promo_offer_discount_source
now = datetime.utcnow()
now = datetime.now(UTC)
is_active = discount_percent > 0 and (expires_at is None or expires_at > now)
return ActiveDiscountInfo(
@@ -284,7 +284,7 @@ async def claim_promo_offer(
detail='Offer not found',
)
now = datetime.utcnow()
now = datetime.now(UTC)
if offer.claimed_at is not None:
raise HTTPException(
@@ -408,7 +408,7 @@ async def clear_active_discount(
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
+2 -3
View File
@@ -1,7 +1,6 @@
"""Promo code routes for cabinet."""
import logging
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,7 +11,7 @@ from app.services.promocode_service import PromoCodeService
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/promocode', tags=['Cabinet Promocode'])
+23 -7
View File
@@ -1,15 +1,15 @@
"""Referral program routes for cabinet."""
import logging
import math
import structlog
from fastapi import APIRouter, Depends, Query
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import ReferralEarning, User
from app.database.models import AdvertisingCampaign, ReferralEarning, User
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.referral import (
@@ -22,7 +22,7 @@ from ..schemas.referral import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/referral', tags=['Cabinet Referral'])
@@ -150,12 +150,26 @@ async def get_referral_earnings(
result = await db.execute(query)
earnings = result.scalars().all()
# Batch-fetch referral users to avoid N+1
referral_ids = list({e.referral_id for e in earnings if e.referral_id})
if referral_ids:
referral_users_result = await db.execute(select(User).where(User.id.in_(referral_ids)))
referral_users_map = {u.id: u for u in referral_users_result.scalars().all()}
else:
referral_users_map = {}
# Batch-fetch campaigns to avoid N+1
campaign_ids = list({e.campaign_id for e in earnings if e.campaign_id})
if campaign_ids:
campaigns_result = await db.execute(select(AdvertisingCampaign).where(AdvertisingCampaign.id.in_(campaign_ids)))
campaigns_map = {c.id: c for c in campaigns_result.scalars().all()}
else:
campaigns_map = {}
items = []
for e in earnings:
# Get referral user info
referral_query = select(User).where(User.id == e.referral_id)
referral_result = await db.execute(referral_query)
referral_user = referral_result.scalar_one_or_none()
referral_user = referral_users_map.get(e.referral_id) if e.referral_id else None
campaign = campaigns_map.get(e.campaign_id) if e.campaign_id else None
items.append(
ReferralEarningResponse(
@@ -165,6 +179,7 @@ async def get_referral_earnings(
reason=e.reason or 'Referral commission',
referral_username=referral_user.username if referral_user else None,
referral_first_name=referral_user.first_name if referral_user else None,
campaign_name=campaign.name if campaign else None,
created_at=e.created_at,
)
)
@@ -194,4 +209,5 @@ async def get_referral_terms():
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
)
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,8 +1,8 @@
"""Ticket notifications routes for cabinet."""
import logging
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,7 +13,7 @@ from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/tickets/notifications', tags=['Cabinet Ticket Notifications'])
admin_router = APIRouter(prefix='/admin/tickets/notifications', tags=['Cabinet Admin Ticket Notifications'])
+12 -12
View File
@@ -1,9 +1,9 @@
"""Support tickets routes for cabinet."""
import logging
import math
from datetime import datetime
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,7 +26,7 @@ from ..schemas.tickets import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/tickets', tags=['Cabinet Tickets'])
@@ -137,8 +137,8 @@ async def create_ticket(
title=request.title,
status='open',
priority='normal',
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
db.add(ticket)
await db.flush()
@@ -152,7 +152,7 @@ async def create_ticket(
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
created_at=datetime.utcnow(),
created_at=datetime.now(UTC),
)
db.add(message)
await db.commit()
@@ -164,7 +164,7 @@ async def create_ticket(
try:
await notify_admins_about_new_ticket(ticket, db)
except Exception as e:
logger.error(f'Error notifying admins about new ticket from cabinet: {e}')
logger.error('Error notifying admins about new ticket from cabinet', error=e)
# Уведомить админов в кабинете
try:
@@ -173,7 +173,7 @@ async def create_ticket(
# Отправить WebSocket уведомление
await notify_admins_new_ticket(ticket.id, ticket.title, user.id)
except Exception as e:
logger.error(f'Error creating cabinet notification for new ticket: {e}')
logger.error('Error creating cabinet notification for new ticket', error=e)
messages = [_message_to_response(m) for m in ticket.messages]
@@ -268,14 +268,14 @@ async def add_ticket_message(
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
created_at=datetime.utcnow(),
created_at=datetime.now(UTC),
)
db.add(message)
# Update ticket status and timestamp
if ticket.status == 'answered':
ticket.status = 'pending'
ticket.updated_at = datetime.utcnow()
ticket.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(message)
@@ -284,7 +284,7 @@ async def add_ticket_message(
try:
await notify_admins_about_ticket_reply(ticket, request.message, db)
except Exception as e:
logger.error(f'Error notifying admins about ticket reply from cabinet: {e}')
logger.error('Error notifying admins about ticket reply from cabinet', error=e)
# Уведомить админов в кабинете
try:
@@ -295,6 +295,6 @@ async def add_ticket_message(
# Отправить WebSocket уведомление
await notify_admins_ticket_reply(ticket.id, (request.message or '')[:100], user.id)
except Exception as e:
logger.error(f'Error creating cabinet notification for user reply: {e}')
logger.error('Error creating cabinet notification for user reply', error=e)
return _message_to_response(message)
+18 -18
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
import asyncio
import json
import logging
import structlog
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.cabinet.auth.jwt_handler import get_token_payload
@@ -14,7 +14,7 @@ from app.database.crud.user import get_user_by_id
from app.database.database import AsyncSessionLocal
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter()
@@ -42,10 +42,10 @@ class CabinetConnectionManager:
self._admin_connections[user_id].add(websocket)
logger.debug(
'Cabinet WS connected: user_id=%d, is_admin=%s, total_users=%d',
user_id,
is_admin,
len(self._user_connections),
'Cabinet WS connected: user_id is_admin total_users',
user_id=user_id,
is_admin=is_admin,
user_connections_count=len(self._user_connections),
)
async def disconnect(self, websocket: WebSocket, user_id: int) -> None:
@@ -61,7 +61,7 @@ class CabinetConnectionManager:
if not self._admin_connections[user_id]:
del self._admin_connections[user_id]
logger.debug('Cabinet WS disconnected: user_id=%d', user_id)
logger.debug('Cabinet WS disconnected: user_id', user_id=user_id)
async def send_to_user(self, user_id: int, message: dict) -> None:
"""Отправить сообщение конкретному пользователю."""
@@ -79,7 +79,7 @@ class CabinetConnectionManager:
try:
await ws.send_text(data)
except Exception as e:
logger.warning('Failed to send to user %d: %s', user_id, e)
logger.warning('Failed to send to user', user_id=user_id, e=e)
disconnected.add(ws)
# Cleanup disconnected
@@ -105,7 +105,7 @@ class CabinetConnectionManager:
try:
await ws.send_text(data)
except Exception as e:
logger.warning('Failed to send to admin %d: %s', user_id, e)
logger.warning('Failed to send to admin', user_id=user_id, e=e)
if user_id not in disconnected_by_user:
disconnected_by_user[user_id] = set()
disconnected_by_user[user_id].add(ws)
@@ -152,7 +152,7 @@ async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]:
)
return user_id, is_admin
except (TimeoutError, OSError, ConnectionRefusedError) as e:
logger.error('Database connection error in WS token verification: %s', str(e)[:200])
logger.error('Database connection error in WS token verification', e=str(e)[:200])
return None, False
@@ -165,7 +165,7 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
token = websocket.query_params.get('token')
if not token:
logger.debug('Cabinet WS: No token from %s', client_host)
logger.debug('Cabinet WS: No token from', client_host=client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason='Unauthorized: No token')
@@ -175,7 +175,7 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
user_id, is_admin = await verify_cabinet_ws_token(token)
if not user_id:
logger.debug('Cabinet WS: Invalid token from %s', client_host)
logger.debug('Cabinet WS: Invalid token from', client_host=client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason='Unauthorized: Invalid token')
@@ -184,9 +184,9 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
# Принимаем соединение
try:
await websocket.accept()
logger.debug('Cabinet WS accepted: user_id=%d, is_admin=%s', user_id, is_admin)
logger.debug('Cabinet WS accepted: user_id is_admin', user_id=user_id, is_admin=is_admin)
except Exception as e:
logger.error('Cabinet WS: Failed to accept from %s: %s', client_host, e)
logger.error('Cabinet WS: Failed to accept from', client_host=client_host, e=e)
return
# Регистрируем подключение
@@ -213,17 +213,17 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
await websocket.send_json({'type': 'pong'})
except json.JSONDecodeError:
logger.warning('Cabinet WS: Invalid JSON from user %d', user_id)
logger.warning('Cabinet WS: Invalid JSON from user', user_id=user_id)
except WebSocketDisconnect:
break
except Exception as e:
logger.exception('Cabinet WS error for user %d: %s', user_id, e)
logger.exception('Cabinet WS error for user', user_id=user_id, e=e)
break
except WebSocketDisconnect:
logger.debug('Cabinet WS disconnected: user_id=%d', user_id)
logger.debug('Cabinet WS disconnected: user_id', user_id=user_id)
except Exception as e:
logger.exception('Cabinet WS error: %s', e)
logger.exception('Cabinet WS error', e=e)
finally:
await cabinet_ws_manager.disconnect(websocket, user_id)
+7 -5
View File
@@ -2,11 +2,11 @@
API роуты колеса удачи для пользователей.
"""
import logging
import math
import time
import httpx
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -32,7 +32,7 @@ from app.database.models import User
from app.services.wheel_service import wheel_service
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/wheel', tags=['Fortune Wheel'])
@@ -253,14 +253,16 @@ async def create_stars_invoice(
result = response.json()
if not result.get('ok'):
logger.error(f'Telegram API error: {result}')
logger.error('Telegram API error', result=result)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Ошибка создания инвойса',
)
invoice_url = result['result']
logger.info(f'Created Stars invoice for wheel spin: user={user.id}, stars={stars_amount}')
logger.info(
'Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount
)
return StarsInvoiceResponse(
invoice_url=invoice_url,
@@ -268,7 +270,7 @@ async def create_stars_invoice(
)
except httpx.HTTPError as e:
logger.error(f'HTTP error creating invoice: {e}')
logger.error('HTTP error creating invoice', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Ошибка соединения с Telegram',
+166
View File
@@ -0,0 +1,166 @@
"""User-facing withdrawal routes for cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User, WithdrawalRequest, WithdrawalRequestStatus
from app.services.referral_withdrawal_service import referral_withdrawal_service
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.withdrawals import (
WithdrawalBalanceResponse,
WithdrawalCreateRequest,
WithdrawalCreateResponse,
WithdrawalItemResponse,
WithdrawalListResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/referral/withdrawal', tags=['Cabinet Withdrawal'])
@router.get('/balance', response_model=WithdrawalBalanceResponse)
async def get_withdrawal_balance(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get withdrawal balance stats for current user."""
can_request, reason, stats = await referral_withdrawal_service.can_request_withdrawal(db, user.id)
return WithdrawalBalanceResponse(
total_earned=stats['total_earned'],
referral_spent=stats['referral_spent'],
withdrawn=stats['withdrawn'],
pending=stats['pending'],
available_referral=stats['available_referral'],
available_total=stats['available_total'],
only_referral_mode=stats['only_referral_mode'],
min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
is_withdrawal_enabled=settings.is_referral_withdrawal_enabled(),
can_request=can_request,
cannot_request_reason=reason if not can_request else None,
requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
)
@router.post('/create', response_model=WithdrawalCreateResponse)
async def create_withdrawal(
request: WithdrawalCreateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a withdrawal request."""
withdrawal, error = await referral_withdrawal_service.create_withdrawal_request(
db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
payment_details=request.payment_details,
)
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Уведомляем админов о запросе на вывод
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_withdrawal_request_notification(
user=user,
amount_kopeks=request.amount_kopeks,
payment_details=request.payment_details,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for withdrawal request', error=e)
return WithdrawalCreateResponse(
id=withdrawal.id,
amount_kopeks=withdrawal.amount_kopeks,
status=withdrawal.status,
)
@router.get('/history', response_model=WithdrawalListResponse)
async def get_withdrawal_history(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's withdrawal request history."""
count_result = await db.execute(
select(func.count()).select_from(WithdrawalRequest).where(WithdrawalRequest.user_id == user.id)
)
total = count_result.scalar() or 0
result = await db.execute(
select(WithdrawalRequest)
.where(WithdrawalRequest.user_id == user.id)
.order_by(desc(WithdrawalRequest.created_at))
.limit(50)
)
requests = result.scalars().all()
items = [
WithdrawalItemResponse(
id=r.id,
amount_kopeks=r.amount_kopeks,
amount_rubles=r.amount_kopeks / 100,
status=r.status,
payment_details=r.payment_details,
admin_comment=r.admin_comment,
created_at=r.created_at,
processed_at=r.processed_at,
)
for r in requests
]
return WithdrawalListResponse(items=items, total=total)
@router.post('/{request_id}/cancel')
async def cancel_withdrawal(
request_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Cancel a pending withdrawal request."""
result = await db.execute(
select(WithdrawalRequest)
.where(
WithdrawalRequest.id == request_id,
WithdrawalRequest.user_id == user.id,
)
.with_for_update()
)
withdrawal = result.scalar_one_or_none()
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Заявка не найдена',
)
if withdrawal.status != WithdrawalRequestStatus.PENDING.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Можно отменить только заявку в ожидании',
)
withdrawal.status = WithdrawalRequestStatus.CANCELLED.value
await db.commit()
return {'success': True}
+25
View File
@@ -9,6 +9,10 @@ class TelegramAuthRequest(BaseModel):
"""Request for Telegram WebApp initData authentication."""
init_data: str = Field(..., description='Telegram WebApp initData string')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
class TelegramWidgetAuthRequest(BaseModel):
@@ -21,6 +25,10 @@ class TelegramWidgetAuthRequest(BaseModel):
photo_url: str | None = Field(None, description="User's photo URL")
auth_date: int = Field(..., description='Unix timestamp of authentication')
hash: str = Field(..., description='Authentication hash')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
class EmailRegisterRequest(BaseModel):
@@ -34,6 +42,9 @@ class EmailVerifyRequest(BaseModel):
"""Request to verify email with token."""
token: str = Field(..., description='Email verification token')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
class EmailLoginRequest(BaseModel):
@@ -41,6 +52,9 @@ class EmailLoginRequest(BaseModel):
email: EmailStr = Field(..., description='Email address')
password: str = Field(..., description='Password')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
class RefreshTokenRequest(BaseModel):
@@ -102,6 +116,16 @@ class EmailRegisterStandaloneRequest(BaseModel):
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
class CampaignBonusInfo(BaseModel):
"""Info about campaign bonus applied during auth."""
campaign_name: str
bonus_type: str
balance_kopeks: int = 0
subscription_days: int | None = None
tariff_name: str | None = None
class AuthResponse(BaseModel):
"""Full authentication response with tokens and user."""
@@ -110,6 +134,7 @@ class AuthResponse(BaseModel):
token_type: str = 'bearer'
expires_in: int
user: UserResponse
campaign_bonus: CampaignBonusInfo | None = None
class RegisterResponse(BaseModel):
+2 -2
View File
@@ -63,7 +63,7 @@ class PaymentMethodResponse(BaseModel):
class TopUpRequest(BaseModel):
"""Request to create payment for balance top-up."""
amount_kopeks: int = Field(..., ge=1000, description='Amount in kopeks (min 10 rubles)')
amount_kopeks: int = Field(..., ge=1000, le=2_000_000_000, description='Amount in kopeks (min 10 rubles)')
payment_method: str = Field(..., description='Payment method ID')
payment_option: str | None = Field(None, description='Payment option (e.g. Platega method code)')
@@ -82,7 +82,7 @@ class TopUpResponse(BaseModel):
class StarsInvoiceRequest(BaseModel):
"""Request to create Telegram Stars invoice for balance top-up."""
amount_kopeks: int = Field(..., ge=100, description='Amount in kopeks (min 1 ruble)')
amount_kopeks: int = Field(..., ge=100, le=2_000_000_000, description='Amount in kopeks (min 1 ruble)')
class StarsInvoiceResponse(BaseModel):
+1
View File
@@ -114,6 +114,7 @@ class BroadcastResponse(BaseModel):
total_count: int
sent_count: int
failed_count: int
blocked_count: int = 0
status: str # queued|in_progress|completed|partial|failed|cancelled|cancelling
admin_id: int | None = None
admin_name: str | None = None
+21 -2
View File
@@ -27,6 +27,8 @@ class CampaignListItem(BaseModel):
registrations_count: int
total_revenue_kopeks: int = 0
conversion_rate: float = 0.0
partner_user_id: int | None = None
partner_name: str | None = None
created_at: datetime
class Config:
@@ -60,12 +62,16 @@ class CampaignDetailResponse(BaseModel):
tariff_id: int | None = None
tariff_duration_days: int | None = None
tariff: TariffInfo | None = None
# Partner
partner_user_id: int | None = None
partner_name: str | None = None
# Meta
created_by: int | None = None
created_at: datetime
updated_at: datetime | None = None
# Deep link
deep_link: str | None = None
web_link: str | None = None
class Config:
from_attributes = True
@@ -75,7 +81,7 @@ class CampaignCreateRequest(BaseModel):
"""Request to create a campaign."""
name: str = Field(..., min_length=1, max_length=255)
start_parameter: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$')
start_parameter: str = Field(..., min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$')
bonus_type: CampaignBonusType
is_active: bool = True
# Balance bonus
@@ -88,13 +94,15 @@ class CampaignCreateRequest(BaseModel):
# Tariff bonus
tariff_id: int | None = None
tariff_duration_days: int | None = Field(None, ge=1)
# Partner
partner_user_id: int | None = None
class CampaignUpdateRequest(BaseModel):
"""Request to update a campaign."""
name: str | None = Field(None, min_length=1, max_length=255)
start_parameter: str | None = Field(None, min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$')
start_parameter: str | None = Field(None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$')
bonus_type: CampaignBonusType | None = None
is_active: bool | None = None
# Balance bonus
@@ -107,6 +115,8 @@ class CampaignUpdateRequest(BaseModel):
# Tariff bonus
tariff_id: int | None = None
tariff_duration_days: int | None = Field(None, ge=1)
# Partner
partner_user_id: int | None = None
class CampaignToggleResponse(BaseModel):
@@ -147,6 +157,7 @@ class CampaignStatisticsResponse(BaseModel):
trial_conversion_rate: float = 0.0
# Deep link
deep_link: str | None = None
web_link: str | None = None
class CampaignRegistrationItem(BaseModel):
@@ -194,6 +205,14 @@ class CampaignsOverviewResponse(BaseModel):
total_tariff_issued: int = 0
class AvailablePartnerItem(BaseModel):
"""Partner item for campaign partner selector."""
user_id: int
username: str | None = None
first_name: str | None = None
class ServerSquadInfo(BaseModel):
"""Server squad info for campaign selection."""
+78
View File
@@ -0,0 +1,78 @@
"""Pydantic v2 schemas for channel subscription management."""
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.database.crud.required_channel import validate_channel_id as _validate_channel_id_format
def _validate_channel_link_value(v: str | None) -> str | None:
"""Shared channel_link validation: t.me URL, @username auto-convert, http->https upgrade."""
if v is None:
return v
v = v.strip()
if v.startswith('http://t.me/'):
v = v.replace('http://', 'https://', 1)
if v.startswith('https://t.me/'):
return v
if v.startswith('@'):
return f'https://t.me/{v[1:]}'
raise ValueError('channel_link must be a t.me URL or @username')
class ChannelResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
channel_id: str
channel_link: str | None
title: str | None
is_active: bool
sort_order: int
class ChannelListResponse(BaseModel):
items: list[ChannelResponse]
total: int
class ChannelCreateRequest(BaseModel):
channel_id: str
channel_link: str | None = None
title: str | None = Field(None, max_length=255)
@field_validator('channel_id')
@classmethod
def validate_channel_id(cls, v: str) -> str:
return _validate_channel_id_format(v)
@field_validator('channel_link')
@classmethod
def validate_channel_link(cls, v: str | None) -> str | None:
return _validate_channel_link_value(v)
class ChannelUpdateRequest(BaseModel):
channel_id: str | None = None
channel_link: str | None = None
title: str | None = Field(None, max_length=255)
is_active: bool | None = None
sort_order: int | None = None
@field_validator('channel_id')
@classmethod
def validate_channel_id(cls, v: str | None) -> str | None:
if v is None:
return v
return _validate_channel_id_format(v)
@field_validator('channel_link')
@classmethod
def validate_channel_link(cls, v: str | None) -> str | None:
return _validate_channel_link_value(v)
class ChannelSubscriptionStatus(BaseModel):
channel_id: str
channel_link: str | None
title: str | None
is_subscribed: bool
+162
View File
@@ -0,0 +1,162 @@
"""Partner system schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
# ==================== User-facing ====================
class PartnerApplicationRequest(BaseModel):
"""Request to apply for partner status."""
company_name: str | None = Field(None, max_length=255)
website_url: str | None = Field(None, max_length=500)
telegram_channel: str | None = Field(None, max_length=255)
description: str | None = Field(None, max_length=2000)
expected_monthly_referrals: int | None = Field(None, ge=0, le=2_000_000_000)
class PartnerApplicationInfo(BaseModel):
"""Application info for the user."""
id: int
status: str
company_name: str | None = None
website_url: str | None = None
telegram_channel: str | None = None
description: str | None = None
expected_monthly_referrals: int | None = None
admin_comment: str | None = None
approved_commission_percent: int | None = None
created_at: datetime
processed_at: datetime | None = None
class Config:
from_attributes = True
class PartnerCampaignInfo(BaseModel):
"""Campaign info visible to the partner."""
id: int
name: str
start_parameter: str
bonus_type: str
balance_bonus_kopeks: int = 0
subscription_duration_days: int | None = None
subscription_traffic_gb: int | None = None
deep_link: str | None = None
web_link: str | None = None
class PartnerStatusResponse(BaseModel):
"""Partner status for current user."""
partner_status: str
commission_percent: int | None = None
latest_application: PartnerApplicationInfo | None = None
campaigns: list[PartnerCampaignInfo] = []
# ==================== Admin-facing ====================
class AdminPartnerApplicationItem(BaseModel):
"""Partner application in admin list."""
id: int
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
company_name: str | None = None
website_url: str | None = None
telegram_channel: str | None = None
description: str | None = None
expected_monthly_referrals: int | None = None
status: str
admin_comment: str | None = None
approved_commission_percent: int | None = None
created_at: datetime
processed_at: datetime | None = None
class AdminPartnerApplicationsResponse(BaseModel):
"""List of partner applications."""
items: list[AdminPartnerApplicationItem]
total: int
class AdminApproveRequest(BaseModel):
"""Request to approve a partner application."""
commission_percent: int = Field(..., ge=1, le=100)
comment: str | None = Field(None, max_length=2000)
class AdminRejectRequest(BaseModel):
"""Request to reject a partner application."""
comment: str | None = Field(None, max_length=2000)
class AdminPartnerItem(BaseModel):
"""Partner in admin list."""
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
commission_percent: int | None = None
total_referrals: int = 0
total_earnings_kopeks: int = 0
balance_kopeks: int = 0
partner_status: str
created_at: datetime
class AdminPartnerListResponse(BaseModel):
"""List of partners for admin."""
items: list[AdminPartnerItem]
total: int
class CampaignSummary(BaseModel):
"""Campaign summary for partner detail."""
id: int
name: str
start_parameter: str
is_active: bool
class AdminPartnerDetailResponse(BaseModel):
"""Detailed partner info for admin."""
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
commission_percent: int | None = None
partner_status: str
balance_kopeks: int = 0
total_referrals: int = 0
paid_referrals: int = 0
active_referrals: int = 0
earnings_all_time: int = 0
earnings_today: int = 0
earnings_week: int = 0
earnings_month: int = 0
conversion_to_paid: float = 0.0
campaigns: list[CampaignSummary] = []
created_at: datetime
class AdminUpdateCommissionRequest(BaseModel):
"""Request to update partner commission."""
commission_percent: int = Field(..., ge=1, le=100)
+2
View File
@@ -47,6 +47,7 @@ class ReferralEarningResponse(BaseModel):
reason: str
referral_username: str | None = None
referral_first_name: str | None = None
campaign_name: str | None = None
created_at: datetime
class Config:
@@ -76,3 +77,4 @@ class ReferralTermsResponse(BaseModel):
first_topup_bonus_rubles: float
inviter_bonus_kopeks: int
inviter_bonus_rubles: float
partner_section_visible: bool = True
+11 -8
View File
@@ -56,6 +56,7 @@ class SubscriptionData(BaseModel):
next_daily_charge_at: datetime | None = None # When next daily charge will happen
tariff_id: int | None = None
tariff_name: str | None = None
traffic_reset_mode: str | None = None
class Config:
from_attributes = True
@@ -85,7 +86,7 @@ class RenewalOptionResponse(BaseModel):
class RenewalRequest(BaseModel):
"""Request to renew subscription."""
period_days: int = Field(..., description='Renewal period in days')
period_days: int = Field(..., ge=1, le=3650, description='Renewal period in days')
class TrafficPackageResponse(BaseModel):
@@ -100,13 +101,13 @@ class TrafficPackageResponse(BaseModel):
class TrafficPurchaseRequest(BaseModel):
"""Request to purchase additional traffic."""
gb: int = Field(..., ge=0, description='GB to purchase (0 = unlimited)')
gb: int = Field(..., ge=0, le=100_000, description='GB to purchase (0 = unlimited)')
class DevicePurchaseRequest(BaseModel):
"""Request to purchase additional device slots."""
devices: int = Field(..., ge=1, description='Number of additional devices')
devices: int = Field(..., ge=1, le=100, description='Number of additional devices')
class AutopayUpdateRequest(BaseModel):
@@ -136,10 +137,10 @@ class PurchaseSelectionRequest(BaseModel):
"""User's selection for subscription purchase."""
period_id: str | None = Field(None, description="Period ID like 'days:30'")
period_days: int | None = Field(None, description='Period in days')
traffic_value: int | None = Field(None, description='Traffic in GB (0 = unlimited)')
period_days: int | None = Field(None, ge=1, le=3650, description='Period in days')
traffic_value: int | None = Field(None, ge=0, le=100_000, description='Traffic in GB (0 = unlimited)')
servers: list[str] | None = Field(default_factory=list, description='Server UUIDs')
devices: int | None = Field(None, description='Device limit')
devices: int | None = Field(None, ge=1, le=100, description='Device limit')
class PurchasePreviewRequest(BaseModel):
@@ -155,5 +156,7 @@ class TariffPurchaseRequest(BaseModel):
"""Request to purchase a tariff."""
tariff_id: int = Field(..., description='Tariff ID to purchase')
period_days: int = Field(..., description='Period in days')
traffic_gb: int | None = Field(None, ge=0, description='Custom traffic in GB (for custom_traffic_enabled tariffs)')
period_days: int = Field(..., ge=1, le=3650, description='Period in days')
traffic_gb: int | None = Field(
None, ge=0, le=100_000, description='Custom traffic in GB (for custom_traffic_enabled tariffs)'
)
+3 -1
View File
@@ -261,7 +261,9 @@ class UserNodeUsageResponse(BaseModel):
class UpdateBalanceRequest(BaseModel):
"""Request to update user balance."""
amount_kopeks: int = Field(..., description='Amount in kopeks (positive to add, negative to subtract)')
amount_kopeks: int = Field(
..., ge=-2_000_000_000, le=2_000_000_000, description='Amount in kopeks (positive to add, negative to subtract)'
)
description: str = Field(default='Admin balance adjustment', max_length=500)
create_transaction: bool = Field(default=True, description='Create transaction record')
+129
View File
@@ -0,0 +1,129 @@
"""Withdrawal system schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
# ==================== User-facing ====================
class WithdrawalBalanceResponse(BaseModel):
"""Withdrawal balance info for user."""
total_earned: int
referral_spent: int
withdrawn: int
pending: int
available_referral: int
available_total: int
only_referral_mode: bool
min_amount_kopeks: int
is_withdrawal_enabled: bool
can_request: bool
cannot_request_reason: str | None = None
requisites_text: str = ''
class WithdrawalCreateRequest(BaseModel):
"""Request to create a withdrawal."""
amount_kopeks: int = Field(..., gt=0, le=10_000_000)
payment_details: str = Field(..., min_length=5, max_length=1000)
class WithdrawalItemResponse(BaseModel):
"""Withdrawal request item."""
id: int
amount_kopeks: int
amount_rubles: float
status: str
payment_details: str | None = None
admin_comment: str | None = None
created_at: datetime
processed_at: datetime | None = None
class Config:
from_attributes = True
class WithdrawalListResponse(BaseModel):
"""List of user's withdrawal requests."""
items: list[WithdrawalItemResponse]
total: int
class WithdrawalCreateResponse(BaseModel):
"""Response after creating withdrawal."""
id: int
amount_kopeks: int
status: str
# ==================== Admin-facing ====================
class AdminWithdrawalItem(BaseModel):
"""Withdrawal request in admin list."""
id: int
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
amount_kopeks: int
amount_rubles: float
status: str
risk_score: int = 0
risk_level: str = 'low'
payment_details: str | None = None
admin_comment: str | None = None
created_at: datetime
processed_at: datetime | None = None
class AdminWithdrawalListResponse(BaseModel):
"""List of withdrawal requests for admin."""
items: list[AdminWithdrawalItem]
total: int
pending_count: int = 0
pending_total_kopeks: int = 0
class AdminWithdrawalDetailResponse(BaseModel):
"""Detailed withdrawal request for admin."""
id: int
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
amount_kopeks: int
amount_rubles: float
status: str
risk_score: int = 0
risk_level: str = 'low'
risk_analysis: dict | None = None
payment_details: str | None = None
admin_comment: str | None = None
balance_kopeks: int = 0
total_referrals: int = 0
total_earnings_kopeks: int = 0
created_at: datetime
processed_at: datetime | None = None
class AdminApproveWithdrawalRequest(BaseModel):
"""Request to approve a withdrawal."""
comment: str | None = Field(None, max_length=2000)
class AdminRejectWithdrawalRequest(BaseModel):
"""Request to reject a withdrawal."""
comment: str | None = Field(None, max_length=2000)
+6 -5
View File
@@ -1,14 +1,15 @@
"""Email service for sending verification and password reset emails."""
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import structlog
from app.config import settings
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class EmailService:
@@ -41,7 +42,7 @@ class EmailService:
if smtp.has_extn('auth'):
smtp.login(self.user, self.password)
else:
logger.debug(f'SMTP server {self.host} does not support AUTH, skipping authentication')
logger.debug('SMTP server does not support AUTH, skipping authentication', host=self.host)
return smtp
@@ -94,11 +95,11 @@ class EmailService:
with self._get_smtp_connection() as smtp:
smtp.sendmail(self.from_email, to_email, msg.as_string())
logger.info(f'Email sent successfully to {to_email}')
logger.info('Email sent successfully to', to_email=to_email)
return True
except Exception as e:
logger.error(f'Failed to send email to {to_email}: {e}')
logger.error('Failed to send email to', to_email=to_email, error=e)
return False
def send_verification_email(
@@ -4,17 +4,17 @@ Service for managing email template overrides stored in the database.
Custom templates override the hardcoded defaults from email_templates.py.
"""
import logging
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import structlog
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.database import AsyncSessionLocal
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_template_override(
@@ -56,7 +56,9 @@ async def get_template_override(
return None
except Exception as e:
logger.debug('Не удалось получить override шаблона %s/%s: %s', notification_type, language, e)
logger.debug(
'Не удалось получить override шаблона /', notification_type=notification_type, language=language, e=e
)
return None
@@ -122,7 +124,7 @@ async def save_template_override(
)
row = existing.fetchone()
now = datetime.utcnow()
now = datetime.now(UTC)
if row:
# Update
+253 -5
View File
@@ -4,6 +4,7 @@ Email notification templates for different notification types.
Supports multiple languages: ru, en, zh, ua, fa
"""
import html
from typing import Any
from app.config import settings
@@ -53,6 +54,10 @@ class EmailNotificationTemplates:
NotificationType.WARNING_NOTIFICATION: self._warning_template,
NotificationType.REFERRAL_BONUS: self._referral_bonus_template,
NotificationType.REFERRAL_REGISTERED: self._referral_registered_template,
NotificationType.PARTNER_APPLICATION_APPROVED: self._partner_approved_template,
NotificationType.PARTNER_APPLICATION_REJECTED: self._partner_rejected_template,
NotificationType.WITHDRAWAL_APPROVED: self._withdrawal_approved_template,
NotificationType.WITHDRAWAL_REJECTED: self._withdrawal_rejected_template,
NotificationType.TRAFFIC_RESET: self._traffic_reset_template,
NotificationType.PAYMENT_RECEIVED: self._payment_received_template,
NotificationType.EMAIL_VERIFICATION: self._email_verification_template,
@@ -528,7 +533,7 @@ class EmailNotificationTemplates:
def _autopay_failed_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for failed autopay notification."""
reason = context.get('reason', '')
reason = html.escape(context.get('reason', ''))
subjects = {
'ru': 'Ошибка автопродления',
@@ -715,7 +720,7 @@ class EmailNotificationTemplates:
def _ban_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for ban notification."""
reason = context.get('reason', '')
reason = html.escape(context.get('reason', ''))
subjects = {
'ru': 'Аккаунт заблокирован',
@@ -783,7 +788,7 @@ class EmailNotificationTemplates:
def _warning_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for warning notification."""
message = context.get('message', '')
message = html.escape(context.get('message', ''))
subjects = {
'ru': 'Предупреждение',
@@ -819,7 +824,7 @@ class EmailNotificationTemplates:
def _referral_bonus_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for referral bonus notification."""
bonus = context.get('formatted_bonus', f'{context.get("bonus_rubles", 0):.2f}')
referral_name = context.get('referral_name', '')
referral_name = html.escape(context.get('referral_name', ''))
subjects = {
'ru': f'Реферальный бонус: +{bonus}',
@@ -856,7 +861,7 @@ class EmailNotificationTemplates:
def _referral_registered_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for new referral registered notification."""
referral_name = context.get('referral_name', '')
referral_name = html.escape(context.get('referral_name', ''))
subjects = {
'ru': 'Новый реферал зарегистрирован',
@@ -889,6 +894,249 @@ class EmailNotificationTemplates:
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
# ============================================================================
# Partner Templates
# ============================================================================
def _partner_approved_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for partner application approved notification."""
commission = context.get('commission_percent', 0)
comment = html.escape(context.get('comment', ''))
subjects = {
'ru': 'Заявка на партнёрство одобрена',
'en': 'Partner Application Approved',
'zh': '合作伙伴申请已批准',
'ua': 'Заявка на партнерство схвалена',
}
bodies = {
'ru': f"""
<h2>Заявка на партнёрство одобрена!</h2>
<div class="highlight success">
<p>Ваша заявка на партнёрство была одобрена.</p>
<p>Ваша комиссия: <strong>{commission}%</strong></p>
{f'<p>Комментарий: {comment}</p>' if comment else ''}
</div>
<p>Теперь вы можете приглашать пользователей и получать вознаграждение!</p>
{self._get_cabinet_button(language)}
""",
'en': f"""
<h2>Partner Application Approved!</h2>
<div class="highlight success">
<p>Your partner application has been approved.</p>
<p>Your commission rate: <strong>{commission}%</strong></p>
{f'<p>Comment: {comment}</p>' if comment else ''}
</div>
<p>You can now invite users and earn rewards!</p>
{self._get_cabinet_button(language)}
""",
'zh': f"""
<h2>合作伙伴申请已批准</h2>
<div class="highlight success">
<p>您的合作伙伴申请已获批准</p>
<p>您的佣金比例: <strong>{commission}%</strong></p>
{f'<p>备注: {comment}</p>' if comment else ''}
</div>
<p>您现在可以邀请用户并获得奖励</p>
{self._get_cabinet_button(language)}
""",
'ua': f"""
<h2>Заявка на партнерство схвалена!</h2>
<div class="highlight success">
<p>Вашу заявку на партнерство було схвалено.</p>
<p>Ваша комісія: <strong>{commission}%</strong></p>
{f'<p>Коментар: {comment}</p>' if comment else ''}
</div>
<p>Тепер ви можете запрошувати користувачів та отримувати винагороду!</p>
{self._get_cabinet_button(language)}
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
def _partner_rejected_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for partner application rejected notification."""
comment = html.escape(context.get('comment', ''))
subjects = {
'ru': 'Заявка на партнёрство отклонена',
'en': 'Partner Application Rejected',
'zh': '合作伙伴申请被拒绝',
'ua': 'Заявка на партнерство відхилена',
}
bodies = {
'ru': f"""
<h2>Заявка на партнёрство отклонена</h2>
<div class="highlight danger">
<p>К сожалению, ваша заявка на партнёрство была отклонена.</p>
{f'<p>Причина: {comment}</p>' if comment else ''}
</div>
<p>Вы можете подать новую заявку позже.</p>
{self._get_cabinet_button(language)}
""",
'en': f"""
<h2>Partner Application Rejected</h2>
<div class="highlight danger">
<p>Unfortunately, your partner application has been rejected.</p>
{f'<p>Reason: {comment}</p>' if comment else ''}
</div>
<p>You can submit a new application later.</p>
{self._get_cabinet_button(language)}
""",
'zh': f"""
<h2>合作伙伴申请被拒绝</h2>
<div class="highlight danger">
<p>很抱歉您的合作伙伴申请已被拒绝</p>
{f'<p>原因: {comment}</p>' if comment else ''}
</div>
<p>您可以稍后提交新的申请</p>
{self._get_cabinet_button(language)}
""",
'ua': f"""
<h2>Заявка на партнерство відхилена</h2>
<div class="highlight danger">
<p>На жаль, вашу заявку на партнерство було відхилено.</p>
{f'<p>Причина: {comment}</p>' if comment else ''}
</div>
<p>Ви можете подати нову заявку пізніше.</p>
{self._get_cabinet_button(language)}
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
# ============================================================================
# Withdrawal Templates
# ============================================================================
def _withdrawal_approved_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for withdrawal approved notification."""
amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f}')
comment = html.escape(context.get('comment', ''))
subjects = {
'ru': f'Запрос на вывод {amount} одобрен',
'en': f'Withdrawal request for {amount} approved',
'zh': f'提现请求 {amount} 已批准',
'ua': f'Запит на виведення {amount} схвалено',
}
bodies = {
'ru': f"""
<h2>Запрос на вывод одобрен!</h2>
<div class="highlight success">
<p>Ваш запрос на вывод средств одобрен.</p>
<p>Сумма: <span class="amount">{amount}</span></p>
{f'<p>Комментарий: {comment}</p>' if comment else ''}
</div>
<p>Средства будут переведены в ближайшее время.</p>
{self._get_cabinet_button(language)}
""",
'en': f"""
<h2>Withdrawal Request Approved!</h2>
<div class="highlight success">
<p>Your withdrawal request has been approved.</p>
<p>Amount: <span class="amount">{amount}</span></p>
{f'<p>Comment: {comment}</p>' if comment else ''}
</div>
<p>Funds will be transferred shortly.</p>
{self._get_cabinet_button(language)}
""",
'zh': f"""
<h2>提现请求已批准</h2>
<div class="highlight success">
<p>您的提现请求已获批准</p>
<p>金额: <span class="amount">{amount}</span></p>
{f'<p>备注: {comment}</p>' if comment else ''}
</div>
<p>资金将很快转入</p>
{self._get_cabinet_button(language)}
""",
'ua': f"""
<h2>Запит на виведення схвалено!</h2>
<div class="highlight success">
<p>Ваш запит на виведення коштів було схвалено.</p>
<p>Сума: <span class="amount">{amount}</span></p>
{f'<p>Коментар: {comment}</p>' if comment else ''}
</div>
<p>Кошти будуть переведені найближчим часом.</p>
{self._get_cabinet_button(language)}
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
def _withdrawal_rejected_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for withdrawal rejected notification."""
amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f}')
comment = html.escape(context.get('comment', ''))
subjects = {
'ru': f'Запрос на вывод {amount} отклонён',
'en': f'Withdrawal request for {amount} rejected',
'zh': f'提现请求 {amount} 被拒绝',
'ua': f'Запит на виведення {amount} відхилено',
}
bodies = {
'ru': f"""
<h2>Запрос на вывод отклонён</h2>
<div class="highlight danger">
<p>Ваш запрос на вывод средств был отклонён.</p>
<p>Сумма: <strong>{amount}</strong></p>
{f'<p>Причина: {comment}</p>' if comment else ''}
</div>
<p>Средства возвращены на ваш баланс.</p>
{self._get_cabinet_button(language)}
""",
'en': f"""
<h2>Withdrawal Request Rejected</h2>
<div class="highlight danger">
<p>Your withdrawal request has been rejected.</p>
<p>Amount: <strong>{amount}</strong></p>
{f'<p>Reason: {comment}</p>' if comment else ''}
</div>
<p>Funds have been returned to your balance.</p>
{self._get_cabinet_button(language)}
""",
'zh': f"""
<h2>提现请求被拒绝</h2>
<div class="highlight danger">
<p>您的提现请求已被拒绝</p>
<p>金额: <strong>{amount}</strong></p>
{f'<p>原因: {comment}</p>' if comment else ''}
</div>
<p>资金已退回您的余额</p>
{self._get_cabinet_button(language)}
""",
'ua': f"""
<h2>Запит на виведення відхилено</h2>
<div class="highlight danger">
<p>Ваш запит на виведення коштів було відхилено.</p>
<p>Сума: <strong>{amount}</strong></p>
{f'<p>Причина: {comment}</p>' if comment else ''}
</div>
<p>Кошти повернуто на ваш баланс.</p>
{self._get_cabinet_button(language)}
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
# ============================================================================
# Payment Templates
# ============================================================================
+20 -43
View File
@@ -1,7 +1,6 @@
import hashlib
import hmac
import html
import logging
import math
import os
import re
@@ -11,6 +10,7 @@ from pathlib import Path
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
import structlog
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings
@@ -23,7 +23,7 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS: list[str] = [
USER_TAG_PATTERN = re.compile(r'^[A-Z0-9_]{1,16}$')
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class Settings(BaseSettings):
@@ -66,8 +66,6 @@ class Settings(BaseSettings):
ADMIN_REPORTS_TOPIC_ID: int | None = None
ADMIN_REPORTS_SEND_TIME: str | None = None
CHANNEL_SUB_ID: str | None = None
CHANNEL_LINK: str | None = None
CHANNEL_IS_REQUIRED_SUB: bool = False
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
CHANNEL_REQUIRED_FOR_ALL: bool = False
@@ -230,7 +228,9 @@ class Settings(BaseSettings):
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS: int = 100000 # Мин. сумма вывода (1000₽)
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS: int = 30 # Частота запросов на вывод
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE: bool = True # Только реф. баланс (False = реф + свой)
REFERRAL_WITHDRAWAL_REQUISITES_TEXT: str = '' # Текст-подсказка для реквизитов при выводе
REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID: int | None = None # Топик для уведомлений
REFERRAL_PARTNER_SECTION_VISIBLE: bool = True # Показывать раздел партнёрки в кабинете
# Настройки анализа на подозрительность
REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS: int = 50000 # Мин. сумма от 1 реферала (500₽)
@@ -549,6 +549,7 @@ class Settings(BaseSettings):
LOG_LEVEL: str = 'INFO'
LOG_FILE: str = 'logs/bot.log'
LOG_COLORS: bool = True # ANSI-цвета в консоли (false для plain-text вывода)
# === Log Rotation Settings ===
LOG_ROTATION_ENABLED: bool = False # По умолчанию старое поведение
@@ -670,9 +671,9 @@ class Settings(BaseSettings):
WEB_API_DEFAULT_TOKEN: str | None = None
WEB_API_DEFAULT_TOKEN_NAME: str = 'Bootstrap Token'
WEB_API_TOKEN_HASH_ALGORITHM: str = 'sha256'
WEB_API_TOKEN_HMAC_SECRET: str | None = None
WEB_API_REQUEST_LOGGING: bool = True
APP_CONFIG_PATH: str = 'app-config.json'
ENABLE_DEEP_LINKS: bool = True
APP_CONFIG_CACHE_TTL: int = 3600
@@ -1297,7 +1298,7 @@ class Settings(BaseSettings):
raise ValueError
return time(hour=hours, minute=minutes)
except (ValueError, AttributeError):
logging.getLogger(__name__).warning('Некорректное значение ADMIN_REPORTS_SEND_TIME: %s', value)
logger.warning('Некорректное значение ADMIN_REPORTS_SEND_TIME', send_time_value=value)
return None
def kopeks_to_rubles(self, kopeks: int) -> float:
@@ -1317,17 +1318,14 @@ class Settings(BaseSettings):
if len(cleaned) > 16:
logger.warning(
'Некорректная длина %s: максимум 16 символов, получено %s',
setting_name,
len(cleaned),
'Некорректная длина : максимум 16 символов, получено',
setting_name=setting_name,
cleaned_count=len(cleaned),
)
return None
if not USER_TAG_PATTERN.fullmatch(cleaned):
logger.warning(
'Некорректный формат %s: допустимы только A-Z, 0-9 и подчёркивание',
setting_name,
)
logger.warning('Некорректный формат : допустимы только A-Z, 0-9 и подчёркивание', setting_name=setting_name)
return None
return cleaned
@@ -1382,13 +1380,6 @@ class Settings(BaseSettings):
return value
return None
def get_app_config_path(self) -> str:
if os.path.isabs(self.APP_CONFIG_PATH):
return self.APP_CONFIG_PATH
project_root = Path(__file__).parent.parent
return str(project_root / self.APP_CONFIG_PATH)
def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS
@@ -1448,9 +1439,9 @@ class Settings(BaseSettings):
try:
return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None
except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений
logging.getLogger(__name__).warning(
'Некорректный идентификатор бота для внешней админки: %s',
self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
logger.warning(
'Некорректный идентификатор бота для внешней админки',
EXTERNAL_ADMIN_TOKEN_BOT_ID=self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
)
return None
@@ -1531,10 +1522,7 @@ class Settings(BaseSettings):
try:
value = int(raw_value)
except (TypeError, ValueError):
logger.warning(
'Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s',
raw_value,
)
logger.warning('Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT', raw_value=raw_value)
return None
if value < 0:
@@ -1574,8 +1562,7 @@ class Settings(BaseSettings):
value = int(self.TRIAL_ACTIVATION_PRICE)
except (TypeError, ValueError):
logger.warning(
'Некорректное значение TRIAL_ACTIVATION_PRICE: %s',
self.TRIAL_ACTIVATION_PRICE,
'Некорректное значение TRIAL_ACTIVATION_PRICE', TRIAL_ACTIVATION_PRICE=self.TRIAL_ACTIVATION_PRICE
)
return 0
@@ -1694,7 +1681,7 @@ class Settings(BaseSettings):
try:
method_code = int(part)
except ValueError:
logger.warning('Некорректный код метода Platega: %s', part)
logger.warning('Некорректный код метода Platega', part=part)
continue
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
methods.append(method_code)
@@ -1789,8 +1776,8 @@ class Settings(BaseSettings):
if minutes <= 0:
logger.warning(
'Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.',
self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
'Некорректный интервал автопроверки платежей: . Используется значение по умолчанию 10 минут.',
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
)
return 10
@@ -2144,22 +2131,13 @@ class Settings(BaseSettings):
return self.REFERRAL_NOTIFICATIONS_ENABLED
def get_traffic_packages(self) -> list[dict]:
import logging
logger = logging.getLogger(__name__)
try:
packages = []
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
logger.debug(f"CONFIG STRING: '{config_str}'")
if not config_str:
logger.debug('CONFIG EMPTY, USING FALLBACK')
return self._get_fallback_traffic_packages()
logger.debug('PARSING CONFIG...')
for package_config in config_str.split(','):
package_config = package_config.strip()
if not package_config:
@@ -2178,11 +2156,10 @@ class Settings(BaseSettings):
except ValueError:
continue
logger.debug(f'PARSED {len(packages)} packages from config')
return packages if packages else self._get_fallback_traffic_packages()
except Exception as e:
logger.info(f'ERROR PARSING CONFIG: {e}')
logger.warning('ERROR PARSING CONFIG', error=e)
return self._get_fallback_traffic_packages()
def is_version_check_enabled(self) -> bool:
+2 -2
View File
@@ -8,7 +8,7 @@ from .database import (
get_db,
get_db_read_only,
get_pool_metrics,
init_db,
sync_postgres_sequences,
)
@@ -20,5 +20,5 @@ __all__ = [
'get_db',
'get_db_read_only',
'get_pool_metrics',
'init_db',
'sync_postgres_sequences',
]
+17 -72
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import and_, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -17,7 +17,7 @@ from app.database.models import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_campaign(
@@ -36,6 +36,7 @@ async def create_campaign(
tariff_id: int | None = None,
tariff_duration_days: int | None = None,
is_active: bool = True,
partner_user_id: int | None = None,
) -> AdvertisingCampaign:
campaign = AdvertisingCampaign(
name=name,
@@ -50,6 +51,7 @@ async def create_campaign(
tariff_duration_days=tariff_duration_days,
created_by=created_by,
is_active=is_active,
partner_user_id=partner_user_id,
)
db.add(campaign)
@@ -57,10 +59,10 @@ async def create_campaign(
await db.refresh(campaign)
logger.info(
'📣 Создана рекламная кампания %s (start=%s, bonus=%s)',
campaign.name,
campaign.start_parameter,
campaign.bonus_type,
'📣 Создана рекламная кампания (start bonus=)',
campaign_name=campaign.name,
start_parameter=campaign.start_parameter,
bonus_type=campaign.bonus_type,
)
return campaign
@@ -71,6 +73,7 @@ async def get_campaign_by_id(db: AsyncSession, campaign_id: int) -> AdvertisingC
.options(
selectinload(AdvertisingCampaign.registrations),
selectinload(AdvertisingCampaign.tariff),
selectinload(AdvertisingCampaign.partner),
)
.where(AdvertisingCampaign.id == campaign_id)
)
@@ -103,6 +106,7 @@ async def get_campaigns_list(
.options(
selectinload(AdvertisingCampaign.registrations),
selectinload(AdvertisingCampaign.tariff),
selectinload(AdvertisingCampaign.partner),
)
.order_by(AdvertisingCampaign.created_at.desc())
.offset(offset)
@@ -141,6 +145,7 @@ async def update_campaign(
'tariff_id',
'tariff_duration_days',
'is_active',
'partner_user_id',
}
update_data = {}
@@ -151,20 +156,20 @@ async def update_campaign(
if not update_data:
return campaign
update_data['updated_at'] = datetime.utcnow()
update_data['updated_at'] = datetime.now(UTC)
await db.execute(update(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id).values(**update_data))
await db.commit()
await db.refresh(campaign)
logger.info('✏️ Обновлена рекламная кампания %s (%s)', campaign.name, update_data)
logger.info('✏️ Обновлена рекламная кампания', campaign_name=campaign.name, update_data=update_data)
return campaign
async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
await db.execute(delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id))
await db.commit()
logger.info('🗑️ Удалена рекламная кампания %s', campaign.name)
logger.info('🗑️ Удалена рекламная кампания', campaign_name=campaign.name)
return True
@@ -217,7 +222,7 @@ async def record_campaign_registration(
await db.commit()
await db.refresh(registration)
logger.info('📈 Регистрируем пользователя %s в кампании %s', user_id, campaign_id)
logger.info('📈 Регистрируем пользователя в кампании', user_id=user_id, campaign_id=campaign_id)
return registration
@@ -331,7 +336,7 @@ async def get_campaign_statistics(
first_payment_time_by_user[user_id] = converted_at
for user_id, amount_kopeks, created_at in subscription_payments:
amount_value = int(amount_kopeks or 0)
amount_value = abs(int(amount_kopeks or 0))
subscription_payments_total += amount_value
paid_users_from_transactions.add(user_id)
@@ -359,66 +364,6 @@ async def get_campaign_statistics(
if first_payment_amount_by_user:
avg_first_payment = int(sum(first_payment_amount_by_user.values()) / len(first_payment_amount_by_user))
conversion_rate = 0.0
if count:
conversion_rate = round((paid_users_count / count) * 100, 1)
trial_conversion_rate = 0.0
if trial_users_count:
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
avg_revenue_per_user = 0
if count:
avg_revenue_per_user = int(total_revenue / count)
deposits_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed.is_(True),
)
)
total_revenue = deposits_result.scalar() or 0
trials_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
Subscription.is_trial.is_(True),
)
)
trial_users_count = trials_result.scalar() or 0
active_trials_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
Subscription.is_trial.is_(True),
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
active_trials_count = active_trials_result.scalar() or 0
conversions_result = await db.execute(
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
)
)
conversion_count = conversions_result.scalar() or 0
paid_users_result = await db.execute(
select(func.count(User.id)).where(
User.id.in_(select(registrations_subquery.c.user_id)),
User.has_had_paid_subscription.is_(True),
)
)
paid_users_count = paid_users_result.scalar() or 0
avg_first_payment_result = await db.execute(
select(func.coalesce(func.avg(SubscriptionConversion.first_payment_amount_kopeks), 0)).where(
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
)
)
avg_first_payment = int(avg_first_payment_result.scalar() or 0)
conversion_rate = 0.0
if count:
conversion_rate = round((paid_users_count / count) * 100, 1)
+11 -13
View File
@@ -2,17 +2,17 @@
from __future__ import annotations
import logging
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import CloudPaymentsPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_cloudpayments_payment(
@@ -65,10 +65,10 @@ async def create_cloudpayments_payment(
await db.refresh(payment)
logger.debug(
'Created CloudPayments payment: id=%s, invoice=%s, amount=%s',
payment.id,
invoice_id,
amount_kopeks,
'Created CloudPayments payment: id invoice amount',
payment_id=payment.id,
invoice_id=invoice_id,
amount_kopeks=amount_kopeks,
)
return payment
@@ -127,7 +127,7 @@ async def update_cloudpayments_payment(
if hasattr(payment, key):
setattr(payment, key, value)
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
@@ -171,7 +171,7 @@ async def mark_cloudpayments_payment_as_paid(
payment.status = 'completed'
payment.is_paid = True
payment.paid_at = datetime.utcnow()
payment.paid_at = datetime.now(UTC)
if transaction_id_cp is not None:
payment.transaction_id_cp = transaction_id_cp
@@ -190,14 +190,12 @@ async def mark_cloudpayments_payment_as_paid(
if callback_payload:
payment.callback_payload = callback_payload
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
logger.info(
'Marked CloudPayments payment as paid: id=%s, invoice=%s',
payment.id,
payment.invoice_id,
'Marked CloudPayments payment as paid: id invoice', payment_id=payment.id, invoice_id=payment.invoice_id
)
return payment
+5 -5
View File
@@ -1,7 +1,7 @@
import logging
from collections.abc import Sequence
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import and_, delete, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import ContestAttempt, ContestRound, ContestTemplate, User
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
# Templates
@@ -107,7 +107,7 @@ async def create_round(
async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
@@ -124,7 +124,7 @@ async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> ContestRound | None:
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
+15 -11
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import CryptoBotPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_cryptobot_payment(
@@ -41,7 +41,13 @@ async def create_cryptobot_payment(
await db.commit()
await db.refresh(payment)
logger.info(f'Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}')
logger.info(
'Создан CryptoBot платеж: на для пользователя',
invoice_id=invoice_id,
amount=amount,
asset=asset,
user_id=user_id,
)
return payment
@@ -70,7 +76,7 @@ async def update_cryptobot_payment_status(
return None
payment.status = status
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
if status == 'paid' and paid_at:
payment.paid_at = paid_at
@@ -78,7 +84,7 @@ async def update_cryptobot_payment_status(
await db.commit()
await db.refresh(payment)
logger.info(f'Обновлен статус CryptoBot платежа {invoice_id}: {status}')
logger.info('Обновлен статус CryptoBot платежа', invoice_id=invoice_id, status=status)
return payment
@@ -91,12 +97,12 @@ async def link_cryptobot_payment_to_transaction(
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(payment)
logger.info(f'Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}')
logger.info('Связан CryptoBot платеж с транзакцией', invoice_id=invoice_id, transaction_id=transaction_id)
return payment
@@ -114,9 +120,7 @@ async def get_user_cryptobot_payments(
async def get_pending_cryptobot_payments(db: AsyncSession, older_than_hours: int = 24) -> list[CryptoBotPayment]:
from datetime import timedelta
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
cutoff_time = datetime.now(UTC) - timedelta(hours=older_than_hours)
result = await db.execute(
select(CryptoBotPayment)
+11 -19
View File
@@ -1,8 +1,8 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -11,7 +11,7 @@ from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.models import DiscountOffer
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def upsert_discount_offer(
@@ -28,7 +28,7 @@ async def upsert_discount_offer(
) -> DiscountOffer:
"""Create or refresh a discount offer for a user."""
expires_at = datetime.utcnow() + timedelta(hours=valid_hours)
expires_at = datetime.now(UTC) + timedelta(hours=valid_hours)
result = await db.execute(
select(DiscountOffer)
@@ -116,7 +116,7 @@ async def list_active_discount_offers_for_user(
) -> list[DiscountOffer]:
"""Return active (not yet claimed) offers for a user."""
now = datetime.utcnow()
now = datetime.now(UTC)
stmt = (
select(DiscountOffer)
.options(
@@ -161,7 +161,7 @@ async def mark_offer_claimed(
*,
details: dict | None = None,
) -> DiscountOffer:
offer.claimed_at = datetime.utcnow()
offer.claimed_at = datetime.now(UTC)
offer.is_active = False
await db.commit()
await db.refresh(offer)
@@ -178,24 +178,19 @@ async def mark_offer_claimed(
details=details,
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer claim log for offer %s: %s',
offer.id,
exc,
)
logger.warning('Failed to record promo offer claim log for offer', offer_id=offer.id, exc=exc)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer claim log failure: %s',
rollback_error,
'Failed to rollback session after promo offer claim log failure', rollback_error=rollback_error
)
return offer
async def deactivate_expired_offers(db: AsyncSession) -> int:
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(DiscountOffer).where(
DiscountOffer.is_active == True,
@@ -239,16 +234,13 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer disable log for offer %s: %s',
payload.get('offer_id'),
exc,
'Failed to record promo offer disable log for offer', payload=payload.get('offer_id'), exc=exc
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer disable log failure: %s',
rollback_error,
'Failed to rollback session after promo offer disable log failure', rollback_error=rollback_error
)
return count
+9 -9
View File
@@ -1,14 +1,14 @@
import logging
from collections.abc import Iterable
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FaqPage, FaqSetting
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_faq_setting(db: AsyncSession, language: str) -> FaqSetting | None:
@@ -21,7 +21,7 @@ async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> Faq
if setting:
setting.is_enabled = bool(enabled)
setting.updated_at = datetime.utcnow()
setting.updated_at = datetime.now(UTC)
else:
setting = FaqSetting(
language=language,
@@ -94,7 +94,7 @@ async def create_faq_page(
await db.commit()
await db.refresh(page)
logger.info('✅ Создана страница FAQ %s для языка %s', page.id, language)
logger.info('✅ Создана страница FAQ для языка', page_id=page.id, language=language)
return page
@@ -117,12 +117,12 @@ async def update_faq_page(
if is_active is not None:
page.is_active = bool(is_active)
page.updated_at = datetime.utcnow()
page.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(page)
logger.info('✅ Страница FAQ %s обновлена', page.id)
logger.info('✅ Страница FAQ обновлена', page_id=page.id)
return page
@@ -130,7 +130,7 @@ async def update_faq_page(
async def delete_faq_page(db: AsyncSession, page_id: int) -> None:
await db.execute(delete(FaqPage).where(FaqPage.id == page_id))
await db.commit()
logger.info('🗑️ Страница FAQ %s удалена', page_id)
logger.info('🗑️ Страница FAQ удалена', page_id=page_id)
async def bulk_update_order(
@@ -139,6 +139,6 @@ async def bulk_update_order(
) -> None:
for page_id, order in pages:
await db.execute(
update(FaqPage).where(FaqPage.id == page_id).values(display_order=order, updated_at=datetime.utcnow())
update(FaqPage).where(FaqPage.id == page_id).values(display_order=order, updated_at=datetime.now(UTC))
)
await db.commit()
+13 -8
View File
@@ -1,16 +1,16 @@
"""CRUD операции для платежей Freekassa."""
import json
import logging
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FreekassaPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_freekassa_payment(
@@ -41,7 +41,7 @@ async def create_freekassa_payment(
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f'Создан платеж Freekassa: order_id={order_id}, user_id={user_id}')
logger.info('Создан платеж Freekassa: order_id=, user_id', order_id=order_id, user_id=user_id)
return payment
@@ -77,10 +77,10 @@ async def update_freekassa_payment_status(
"""Обновляет статус платежа."""
payment.status = status
payment.is_paid = is_paid
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
if is_paid:
payment.paid_at = datetime.utcnow()
payment.paid_at = datetime.now(UTC)
if freekassa_order_id:
payment.freekassa_order_id = freekassa_order_id
if payment_system_id is not None:
@@ -92,7 +92,12 @@ async def update_freekassa_payment_status(
await db.commit()
await db.refresh(payment)
logger.info(f'Обновлен статус платежа Freekassa: order_id={payment.order_id}, status={status}, is_paid={is_paid}')
logger.info(
'Обновлен статус платежа Freekassa: order_id=, status=, is_paid',
order_id=payment.order_id,
status=status,
is_paid=is_paid,
)
return payment
@@ -129,7 +134,7 @@ async def get_expired_pending_payments(
db: AsyncSession,
) -> list[FreekassaPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(FreekassaPayment).where(
FreekassaPayment.status == 'pending',
+19 -23
View File
@@ -1,7 +1,7 @@
import logging
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import HeleketPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_heleket_payment(
@@ -50,12 +50,12 @@ async def create_heleket_payment(
await db.refresh(payment)
logger.info(
'Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s',
uuid,
order_id,
amount,
currency,
user_id,
'Создан Heleket платеж: uuid= order_id= amount= для пользователя',
uuid=uuid,
order_id=order_id,
amount=amount,
currency=currency,
user_id=user_id,
)
return payment
@@ -107,7 +107,7 @@ async def update_heleket_payment(
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error('Heleket платеж с uuid=%s не найден', uuid)
logger.error('Heleket платеж с uuid= не найден', uuid=uuid)
return None
if status is not None:
@@ -129,17 +129,17 @@ async def update_heleket_payment(
if paid_at is not None:
payment.paid_at = paid_at
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s',
uuid,
payment.status,
payment.payer_amount,
payment.payer_currency,
'Обновлен Heleket платеж : статус= payer_amount',
uuid=uuid,
payment_status=payment.status,
payer_amount=payment.payer_amount,
payer_currency=payment.payer_currency,
)
return payment
@@ -153,19 +153,15 @@ async def link_heleket_payment_to_transaction(
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error('Не найден Heleket платеж для связи с транзакцией: %s', uuid)
logger.error('Не найден Heleket платеж для связи с транзакцией', uuid=uuid)
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(payment)
logger.info(
'Heleket платеж %s связан с транзакцией %s',
uuid,
transaction_id,
)
logger.info('Heleket платеж связан с транзакцией', uuid=uuid, transaction_id=transaction_id)
return payment
+13 -8
View File
@@ -1,16 +1,16 @@
"""CRUD операции для платежей KassaAI."""
import json
import logging
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import KassaAiPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_kassa_ai_payment(
@@ -43,7 +43,7 @@ async def create_kassa_ai_payment(
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f'Создан платеж KassaAI: order_id={order_id}, user_id={user_id}')
logger.info('Создан платеж KassaAI: order_id=, user_id', order_id=order_id, user_id=user_id)
return payment
@@ -79,10 +79,10 @@ async def update_kassa_ai_payment_status(
"""Обновляет статус платежа."""
payment.status = status
payment.is_paid = is_paid
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
if is_paid:
payment.paid_at = datetime.utcnow()
payment.paid_at = datetime.now(UTC)
if kassa_ai_order_id:
payment.kassa_ai_order_id = kassa_ai_order_id
if payment_system_id is not None:
@@ -94,7 +94,12 @@ async def update_kassa_ai_payment_status(
await db.commit()
await db.refresh(payment)
logger.info(f'Обновлен статус платежа KassaAI: order_id={payment.order_id}, status={status}, is_paid={is_paid}')
logger.info(
'Обновлен статус платежа KassaAI: order_id=, status=, is_paid',
order_id=payment.order_id,
status=status,
is_paid=is_paid,
)
return payment
@@ -131,7 +136,7 @@ async def get_expired_pending_kassa_ai_payments(
db: AsyncSession,
) -> list[KassaAiPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(KassaAiPayment).where(
KassaAiPayment.status == 'pending',
+12 -12
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,7 +8,7 @@ from app.config import settings
from app.database.models import MulenPayPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_mulenpay_payment(
@@ -41,12 +41,12 @@ async def create_mulenpay_payment(
await db.refresh(payment)
logger.info(
'Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s',
settings.get_mulenpay_display_name(),
payment.mulen_payment_id,
uuid,
amount_kopeks,
user_id,
'Создан платеж # (uuid=) на сумму копеек для пользователя',
get_mulenpay_display_name=settings.get_mulenpay_display_name(),
mulen_payment_id=payment.mulen_payment_id,
uuid=uuid,
amount_kopeks=amount_kopeks,
user_id=user_id,
)
return payment
@@ -90,7 +90,7 @@ async def update_mulenpay_payment_status(
if metadata is not None:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(payment)
return payment
@@ -103,7 +103,7 @@ async def update_mulenpay_payment_metadata(
metadata: dict,
) -> MulenPayPayment:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(payment)
return payment
@@ -116,7 +116,7 @@ async def link_mulenpay_payment_to_transaction(
transaction_id: int,
) -> MulenPayPayment:
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(payment)
return payment
+2 -3
View File
@@ -1,12 +1,11 @@
import logging
import structlog
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SentNotification
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def notification_sent(
+12 -16
View File
@@ -2,17 +2,17 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
import structlog
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Pal24Payment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_pal24_payment(
@@ -51,11 +51,11 @@ async def create_pal24_payment(
await db.refresh(payment)
logger.info(
'Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)',
payment.id,
user_id,
amount_kopeks,
status,
'Создан Pal24 платеж # для пользователя : копеек (статус)',
payment_id=payment.id,
user_id=user_id,
amount_kopeks=amount_kopeks,
status=status,
)
return payment
@@ -128,10 +128,10 @@ async def update_pal24_payment_status(
await db.refresh(payment)
logger.info(
'Обновлен Pal24 платеж %s: статус=%s, is_paid=%s',
payment.bill_id,
payment.status,
payment.is_paid,
'Обновлен Pal24 платеж : статус is_paid',
bill_id=payment.bill_id,
payment_status=payment.status,
is_paid=payment.is_paid,
)
return payment
@@ -145,9 +145,5 @@ async def link_pal24_payment_to_transaction(
await db.execute(update(Pal24Payment).where(Pal24Payment.id == payment.id).values(transaction_id=transaction_id))
await db.commit()
await db.refresh(payment)
logger.info(
'Pal24 платеж %s привязан к транзакции %s',
payment.bill_id,
transaction_id,
)
logger.info('Pal24 платеж привязан к транзакции', bill_id=payment.bill_id, transaction_id=transaction_id)
return payment
+10 -10
View File
@@ -2,17 +2,17 @@
from __future__ import annotations
import logging
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PlategaPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_platega_payment(
@@ -55,11 +55,11 @@ async def create_platega_payment(
await db.refresh(payment)
logger.info(
'Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s',
payment.id,
platega_transaction_id,
amount_kopeks,
user_id,
'Создан Platega платеж # (tx=) на сумму копеек для пользователя',
payment_id=payment.id,
platega_transaction_id=platega_transaction_id,
amount_kopeks=amount_kopeks,
user_id=user_id,
)
return payment
@@ -115,7 +115,7 @@ async def update_platega_payment(
if expires_at is not None:
payment.expires_at = expires_at
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(payment)
@@ -129,7 +129,7 @@ async def link_platega_payment_to_transaction(
transaction_id: int,
) -> PlategaPayment:
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(payment)
return payment
+3 -3
View File
@@ -1,6 +1,6 @@
import logging
from collections.abc import Iterable, Sequence
import structlog
from sqlalchemy import and_, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -14,7 +14,7 @@ from app.database.models import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_poll(
@@ -97,7 +97,7 @@ async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
await db.delete(poll)
await db.commit()
logger.info('🗑️ Удалён опрос %s', poll_id)
logger.info('🗑️ Удалён опрос', poll_id=poll_id)
return True
+6 -10
View File
@@ -1,13 +1,13 @@
import logging
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PrivacyPolicy
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_privacy_policy(db: AsyncSession, language: str) -> PrivacyPolicy | None:
@@ -26,7 +26,7 @@ async def upsert_privacy_policy(
if policy:
policy.content = content or ''
policy.updated_at = datetime.utcnow()
policy.updated_at = datetime.now(UTC)
else:
policy = PrivacyPolicy(
language=language,
@@ -38,11 +38,7 @@ async def upsert_privacy_policy(
await db.commit()
await db.refresh(policy)
logger.info(
'✅ Политика конфиденциальности для языка %s обновлена (ID: %s)',
language,
policy.id,
)
logger.info('✅ Политика конфиденциальности для языка обновлена (ID:)', language=language, policy_id=policy.id)
return policy
@@ -56,7 +52,7 @@ async def set_privacy_policy_enabled(
if policy:
policy.is_enabled = bool(enabled)
policy.updated_at = datetime.utcnow()
policy.updated_at = datetime.now(UTC)
else:
policy = PrivacyPolicy(
language=language,
+7 -12
View File
@@ -1,5 +1,4 @@
import logging
import structlog
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -25,7 +24,7 @@ def _normalize_period_discounts(period_discounts: dict[int, int] | None) -> dict
return normalized
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_promo_groups_with_counts(
@@ -194,11 +193,7 @@ async def update_promo_group(
await db.commit()
await db.refresh(group)
logger.info(
"Обновлена промогруппа '%s' (id=%s)",
group.name,
group.id,
)
logger.info("Обновлена промогруппа '' (id=)", group_name=group.name, group_id=group.id)
return group
@@ -246,10 +241,10 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
await db.commit()
logger.info(
"Промогруппа '%s' (id=%s) удалена, пользователи переведены в '%s'",
group.name,
group.id,
default_group.name,
"Промогруппа '' (id=) удалена, пользователи переведены в ''",
group_name=group.name,
group_id=group.id,
default_group_name=default_group.name,
)
return True
+2 -3
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import logging
import structlog
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -9,7 +8,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import PromoOfferLog
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def log_promo_offer_action(
+4 -4
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Iterable
from datetime import datetime
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -118,13 +118,13 @@ async def ensure_default_templates(db: AsyncSession, *, created_by: int | None =
if should_update and new_message:
existing.message_text = new_message
existing.updated_at = datetime.utcnow()
existing.updated_at = datetime.now(UTC)
await db.flush()
target_active_hours = template_data.get('active_discount_hours')
if target_active_hours is not None and target_active_hours > 0 and not existing.active_discount_hours:
existing.active_discount_hours = target_active_hours
existing.updated_at = datetime.utcnow()
existing.updated_at = datetime.now(UTC)
await db.flush()
templates.append(existing)
continue
@@ -204,7 +204,7 @@ async def update_promo_offer_template(
if is_active is not None:
template.is_active = is_active
template.updated_at = datetime.utcnow()
template.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(template)
+14 -16
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, User
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_promocode_by_code(db: AsyncSession, code: str) -> PromoCode | None:
@@ -78,9 +78,9 @@ async def create_promocode(
await db.refresh(promocode)
if promo_group_id:
logger.info(f'✅ Создан промокод: {code} с промогруппой ID {promo_group_id}')
logger.info('✅ Создан промокод: с промогруппой ID', code=code, promo_group_id=promo_group_id)
else:
logger.info(f'✅ Создан промокод: {code}')
logger.info('✅ Создан промокод', code=code)
return promocode
@@ -97,11 +97,11 @@ async def use_promocode(db: AsyncSession, promocode_id: int, user_id: int) -> bo
await db.commit()
logger.info(f'✅ Промокод {promocode.code} использован пользователем {user_id}')
logger.info('✅ Промокод использован пользователем', code=promocode.code, user_id=user_id)
return True
except Exception as e:
logger.error(f'Ошибка использования промокода: {e}')
logger.error('Ошибка использования промокода', error=e)
await db.rollback()
return False
@@ -114,13 +114,13 @@ async def check_user_promocode_usage(db: AsyncSession, user_id: int, promocode_i
async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse:
promocode_use = PromoCodeUse(promocode_id=promocode_id, user_id=user_id, used_at=datetime.utcnow())
promocode_use = PromoCodeUse(promocode_id=promocode_id, user_id=user_id, used_at=datetime.now(UTC))
db.add(promocode_use)
await db.commit()
await db.refresh(promocode_use)
logger.info(f'📝 Записано использование промокода {promocode_id} пользователем {user_id}')
logger.info('📝 Записано использование промокода пользователем', promocode_id=promocode_id, user_id=user_id)
return promocode_use
@@ -133,9 +133,7 @@ async def get_promocode_use_by_user_and_code(db: AsyncSession, user_id: int, pro
async def count_user_recent_activations(db: AsyncSession, user_id: int, hours: int = 24) -> int:
"""Подсчитывает количество активаций промокодов пользователем за последние N часов."""
from datetime import timedelta
cutoff = datetime.utcnow() - timedelta(hours=hours)
cutoff = datetime.now(UTC) - timedelta(hours=hours)
result = await db.execute(
select(func.count(PromoCodeUse.id)).where(and_(PromoCodeUse.user_id == user_id, PromoCodeUse.used_at >= cutoff))
)
@@ -178,7 +176,7 @@ async def update_promocode(db: AsyncSession, promocode: PromoCode, **kwargs) ->
if hasattr(promocode, field):
setattr(promocode, field, value)
promocode.updated_at = datetime.utcnow()
promocode.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(promocode)
@@ -194,11 +192,11 @@ async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
await db.delete(promocode)
await db.commit()
logger.info(f'🗑️ Удален промокод: {promocode.code}')
logger.info('🗑️ Удален промокод', code=promocode.code)
return True
except Exception as e:
logger.error(f'Ошибка удаления промокода: {e}')
logger.error('Ошибка удаления промокода', error=e)
await db.rollback()
return False
@@ -239,7 +237,7 @@ async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
)
total_uses = total_uses_result.scalar()
today = datetime.utcnow().date()
today = datetime.now(UTC).date()
today_uses_result = await db.execute(
select(func.count(PromoCodeUse.id)).where(
and_(PromoCodeUse.promocode_id == promocode_id, PromoCodeUse.used_at >= today)
+6 -10
View File
@@ -1,13 +1,13 @@
import logging
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PublicOffer
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_public_offer(db: AsyncSession, language: str) -> PublicOffer | None:
@@ -26,7 +26,7 @@ async def upsert_public_offer(
if offer:
offer.content = content or ''
offer.updated_at = datetime.utcnow()
offer.updated_at = datetime.now(UTC)
else:
offer = PublicOffer(
language=language,
@@ -38,11 +38,7 @@ async def upsert_public_offer(
await db.commit()
await db.refresh(offer)
logger.info(
'✅ Публичная оферта для языка %s обновлена (ID: %s)',
language,
offer.id,
)
logger.info('✅ Публичная оферта для языка обновлена (ID:)', language=language, offer_id=offer.id)
return offer
@@ -56,7 +52,7 @@ async def set_public_offer_enabled(
if offer:
offer.is_enabled = bool(enabled)
offer.updated_at = datetime.utcnow()
offer.updated_at = datetime.now(UTC)
else:
offer = PublicOffer(
language=language,
+35 -13
View File
@@ -1,14 +1,25 @@
import logging
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import ReferralEarning, User
from app.database.models import AdvertisingCampaignRegistration, ReferralEarning, User
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_user_campaign_id(db: AsyncSession, user_id: int) -> int | None:
"""Получить campaign_id первой регистрации пользователя."""
result = await db.execute(
select(AdvertisingCampaignRegistration.campaign_id)
.where(AdvertisingCampaignRegistration.user_id == user_id)
.order_by(AdvertisingCampaignRegistration.created_at.asc())
.limit(1)
)
return result.scalar_one_or_none()
async def create_referral_earning(
@@ -18,6 +29,7 @@ async def create_referral_earning(
amount_kopeks: int,
reason: str,
referral_transaction_id: int | None = None,
campaign_id: int | None = None,
) -> ReferralEarning:
earning = ReferralEarning(
user_id=user_id,
@@ -25,13 +37,16 @@ async def create_referral_earning(
amount_kopeks=amount_kopeks,
reason=reason,
referral_transaction_id=referral_transaction_id,
campaign_id=campaign_id,
)
db.add(earning)
await db.commit()
await db.refresh(earning)
logger.info(f'💰 Создан реферальный заработок: {amount_kopeks / 100}₽ для пользователя {user_id}')
logger.info(
'💰 Создан реферальный заработок: ₽ для пользователя', amount_kopeks=amount_kopeks / 100, user_id=user_id
)
return earning
@@ -40,7 +55,11 @@ async def get_referral_earnings_by_user(
) -> list[ReferralEarning]:
result = await db.execute(
select(ReferralEarning)
.options(selectinload(ReferralEarning.referral), selectinload(ReferralEarning.referral_transaction))
.options(
selectinload(ReferralEarning.referral),
selectinload(ReferralEarning.referral_transaction),
selectinload(ReferralEarning.campaign),
)
.where(ReferralEarning.user_id == user_id)
.order_by(ReferralEarning.created_at.desc())
.offset(offset)
@@ -176,7 +195,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
}
)
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
today = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
today_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= today)
@@ -188,7 +207,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
)
today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar()
week_ago = datetime.utcnow() - timedelta(days=7)
week_ago = datetime.now(UTC) - timedelta(days=7)
week_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= week_ago)
)
@@ -199,7 +218,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
)
week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar()
month_ago = datetime.utcnow() - timedelta(days=30)
month_ago = datetime.now(UTC) - timedelta(days=30)
month_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= month_ago)
)
@@ -211,7 +230,10 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
month_earnings = month_referral_earnings_result.scalar() + month_transaction_earnings_result.scalar()
logger.info(
f'Реферальная статистика: {users_with_referrals} рефералов, {active_referrers} рефереров, выплачено {total_paid} копеек'
'Реферальная статистика: рефералов, рефереров, выплачено копеек',
users_with_referrals=users_with_referrals,
active_referrers=active_referrers,
total_paid=total_paid,
)
return {
@@ -244,7 +266,7 @@ async def get_top_referrers_by_period(
"""
from app.database.models import Transaction, TransactionType
now = datetime.utcnow()
now = datetime.now(UTC)
if period == 'week':
start_date = now - timedelta(days=7)
else: # month
@@ -375,12 +397,12 @@ async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
total_earned = await get_referral_earnings_sum(db, user_id)
month_ago = datetime.utcnow() - timedelta(days=30)
month_ago = datetime.now(UTC) - timedelta(days=30)
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
from app.database.models import Subscription, SubscriptionStatus
current_time = datetime.utcnow()
current_time = datetime.now(UTC)
active_referrals_result = await db.execute(
select(func.count(User.id))
+34 -23
View File
@@ -1,7 +1,7 @@
import logging
from collections.abc import Sequence
from datetime import date, datetime, time
from datetime import UTC, date, datetime, time
import structlog
from sqlalchemy import and_, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -16,7 +16,7 @@ from app.database.models import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_referral_contest(
@@ -165,7 +165,7 @@ async def add_contest_event(
referral_id=referral_id,
amount_kopeks=amount_kopeks,
event_type=event_type,
occurred_at=datetime.utcnow(),
occurred_at=datetime.now(UTC),
)
db.add(event)
await db.commit()
@@ -440,7 +440,7 @@ async def get_contest_transaction_breakdown(
# Сумма покупок подписок
subscription_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.user_id.in_(referral_ids),
Transaction.is_completed.is_(True),
@@ -512,7 +512,7 @@ async def upsert_contest_event(
referral_id=referral_id,
amount_kopeks=amount_kopeks,
event_type=event_type,
occurred_at=datetime.utcnow(),
occurred_at=datetime.now(UTC),
)
db.add(event)
await db.commit()
@@ -621,10 +621,10 @@ async def debug_contest_transactions(
tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.DEPOSIT.value and tx.payment_method is not None
)
subscription_in_period = sum(
tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value
abs(tx.amount_kopeks) for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value
)
total_in_period = deposit_in_period + subscription_in_period
total_outside = sum(tx.amount_kopeks for tx in txs_out)
total_outside = sum(abs(tx.amount_kopeks) for tx in txs_out)
# Подсчёт ПОЛНЫХ сумм (не только sample, БЕЗ бонусов)
full_deposit_result = await db.execute(
@@ -642,7 +642,7 @@ async def debug_contest_transactions(
full_deposit_total = int(full_deposit_result.scalar_one() or 0)
full_subscription_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.user_id.in_(referral_ids),
Transaction.is_completed.is_(True),
@@ -725,7 +725,12 @@ async def sync_contest_events(
# Конец дня: 23:59:59.999999
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
logger.info('Синхронизация конкурса %s: период с %s по %s', contest_id, contest_start, contest_end)
logger.info(
'Синхронизация конкурса : период с по',
contest_id=contest_id,
contest_start=contest_start,
contest_end=contest_end,
)
stats = {
'updated': 0,
@@ -768,7 +773,7 @@ async def sync_contest_events(
for event in events:
# Считаем ТОЛЬКО покупки подписок (реальные траты на подписки)
subscription_query = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
subscription_query = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.user_id == event.referral_id,
Transaction.is_completed.is_(True),
@@ -815,7 +820,11 @@ async def sync_contest_events(
# Логируем значительные изменения
if abs(old_amount - total_paid) > 10000: # больше 100 руб разницы
logger.debug(
'Событие %s (реферал %s): %s -> %s коп.', event.id, event.referral_id, old_amount, total_paid
'Событие (реферал): -> коп.',
event_id=event.id,
referral_id=event.referral_id,
old_amount=old_amount,
total_paid=total_paid,
)
else:
stats['skipped'] += 1
@@ -824,11 +833,11 @@ async def sync_contest_events(
await db.commit()
logger.info(
'Синхронизация конкурса %s завершена: обновлено %s, пропущено %s, сумма %s коп.',
contest_id,
stats['updated'],
stats['skipped'],
stats['total_amount'],
'Синхронизация конкурса завершена: обновлено , пропущено , сумма коп.',
contest_id=contest_id,
stats=stats['updated'],
stats_2=stats['skipped'],
stats_3=stats['total_amount'],
)
return stats
@@ -861,7 +870,9 @@ async def cleanup_invalid_contest_events(
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
logger.info('Очистка конкурса %s: период с %s по %s', contest_id, contest_start, contest_end)
logger.info(
'Очистка конкурса : период с по', contest_id=contest_id, contest_start=contest_start, contest_end=contest_end
)
# Считаем сколько было событий до очистки
total_before_result = await db.execute(
@@ -905,11 +916,11 @@ async def cleanup_invalid_contest_events(
remaining = int(remaining_result.scalar_one() or 0)
logger.info(
'Очистка конкурса %s завершена: удалено %s невалидных событий, осталось %s валидных (было %s)',
contest_id,
deleted,
remaining,
total_before,
'Очистка конкурса завершена: удалено невалидных событий, осталось валидных (было)',
contest_id=contest_id,
deleted=deleted,
remaining=remaining,
total_before=total_before,
)
return {
+212
View File
@@ -0,0 +1,212 @@
import re
from datetime import UTC, datetime
import structlog
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import RequiredChannel, UserChannelSubscription
logger = structlog.get_logger(__name__)
# Explicit allowlist of fields that can be updated via update_channel()
_UPDATABLE_FIELDS = frozenset({'channel_id', 'channel_link', 'title', 'is_active', 'sort_order'})
# Validation patterns for channel_id
_CHANNEL_ID_NUMERIC = re.compile(r'^-100\d{10,13}$')
_BARE_DIGITS = re.compile(r'^\d{10,13}$')
def validate_channel_id(channel_id: str) -> str:
"""Validate and normalize channel_id. Auto-prefixes -100 for bare digits.
Raises ValueError on invalid input.
"""
channel_id = channel_id.strip()
if _CHANNEL_ID_NUMERIC.match(channel_id):
return channel_id
if _BARE_DIGITS.match(channel_id):
return f'-100{channel_id}'
raise ValueError(
f'Invalid channel_id format: {channel_id!r}. '
'Enter numeric channel ID (e.g. 1234567890) — prefix -100 is added automatically'
)
# -- RequiredChannel CRUD --------------------------------------------------------
async def get_active_channels(db: AsyncSession) -> list[RequiredChannel]:
"""Get all active required channels (sorted by sort_order)."""
result = await db.execute(
select(RequiredChannel)
.where(RequiredChannel.is_active.is_(True))
.order_by(RequiredChannel.sort_order, RequiredChannel.id)
)
return list(result.scalars().all())
async def get_all_channels(db: AsyncSession) -> list[RequiredChannel]:
"""Get all required channels (including inactive)."""
result = await db.execute(select(RequiredChannel).order_by(RequiredChannel.sort_order, RequiredChannel.id))
return list(result.scalars().all())
async def get_channel_by_id(db: AsyncSession, channel_db_id: int) -> RequiredChannel | None:
result = await db.execute(select(RequiredChannel).where(RequiredChannel.id == channel_db_id))
return result.scalar_one_or_none()
async def get_channel_by_channel_id(db: AsyncSession, channel_id: str) -> RequiredChannel | None:
result = await db.execute(select(RequiredChannel).where(RequiredChannel.channel_id == channel_id))
return result.scalar_one_or_none()
async def add_channel(
db: AsyncSession,
channel_id: str,
channel_link: str | None = None,
title: str | None = None,
) -> RequiredChannel:
channel_id = validate_channel_id(channel_id)
channel = RequiredChannel(
channel_id=channel_id,
channel_link=channel_link,
title=title,
)
db.add(channel)
await db.commit()
await db.refresh(channel)
return channel
async def update_channel(
db: AsyncSession,
channel_db_id: int,
**kwargs,
) -> RequiredChannel | None:
"""Update channel fields. Only fields in _UPDATABLE_FIELDS are accepted."""
channel = await get_channel_by_id(db, channel_db_id)
if not channel:
return None
for key, value in kwargs.items():
if key not in _UPDATABLE_FIELDS:
logger.warning('Rejected update of non-updatable field', field=key)
continue
if key == 'channel_id' and value is not None:
value = validate_channel_id(value)
setattr(channel, key, value)
channel.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(channel)
return channel
async def delete_channel(db: AsyncSession, channel_db_id: int) -> bool:
channel = await get_channel_by_id(db, channel_db_id)
if not channel:
return False
# Also clean up user subscriptions for this channel
await db.execute(delete(UserChannelSubscription).where(UserChannelSubscription.channel_id == channel.channel_id))
await db.delete(channel)
await db.commit()
return True
async def toggle_channel(db: AsyncSession, channel_db_id: int) -> RequiredChannel | None:
channel = await get_channel_by_id(db, channel_db_id)
if not channel:
return None
channel.is_active = not channel.is_active
channel.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(channel)
return channel
# -- UserChannelSubscription CRUD ------------------------------------------------
async def upsert_user_channel_sub(
db: AsyncSession,
telegram_id: int,
channel_id: str,
is_member: bool,
) -> None:
"""Upsert user subscription status (PostgreSQL ON CONFLICT)."""
now = datetime.now(UTC) # Single timestamp for both INSERT and UPDATE
stmt = (
pg_insert(UserChannelSubscription)
.values(
telegram_id=telegram_id,
channel_id=channel_id,
is_member=is_member,
checked_at=now,
)
.on_conflict_do_update(
constraint='uq_user_channel_sub',
set_={
'is_member': is_member,
'checked_at': now,
},
)
)
await db.execute(stmt)
# NOTE: caller is responsible for commit (allows batching)
async def get_user_channel_subs(
db: AsyncSession,
telegram_id: int,
) -> list[UserChannelSubscription]:
"""Get all channel subscriptions for a user."""
result = await db.execute(select(UserChannelSubscription).where(UserChannelSubscription.telegram_id == telegram_id))
return list(result.scalars().all())
async def get_user_channel_sub(
db: AsyncSession,
telegram_id: int,
channel_id: str,
) -> UserChannelSubscription | None:
result = await db.execute(
select(UserChannelSubscription).where(
UserChannelSubscription.telegram_id == telegram_id,
UserChannelSubscription.channel_id == channel_id,
)
)
return result.scalar_one_or_none()
async def bulk_upsert_user_subs(
db: AsyncSession,
telegram_id: int,
subs: dict[str, bool], # {channel_id: is_member}
) -> None:
"""Batch upsert user subscriptions with single multi-row INSERT."""
if not subs:
return
now = datetime.now(UTC)
values = [
{
'telegram_id': telegram_id,
'channel_id': channel_id,
'is_member': is_member,
'checked_at': now,
}
for channel_id, is_member in subs.items()
]
stmt = pg_insert(UserChannelSubscription).values(values)
stmt = stmt.on_conflict_do_update(
constraint='uq_user_channel_sub',
set_={
'is_member': stmt.excluded.is_member,
'checked_at': stmt.excluded.checked_at,
},
)
await db.execute(stmt)
await db.commit()
+17 -13
View File
@@ -1,13 +1,13 @@
import logging
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import ServiceRule
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_rules_by_language(db: AsyncSession, language: str = 'ru') -> ServiceRule | None:
@@ -30,7 +30,7 @@ async def create_or_update_rules(
for rule in existing_rules:
rule.is_active = False
rule.updated_at = datetime.utcnow()
rule.updated_at = datetime.now(UTC)
new_rules = ServiceRule(title=title, content=content, language=language, is_active=True, order=0)
@@ -38,7 +38,7 @@ async def create_or_update_rules(
await db.commit()
await db.refresh(new_rules)
logger.info(f'✅ Правила для языка {language} обновлены (ID: {new_rules.id})')
logger.info('✅ Правила для языка обновлены (ID: )', language=language, new_rules_id=new_rules.id)
return new_rules
@@ -47,18 +47,20 @@ async def clear_all_rules(db: AsyncSession, language: str = 'ru') -> bool:
result = await db.execute(
update(ServiceRule)
.where(ServiceRule.language == language, ServiceRule.is_active == True)
.values(is_active=False, updated_at=datetime.utcnow())
.values(is_active=False, updated_at=datetime.now(UTC))
)
await db.commit()
rows_affected = result.rowcount
logger.info(f'✅ Очищены правила для языка {language}. Деактивировано записей: {rows_affected}')
logger.info(
'✅ Очищены правила для языка . Деактивировано записей', language=language, rows_affected=rows_affected
)
return rows_affected > 0
except Exception as e:
logger.error(f'❌ Ошибка при очистке правил для языка {language}: {e}')
logger.error('❌ Ошибка при очистке правил для языка', language=language, error=e)
await db.rollback()
raise
@@ -102,13 +104,13 @@ async def restore_rules_version(db: AsyncSession, rule_id: int, language: str =
rule_to_restore = result.scalar_one_or_none()
if not rule_to_restore:
logger.warning(f'Правило с ID {rule_id} не найдено для языка {language}')
logger.warning('Правило с ID не найдено для языка', rule_id=rule_id, language=language)
return None
await db.execute(
update(ServiceRule)
.where(ServiceRule.language == language, ServiceRule.is_active == True)
.values(is_active=False, updated_at=datetime.utcnow())
.values(is_active=False, updated_at=datetime.now(UTC))
)
restored_rule = ServiceRule(
@@ -119,11 +121,13 @@ async def restore_rules_version(db: AsyncSession, rule_id: int, language: str =
await db.commit()
await db.refresh(restored_rule)
logger.info(f'✅ Восстановлена версия правил ID {rule_id} как новое правило ID {restored_rule.id}')
logger.info(
'✅ Восстановлена версия правил ID как новое правило ID', rule_id=rule_id, restored_rule_id=restored_rule.id
)
return restored_rule
except Exception as e:
logger.error(f'❌ Ошибка при восстановлении правил ID {rule_id}: {e}')
logger.error('❌ Ошибка при восстановлении правил ID', rule_id=rule_id, error=e)
await db.rollback()
raise
@@ -156,5 +160,5 @@ async def get_rules_statistics(db: AsyncSession) -> dict:
}
except Exception as e:
logger.error(f'❌ Ошибка при получении статистики правил: {e}')
logger.error('❌ Ошибка при получении статистики правил', error=e)
return {'total_active': 0, 'total_all_time': 0, 'languages': {}, 'total_languages': 0, 'error': str(e)}
+33 -37
View File
@@ -1,8 +1,8 @@
import logging
import random
from collections.abc import Iterable, Sequence
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import (
String,
and_,
@@ -28,7 +28,7 @@ from app.database.models import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def _get_default_promo_group_id(db: AsyncSession) -> int | None:
@@ -64,7 +64,7 @@ async def create_server_squad(
promo_groups = promo_groups_result.scalars().all()
if len(promo_groups) != len(normalized_group_ids):
logger.warning('Не все промогруппы найдены при создании сервера %s', display_name)
logger.warning('Не все промогруппы найдены при создании сервера', display_name=display_name)
server_squad = ServerSquad(
squad_uuid=squad_uuid,
@@ -84,7 +84,7 @@ async def create_server_squad(
await db.commit()
await db.refresh(server_squad)
logger.info(f'✅ Создан сервер {display_name} (UUID: {squad_uuid})')
logger.info('✅ Создан сервер (UUID: )', display_name=display_name, squad_uuid=squad_uuid)
return server_squad
@@ -267,13 +267,17 @@ async def delete_server_squad(db: AsyncSession, server_id: int) -> bool:
connections_count = connections_result.scalar()
if connections_count > 0:
logger.warning(f'⚠ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})')
logger.warning(
'⚠ Нельзя удалить сервер есть активные подключения',
server_id=server_id,
connections_count=connections_count,
)
return False
await db.execute(delete(ServerSquad).where(ServerSquad.id == server_id))
await db.commit()
logger.info(f'🗑️ Удален сервер (ID: {server_id})')
logger.info('🗑️ Удален сервер (ID: )', server_id=server_id)
return True
@@ -322,11 +326,7 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
subscription_ids = {row[0] for row in subscription_ids_result.fetchall()}
for server in removed_servers:
logger.info(
'🗑️ Удаляется сервер %s (UUID: %s)',
server.display_name,
server.squad_uuid,
)
logger.info('🗑️ Удаляется сервер (UUID:)', display_name=server.display_name, squad_uuid=server.squad_uuid)
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids)))
@@ -360,7 +360,7 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
if len(filtered_squads) != len(current_squads):
subscription.connected_squads = filtered_squads
subscription.updated_at = datetime.utcnow()
subscription.updated_at = datetime.now(UTC)
cleaned_subscriptions += 1
# Clean up stale UUIDs from tariff allowed_squads
@@ -373,7 +373,7 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
filtered = [u for u in current if u not in removed_uuids]
if len(filtered) != len(current):
tariff.allowed_squads = filtered
tariff.updated_at = datetime.utcnow()
tariff.updated_at = datetime.now(UTC)
cleaned_tariffs += 1
logger.info(
'🧹 Тариф "%s" (ID: %s): удалены несуществующие сквады %s',
@@ -386,20 +386,14 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
removed = len(removed_servers)
if cleaned_subscriptions:
logger.info(
'🧹 Обновлены подписки после удаления серверов: %s',
cleaned_subscriptions,
)
logger.info('🧹 Обновлены подписки после удаления серверов', cleaned_subscriptions=cleaned_subscriptions)
if cleaned_tariffs:
logger.info(
'🧹 Обновлены тарифы после удаления серверов: %s',
cleaned_tariffs,
)
logger.info('🧹 Обновлены тарифы после удаления серверов', cleaned_tariffs=cleaned_tariffs)
await db.commit()
logger.info(f'🔄 Синхронизация завершена: +{created} ~{updated} -{removed}')
logger.info('🔄 Синхронизация завершена: + ~', created=created, updated=updated, removed=removed)
return created, updated, removed
@@ -767,11 +761,11 @@ async def add_user_to_servers(db: AsyncSession, server_squad_ids: list[int]) ->
)
await db.flush()
logger.info(f'✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}')
logger.info('✅ Увеличен счетчик пользователей для серверов', server_squad_ids=server_squad_ids)
return True
except Exception as e:
logger.error(f'Ошибка увеличения счетчика пользователей: {e}')
logger.error('Ошибка увеличения счетчика пользователей', error=e)
raise
@@ -785,11 +779,11 @@ async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int]
)
await db.flush()
logger.info(f'✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}')
logger.info('✅ Уменьшен счетчик пользователей для серверов', server_squad_ids=server_squad_ids)
return True
except Exception as e:
logger.error(f'Ошибка уменьшения счетчика пользователей: {e}')
logger.error('Ошибка уменьшения счетчика пользователей', error=e)
raise
@@ -836,12 +830,12 @@ async def update_server_user_counts(
await db.flush()
if add_set:
logger.info('✅ Увеличен счетчик пользователей для серверов: %s', sorted(add_set))
logger.info('✅ Увеличен счетчик пользователей для серверов', sorted=sorted(add_set))
if remove_set:
logger.info('✅ Уменьшен счетчик пользователей для серверов: %s', sorted(remove_set))
logger.info('✅ Уменьшен счетчик пользователей для серверов', sorted=sorted(remove_set))
except Exception as e:
logger.error('Ошибка обновления счетчиков серверов: %s', e)
logger.error('Ошибка обновления счетчиков серверов', e=e)
raise
@@ -875,7 +869,7 @@ async def ensure_servers_synced(db: AsyncSession) -> None:
server_count = result.scalar() or 0
if server_count > 0:
logger.info(f'В базе уже есть {server_count} серверов, пропускаем синхронизацию')
logger.info('✅ В базе уже есть серверов, пропускаем синхронизацию', server_count=server_count)
return
logger.info('🔄 Серверов в БД нет, начинаем синхронизацию с RemnaWave...')
@@ -900,10 +894,10 @@ async def ensure_servers_synced(db: AsyncSession) -> None:
# Синхронизируем
created, updated, removed = await sync_with_remnawave(db, squads)
logger.info(f'✅ Серверы синхронизированы: +{created} ~{updated} -{removed}')
logger.info('✅ Серверы синхронизированы: + ~', created=created, updated=updated, removed=removed)
except Exception as e:
logger.error(f'❌ Ошибка синхронизации серверов: {e}')
logger.error('❌ Ошибка синхронизации серверов', error=e)
async def sync_server_user_counts(db: AsyncSession) -> int:
@@ -911,7 +905,7 @@ async def sync_server_user_counts(db: AsyncSession) -> int:
all_servers_result = await db.execute(select(ServerSquad.id, ServerSquad.squad_uuid))
all_servers = all_servers_result.fetchall()
logger.info(f'🔍 Найдено серверов для синхронизации: {len(all_servers)}')
logger.info('🔍 Найдено серверов для синхронизации', all_servers_count=len(all_servers))
updated_count = 0
for server_id, squad_uuid in all_servers:
@@ -926,16 +920,18 @@ async def sync_server_user_counts(db: AsyncSession) -> int:
)
actual_users = count_result.scalar() or 0
logger.info(f'📊 Сервер {server_id} ({squad_uuid[:8]}): {actual_users} пользователей')
logger.info(
'📊 Сервер пользователей', server_id=server_id, squad_uuid=squad_uuid[:8], actual_users=actual_users
)
await db.execute(update(ServerSquad).where(ServerSquad.id == server_id).values(current_users=actual_users))
updated_count += 1
await db.commit()
logger.info(f'✅ Синхронизированы счетчики для {updated_count} серверов')
logger.info('✅ Синхронизированы счетчики для серверов', updated_count=updated_count)
return updated_count
except Exception as e:
logger.error(f'Ошибка синхронизации счетчиков пользователей: {e}')
logger.error('Ошибка синхронизации счетчиков пользователей', error=e)
await db.rollback()
return 0
+3 -4
View File
@@ -1,12 +1,11 @@
import logging
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Squad
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_squad_by_uuid(db: AsyncSession, uuid: str) -> Squad | None:
@@ -28,7 +27,7 @@ async def create_squad(
await db.commit()
await db.refresh(squad)
logger.info(f'✅ Создан сквад: {name}')
logger.info('✅ Создан сквад', name=name)
return squad
File diff suppressed because it is too large Load Diff

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