Compare commits

..

115 Commits

Author SHA1 Message Date
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
262 changed files with 6436 additions and 11016 deletions
+6 -1
View File
@@ -371,7 +371,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 +385,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 = основной чат)
@@ -845,6 +848,8 @@ VERSION_CHECK_INTERVAL_HOURS=1
# ===== ЛОГИРОВАНИЕ =====
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
# ANSI-цвета в консоли (true — цветной вывод с Rich, false — plain-text)
LOG_COLORS=true
# === Ротация логов ===
# Включить новую систему ротации (по умолчанию старое поведение)
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 0
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 0
+3 -3
View File
@@ -10,11 +10,11 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v7
- uses: astral-sh/setup-uv@v5
- uses: actions/setup-python@v6
- uses: actions/setup-python@v5
with:
python-version: '3.13'
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.12.1"
".": "3.17.1"
}
+184
View File
@@ -1,5 +1,189 @@
# Changelog
## [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)
+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.1" # x-release-please-version
ARG VERSION="v3.17.1" # 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]
+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)
+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):
+46 -1
View File
@@ -4,7 +4,7 @@ import asyncio
import structlog
from aiogram import Bot
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,6 +16,7 @@ from app.services.blacklist_service import blacklist_service
from app.services.maintenance_service import maintenance_service
from .auth.jwt_handler import get_token_payload
from .auth.telegram_auth import validate_telegram_init_data
logger = structlog.get_logger(__name__)
@@ -44,6 +45,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 +53,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 +108,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)
@@ -173,6 +204,7 @@ async def get_current_cabinet_user(
async def get_optional_cabinet_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User | None:
@@ -200,6 +232,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
+8
View File
@@ -8,6 +8,7 @@ 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_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 +24,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 +33,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 +46,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 +58,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)
@@ -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)
+5 -3
View File
@@ -1,6 +1,6 @@
"""Admin routes for broadcasts in cabinet."""
from datetime import datetime
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -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,
@@ -724,7 +726,7 @@ 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)
+86 -8
View File
@@ -1,5 +1,7 @@
"""Admin routes for managing advertising campaigns in cabinet."""
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
@@ -20,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,
@@ -28,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,
@@ -57,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),
@@ -132,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,
@@ -158,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,
)
)
@@ -201,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),
)
@@ -248,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),
)
@@ -288,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,
@@ -315,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,
)
)
@@ -358,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,
@@ -372,6 +431,7 @@ 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
@@ -443,8 +503,26 @@ 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('Admin updated campaign', admin_id=admin.id, campaign_id=campaign_id)
+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}
+5 -5
View File
@@ -1,7 +1,7 @@
"""Admin routes for pinned messages in cabinet."""
import time
from datetime import datetime
from datetime import UTC, datetime
import structlog
from aiogram import Bot
@@ -227,7 +227,7 @@ 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)
@@ -255,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)
@@ -332,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)
+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
+6 -3
View File
@@ -1,6 +1,6 @@
"""Admin routes for RemnaWave management in cabinet."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
@@ -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:
+5 -5
View File
@@ -2,7 +2,7 @@
import sys
import time
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
@@ -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)
@@ -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)
@@ -601,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)
@@ -874,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)
+1 -1
View File
@@ -535,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,
+7 -7
View File
@@ -1,7 +1,7 @@
"""Admin tickets routes for cabinet."""
import math
from datetime import datetime
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -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:
@@ -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)
@@ -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)
+8 -2
View File
@@ -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)
@@ -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),
+3 -3
View File
@@ -1,6 +1,6 @@
"""Admin routes for version and release information."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import aiohttp
import structlog
@@ -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,7 +75,7 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
}
)
_cabinet_cache['releases'] = releases
_cabinet_last_check = datetime.now()
_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 for cabinet releases', response_status=response.status)
+107 -77
View File
@@ -1,6 +1,6 @@
"""Admin routes for managing users in cabinet."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -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 (
@@ -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)
@@ -225,13 +225,13 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
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,
@@ -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,
@@ -330,7 +330,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
changes['panel_uuid'] = 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
@@ -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,
@@ -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')
@@ -1011,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
@@ -1107,7 +1109,7 @@ 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)
@@ -1124,9 +1126,9 @@ 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)
@@ -1196,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,
@@ -1388,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)
@@ -1456,7 +1458,7 @@ 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)
@@ -1511,7 +1513,7 @@ 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)
@@ -1552,7 +1554,7 @@ 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(
@@ -1771,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('Admin fully deleted user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
logger.info('Admin fully deleted user', admin_id=admin_id_val, user_id=user_id, reason_text=reason_text)
return FullDeleteUserResponse(
success=success,
@@ -1816,28 +1821,37 @@ async def reset_user_trial(
# Delete subscription if exists
if user.subscription:
# Deactivate in Remnawave panel first
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
from app.database.crud.subscription import is_active_paid_subscription
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск удаления подписки и RemnaWave: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
else:
# Deactivate in Remnawave panel first
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
# Delete subscription from database
from sqlalchemy import delete
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
subscription_id = user.subscription.id
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
# Delete subscription from database
from sqlalchemy import delete
subscription_id = user.subscription.id
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
# Reset trial flag
user.has_used_trial = False
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
@@ -1886,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:
@@ -1907,7 +1936,7 @@ 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 ''
@@ -1948,8 +1977,16 @@ async def disable_user(
panel_deactivated = False
panel_error: str | None = None
# Deactivate subscription in panel
if user.remnawave_uuid:
# Deactivate subscription in panel (skip if active paid subscription)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск отключения RemnaWave: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
elif user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
@@ -1961,8 +1998,8 @@ async def disable_user(
panel_error = str(e)
logger.warning('Failed to disable Remnawave user', error=e)
# Deactivate subscription in bot database
if user.subscription:
# Deactivate subscription in bot database (skip if active paid subscription)
if user.subscription and not is_active_paid_subscription(user.subscription):
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, user.subscription)
@@ -1971,7 +2008,7 @@ async def disable_user(
# 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 ''
@@ -2062,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,
@@ -2179,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
@@ -2319,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,
@@ -2338,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(),
@@ -2353,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:
@@ -2410,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,
@@ -2427,8 +2457,8 @@ 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()
@@ -2502,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,
@@ -2595,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,
@@ -2620,8 +2650,8 @@ 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()
+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}
+163 -20
View File
@@ -11,6 +11,10 @@ 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,
@@ -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:
@@ -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
@@ -279,6 +374,16 @@ async def auth_telegram(
tg_last_name = user_data.get('last_name')
tg_language = user_data.get('language_code', 'ru')
# Resolve referral code to referrer ID for new users
referrer_id = None
if request.referral_code and not user:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
if not user:
# Create new user from Telegram initData
logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
@@ -289,6 +394,7 @@ async def auth_telegram(
first_name=tg_first_name,
last_name=tg_last_name,
language=tg_language,
referred_by_id=referrer_id,
)
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
else:
@@ -313,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)
@@ -321,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
@@ -335,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(
@@ -345,6 +459,16 @@ async def auth_telegram_widget(
user = await get_user_by_telegram_id(db, request.id)
# Resolve referral code to referrer ID for new users
referrer_id = None
if request.referral_code and not user:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
if not user:
# Create new user from Telegram data
logger.info(
@@ -357,6 +481,7 @@ async def auth_telegram_widget(
first_name=request.first_name,
last_name=request.last_name,
language='ru',
referred_by_id=referrer_id,
)
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
@@ -374,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
@@ -546,7 +679,7 @@ 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('Test email auto-verified: user_id', email=request.email, user_id=user.id)
else:
@@ -633,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()
@@ -647,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
@@ -750,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(
@@ -783,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
@@ -871,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'}
+2 -2
View File
@@ -1,7 +1,7 @@
"""Contests routes for cabinet - user participation in games/contests."""
import random
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
@@ -102,7 +102,7 @@ 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)
+2 -2
View File
@@ -1,6 +1,6 @@
"""Notification settings routes for cabinet."""
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import structlog
@@ -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)
+51 -7
View File
@@ -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,7 +25,7 @@ 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 = structlog.get_logger(__name__)
@@ -32,12 +33,27 @@ 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 ---
@@ -140,7 +160,7 @@ async def oauth_callback(
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
if user:
logger.info('OAuth login via for existing user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider)
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:
@@ -148,9 +168,32 @@ async def oauth_callback(
if user:
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider)
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 with id', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider)
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,
)
+3 -3
View File
@@ -1,6 +1,6 @@
"""Polls routes for cabinet - user participation in polls/surveys."""
from datetime import datetime
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
@@ -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
+5 -5
View File
@@ -1,6 +1,6 @@
"""Promo offers routes for cabinet - personal discounts and offers."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
@@ -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()
+21 -5
View File
@@ -9,7 +9,7 @@ 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 (
@@ -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,
)
+32 -37
View File
@@ -135,7 +135,7 @@ def _subscription_to_response(
traffic_purchases: list[dict[str, Any]] | None = None,
) -> SubscriptionData:
"""Convert Subscription model to response."""
now = datetime.utcnow()
now = datetime.now(UTC)
# Use actual_status property for correct status (same as bot uses)
actual_status = subscription.actual_status
@@ -190,11 +190,15 @@ def _subscription_to_response(
elif tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
is_daily = getattr(subscription.tariff, 'is_daily', False)
# Get daily_price_kopeks and tariff_name from tariff (separate from is_daily check)
# Get daily_price_kopeks, tariff_name, traffic_reset_mode from tariff
traffic_reset_mode = None
if tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
daily_price_kopeks = getattr(subscription.tariff, 'daily_price_kopeks', None)
if not tariff_name: # Only set if not passed as parameter
tariff_name = getattr(subscription.tariff, 'name', None)
traffic_reset_mode = (
getattr(subscription.tariff, 'traffic_reset_mode', None) or settings.DEFAULT_TRAFFIC_RESET_STRATEGY
)
# Calculate next daily charge time (24 hours after last charge)
next_daily_charge_at = None
@@ -235,6 +239,7 @@ def _subscription_to_response(
next_daily_charge_at=next_daily_charge_at,
tariff_id=tariff_id,
tariff_name=tariff_name,
traffic_reset_mode=traffic_reset_mode,
)
@@ -276,7 +281,7 @@ async def get_subscription(
traffic_purchases_data = []
from app.database.models import TrafficPurchase
now = datetime.utcnow()
now = datetime.now(UTC)
purchases_query = (
select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == fresh_user.subscription.id)
@@ -515,7 +520,7 @@ async def renew_subscription(
user.promo_offer_discount_expires_at = None
# Extend from end_date or now if expired
now = datetime.utcnow()
now = datetime.now(UTC)
if user.subscription.end_date and user.subscription.end_date > now:
user.subscription.end_date = user.subscription.end_date + timedelta(days=request.period_days)
else:
@@ -832,9 +837,7 @@ async def purchase_traffic(
# Устанавливаем дату сброса трафика (только при первой докупке)
# При повторной докупке дата НЕ продлевается
if not subscription.traffic_reset_at:
from datetime import timedelta
subscription.traffic_reset_at = datetime.utcnow() + timedelta(days=30)
subscription.traffic_reset_at = datetime.now(UTC) + timedelta(days=30)
logger.info(
'Set traffic_reset_at for subscription',
subscription_id=subscription.id,
@@ -1137,7 +1140,7 @@ async def get_trial_info(
# Check if user already has an active subscription
if user.subscription:
now = datetime.utcnow()
now = datetime.now(UTC)
is_active = (
user.subscription.status == 'active' and user.subscription.end_date and user.subscription.end_date > now
)
@@ -1194,7 +1197,7 @@ async def activate_trial(
# Check if user already has an active subscription
if user.subscription:
now = datetime.utcnow()
now = datetime.now(UTC)
is_active = (
user.subscription.status == 'active' and user.subscription.end_date and user.subscription.end_date > now
)
@@ -1484,6 +1487,8 @@ async def _build_tariff_response(
# Дневной тариф
'is_daily': getattr(tariff, 'is_daily', False),
'daily_price_kopeks': daily_price,
# Сброс трафика
'traffic_reset_mode': tariff.traffic_reset_mode or settings.DEFAULT_TRAFFIC_RESET_STRATEGY,
}
# Add promo group info if user has discounts
@@ -2010,7 +2015,7 @@ async def purchase_tariff(
# For daily tariffs, set last_daily_charge_at
if is_daily_tariff:
subscription.last_daily_charge_at = datetime.utcnow()
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.is_daily_paused = False
await db.commit()
await db.refresh(subscription)
@@ -2088,7 +2093,7 @@ async def purchase_tariff(
try:
# Determine if this is a new subscription or extension
was_new_subscription = (
subscription.start_date and (datetime.utcnow() - subscription.start_date).total_seconds() < 60
subscription.start_date and (datetime.now(UTC) - subscription.start_date).total_seconds() < 60
)
notification_type = (
NotificationType.SUBSCRIPTION_ACTIVATED
@@ -2124,7 +2129,7 @@ async def purchase_tariff(
notification_service = AdminNotificationService(bot)
# Определяем тип покупки: новая подписка или продление
was_new_subscription = (
subscription.start_date and (datetime.utcnow() - subscription.start_date).total_seconds() < 60
subscription.start_date and (datetime.now(UTC) - subscription.start_date).total_seconds() < 60
)
await notification_service.send_subscription_purchase_notification(
db=db,
@@ -2211,8 +2216,6 @@ async def purchase_devices(
)
# Calculate prorated price based on remaining days
from datetime import datetime
now = datetime.now(UTC)
end_date = subscription.end_date
if end_date.tzinfo is None:
@@ -2627,8 +2630,6 @@ async def get_device_price(
}
# Calculate prorated price
from datetime import datetime
now = datetime.now(UTC)
end_date = subscription.end_date
if end_date.tzinfo is None:
@@ -3079,9 +3080,7 @@ async def get_available_countries(
connected_squads = user.subscription.connected_squads or []
# Calculate days left for prorated pricing
if user.subscription.end_date:
from datetime import datetime
delta = user.subscription.end_date - datetime.utcnow()
delta = user.subscription.end_date - datetime.now(UTC)
days_left = max(0, delta.days)
# Get discount from promo group
@@ -3263,7 +3262,7 @@ async def update_countries(
# Update connected squads
user.subscription.connected_squads = selected_countries
user.subscription.updated_at = datetime.utcnow()
user.subscription.updated_at = datetime.now(UTC)
await db.commit()
# Sync with RemnaWave
@@ -3910,7 +3909,7 @@ async def reduce_devices(
# Update subscription
subscription.device_limit = new_device_limit
subscription.updated_at = datetime.utcnow()
subscription.updated_at = datetime.now(UTC)
await db.commit()
# Update RemnaWave
@@ -4010,8 +4009,8 @@ async def preview_tariff_switch(
# Calculate remaining days
remaining_days = 0
if user.subscription.end_date and user.subscription.end_date > datetime.utcnow():
delta = user.subscription.end_date - datetime.utcnow()
if user.subscription.end_date and user.subscription.end_date > datetime.now(UTC):
delta = user.subscription.end_date - datetime.now(UTC)
remaining_days = max(0, delta.days)
# Calculate switch cost
@@ -4128,8 +4127,6 @@ async def switch_tariff(
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Switch to a different tariff without changing end date."""
from datetime import timedelta
if not settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -4195,8 +4192,8 @@ async def switch_tariff(
# Calculate remaining days
remaining_days = 0
if user.subscription.end_date and user.subscription.end_date > datetime.utcnow():
delta = user.subscription.end_date - datetime.utcnow()
if user.subscription.end_date and user.subscription.end_date > datetime.now(UTC):
delta = user.subscription.end_date - datetime.now(UTC)
remaining_days = max(0, delta.days)
# Calculate cost
@@ -4334,14 +4331,14 @@ async def switch_tariff(
if switching_to_daily:
# Switching TO daily - reset end_date to 1 day, set last_daily_charge_at
user.subscription.end_date = datetime.utcnow() + timedelta(days=1)
user.subscription.last_daily_charge_at = datetime.utcnow()
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
user.subscription.last_daily_charge_at = datetime.now(UTC)
user.subscription.is_daily_paused = False
elif switching_from_daily:
user.subscription.end_date = datetime.utcnow() + timedelta(days=new_period_days)
user.subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days)
user.subscription.is_daily_paused = False
user.subscription.updated_at = datetime.utcnow()
user.subscription.updated_at = datetime.now(UTC)
await db.commit()
# Sync with RemnaWave
@@ -4425,8 +4422,6 @@ async def toggle_subscription_pause(
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Toggle pause/resume for daily subscription."""
from datetime import timedelta
await db.refresh(user, ['subscription'])
if not user.subscription:
@@ -4476,8 +4471,8 @@ async def toggle_subscription_pause(
# Restore ACTIVE status if was DISABLED
if was_disabled:
user.subscription.status = SubscriptionStatus.ACTIVE.value
user.subscription.last_daily_charge_at = datetime.utcnow()
user.subscription.end_date = datetime.utcnow() + timedelta(days=1)
user.subscription.last_daily_charge_at = datetime.now(UTC)
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
await db.commit()
await db.refresh(user.subscription)
@@ -4614,7 +4609,7 @@ async def switch_traffic_package(
user.subscription.traffic_limit_gb = new_traffic
user.subscription.purchased_traffic_gb = 0 # Reset purchased traffic on switch
user.subscription.traffic_reset_at = None # Reset traffic reset date
user.subscription.updated_at = datetime.utcnow()
user.subscription.updated_at = datetime.now(UTC)
await db.commit()
# Sync with RemnaWave
@@ -4733,7 +4728,7 @@ async def refresh_traffic(
used_gb = traffic_stats.get('used_traffic_gb', 0)
if abs((user.subscription.traffic_used_gb or 0) - used_gb) > 0.01:
user.subscription.traffic_used_gb = used_gb
user.subscription.updated_at = datetime.utcnow()
user.subscription.updated_at = datetime.now(UTC)
await db.commit()
# Calculate percentage
+6 -6
View File
@@ -1,7 +1,7 @@
"""Support tickets routes for cabinet."""
import math
from datetime import datetime
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -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()
@@ -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)
+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."""
+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)
@@ -4,7 +4,7 @@ Service for managing email template overrides stored in the database.
Custom templates override the hardcoded defaults from email_templates.py.
"""
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import structlog
@@ -124,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
# ============================================================================
+4
View File
@@ -230,7 +230,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 +551,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,6 +673,7 @@ 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'
+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',
]
+8 -63
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import and_, delete, func, select, update
@@ -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)
@@ -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,7 +156,7 @@ 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()
@@ -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)
+4 -4
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import structlog
@@ -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,7 +190,7 @@ 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)
+3 -3
View File
@@ -1,5 +1,5 @@
from collections.abc import Sequence
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import and_, delete, desc, select
@@ -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))
+4 -6
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import and_, select
@@ -76,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
@@ -97,7 +97,7 @@ 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)
@@ -120,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)
+5 -5
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import func, select
@@ -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)
@@ -190,7 +190,7 @@ async def mark_offer_claimed(
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,
+4 -4
View File
@@ -1,5 +1,5 @@
from collections.abc import Iterable
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import delete, func, select, update
@@ -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,
@@ -117,7 +117,7 @@ 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)
@@ -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()
+4 -4
View File
@@ -1,7 +1,7 @@
"""CRUD операции для платежей Freekassa."""
import json
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
@@ -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:
@@ -134,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',
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import structlog
@@ -129,7 +129,7 @@ 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)
@@ -157,7 +157,7 @@ async def link_heleket_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)
+4 -4
View File
@@ -1,7 +1,7 @@
"""CRUD операции для платежей KassaAI."""
import json
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
@@ -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:
@@ -136,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',
+4 -4
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
@@ -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
+3 -3
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import structlog
@@ -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,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
@@ -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,
@@ -52,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,
+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)
+5 -7
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import and_, func, select
@@ -114,7 +114,7 @@ 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()
@@ -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)
@@ -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)
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
@@ -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,
@@ -52,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,
+26 -9
View File
@@ -1,16 +1,27 @@
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 = 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(
db: AsyncSession,
user_id: int,
@@ -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,6 +37,7 @@ async def create_referral_earning(
amount_kopeks=amount_kopeks,
reason=reason,
referral_transaction_id=referral_transaction_id,
campaign_id=campaign_id,
)
db.add(earning)
@@ -42,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)
@@ -178,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)
@@ -190,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)
)
@@ -201,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)
)
@@ -249,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
@@ -380,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))
+8 -8
View File
@@ -1,5 +1,5 @@
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
@@ -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),
@@ -773,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),
+4 -4
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import select, update
@@ -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)
@@ -47,7 +47,7 @@ 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()
@@ -110,7 +110,7 @@ async def restore_rules_version(db: AsyncSession, rule_id: int, language: str =
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(
+3 -3
View File
@@ -1,6 +1,6 @@
import random
from collections.abc import Iterable, Sequence
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import (
@@ -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',
+87 -68
View File
@@ -17,6 +17,7 @@ from app.database.models import (
SubscriptionStatus,
User,
UserPromoGroup,
UserStatus,
)
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
from app.utils.timezone import format_local_datetime
@@ -31,10 +32,22 @@ def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
"""Return True if subscription was updated by webhook within guard window."""
if not subscription.last_webhook_update_at:
return False
elapsed = (datetime.now(UTC).replace(tzinfo=None) - subscription.last_webhook_update_at).total_seconds()
elapsed = (datetime.now(UTC) - subscription.last_webhook_update_at).total_seconds()
return elapsed < _WEBHOOK_GUARD_SECONDS
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
"""Return True if subscription is active, paid (non-trial), and not expired."""
if not subscription:
return False
return (
not subscription.is_trial
and subscription.status == SubscriptionStatus.ACTIVE.value
and subscription.end_date is not None
and subscription.end_date > datetime.now(UTC)
)
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None:
result = await db.execute(
select(Subscription)
@@ -101,13 +114,13 @@ async def create_trial_subscription(
except Exception as error:
logger.error('Не удалось получить сквад для триальной подписки пользователя', user_id=user_id, error=error)
end_date = datetime.utcnow() + timedelta(days=duration_days)
end_date = datetime.now(UTC) + timedelta(days=duration_days)
# Check for existing PENDING trial subscription (retry after failed payment)
existing = await get_subscription_by_user_id(db, user_id)
if existing and existing.is_trial and existing.status == SubscriptionStatus.PENDING.value:
existing.status = SubscriptionStatus.ACTIVE.value
existing.start_date = datetime.utcnow()
existing.start_date = datetime.now(UTC)
existing.end_date = end_date
existing.traffic_limit_gb = traffic_limit_gb
existing.device_limit = device_limit
@@ -124,7 +137,7 @@ async def create_trial_subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
is_trial=True,
start_date=datetime.utcnow(),
start_date=datetime.now(UTC),
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
@@ -176,7 +189,7 @@ async def create_paid_subscription(
is_trial: bool = False,
tariff_id: int | None = None,
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
end_date = datetime.now(UTC) + timedelta(days=duration_days)
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
@@ -185,7 +198,7 @@ async def create_paid_subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
is_trial=is_trial,
start_date=datetime.utcnow(),
start_date=datetime.now(UTC),
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
@@ -253,7 +266,7 @@ async def replace_subscription(
) -> Subscription:
"""Перезаписывает параметры существующей подписки пользователя."""
current_time = datetime.utcnow()
current_time = datetime.now(UTC)
old_squads = set(subscription.connected_squads or [])
new_squads = set(connected_squads or [])
@@ -341,7 +354,7 @@ async def extend_subscription(
device_limit: Лимит устройств (опционально, для режима тарифов)
connected_squads: Список UUID сквадов (опционально, для режима тарифов)
"""
current_time = datetime.utcnow()
current_time = datetime.now(UTC)
logger.info('🔄 Продление подписки на дней', subscription_id=subscription.id, days=days)
logger.info(
@@ -358,45 +371,41 @@ async def extend_subscription(
if is_tariff_change:
logger.info('🔄 Обнаружена СМЕНА тарифа: →', tariff_id=subscription.tariff_id, tariff_id_2=tariff_id)
# Бонусные дни от триала - добавляются ТОЛЬКО когда подписка истекла
# и мы начинаем отсчёт с текущей даты. НЕ начисляются при смене тарифа.
# Если подписка ещё активна - просто добавляем дни к существующей дате окончания.
bonus_days = 0
if days < 0:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(
'📅 Срок подписки уменьшен на дней, новая дата окончания', abs=abs(days), end_date=subscription.end_date
)
elif is_tariff_change:
# При СМЕНЕ тарифа срок начинается с текущей даты + бонус от триала
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
if subscription.end_date and subscription.end_date > current_time:
# При СМЕНЕ тарифа сохраняем оставшееся время активной подписки
# Для триалов — только если включена настройка TRIAL_ADD_REMAINING_DAYS_TO_PAID
remaining_seconds = 0
if subscription.end_date and subscription.end_date > current_time:
if not subscription.is_trial or settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
remaining = subscription.end_date - current_time
if remaining.total_seconds() > 0:
bonus_days = max(0, remaining.days)
logger.info(
'🎁 Обнаружен остаток триала: дней для подписки',
bonus_days=bonus_days,
subscription_id=subscription.id,
)
total_days = days + bonus_days
subscription.end_date = current_time + timedelta(days=total_days)
remaining_seconds = max(0, remaining.total_seconds())
logger.info(
'🎁 Обнаружен остаток подписки, будет добавлен к новому сроку',
remaining_seconds=int(remaining_seconds),
subscription_id=subscription.id,
is_trial=subscription.is_trial,
)
subscription.end_date = current_time + timedelta(days=days, seconds=remaining_seconds)
subscription.start_date = current_time
logger.info('📅 СМЕНА тарифа: срок начинается с текущей даты + дней', total_days=total_days)
logger.info(
'📅 СМЕНА тарифа: срок начинается с текущей даты + дней + остаток',
days=days,
remaining_seconds=int(remaining_seconds),
)
elif subscription.end_date > current_time:
# Подписка активна - просто добавляем дни к текущей дате окончания
# БЕЗ бонусных дней (они уже учтены в end_date)
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info('📅 Подписка активна, добавляем дней к текущей дате окончания', days=days)
else:
# Подписка истекла - начинаем с текущей даты + бонус от триала
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
# Триал истёк, но бонус всё равно не добавляем (триал уже истёк)
pass
total_days = days + bonus_days
subscription.end_date = current_time + timedelta(days=total_days)
logger.info('📅 Подписка истекла, устанавливаем новую дату окончания на дней', total_days=total_days)
# Подписка истекла - начинаем с текущей даты
subscription.end_date = current_time + timedelta(days=days)
logger.info('📅 Подписка истекла, устанавливаем новую дату окончания на дней', days=days)
# УДАЛЕНО: Автоматическая конвертация триала по длительности
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
@@ -535,16 +544,12 @@ async def extend_subscription(
async def add_subscription_traffic(db: AsyncSession, subscription: Subscription, gb: int) -> Subscription:
subscription.add_traffic(gb)
subscription.updated_at = datetime.utcnow()
subscription.updated_at = datetime.now(UTC)
# Создаём новую запись докупки с индивидуальной датой истечения (30 дней)
from datetime import timedelta
from sqlalchemy import select as sql_select
from app.database.models import TrafficPurchase
new_expires_at = datetime.utcnow() + timedelta(days=30)
new_expires_at = datetime.now(UTC) + timedelta(days=30)
new_purchase = TrafficPurchase(subscription_id=subscription.id, traffic_gb=gb, expires_at=new_expires_at)
db.add(new_purchase)
@@ -553,9 +558,9 @@ async def add_subscription_traffic(db: AsyncSession, subscription: Subscription,
subscription.purchased_traffic_gb = current_purchased + gb
# Устанавливаем traffic_reset_at на ближайшую дату истечения из всех активных докупок
now = datetime.utcnow()
now = datetime.now(UTC)
active_purchases_query = (
sql_select(TrafficPurchase)
select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == subscription.id)
.where(TrafficPurchase.expires_at > now)
)
@@ -585,7 +590,7 @@ async def add_subscription_traffic(db: AsyncSession, subscription: Subscription,
async def add_subscription_devices(db: AsyncSession, subscription: Subscription, devices: int) -> Subscription:
subscription.device_limit += devices
subscription.updated_at = datetime.utcnow()
subscription.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
@@ -597,7 +602,7 @@ async def add_subscription_devices(db: AsyncSession, subscription: Subscription,
async def add_subscription_squad(db: AsyncSession, subscription: Subscription, squad_uuid: str) -> Subscription:
if squad_uuid not in subscription.connected_squads:
subscription.connected_squads = subscription.connected_squads + [squad_uuid]
subscription.updated_at = datetime.utcnow()
subscription.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
@@ -612,7 +617,7 @@ async def remove_subscription_squad(db: AsyncSession, subscription: Subscription
squads = subscription.connected_squads.copy()
squads.remove(squad_uuid)
subscription.connected_squads = squads
subscription.updated_at = datetime.utcnow()
subscription.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
@@ -688,7 +693,7 @@ async def update_subscription_autopay(
) -> Subscription:
subscription.autopay_enabled = enabled
subscription.autopay_days_before = days_before
subscription.updated_at = datetime.utcnow()
subscription.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
@@ -700,7 +705,7 @@ async def update_subscription_autopay(
async def deactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
subscription.status = SubscriptionStatus.DISABLED.value
subscription.updated_at = datetime.utcnow()
subscription.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
@@ -715,7 +720,7 @@ async def reactivate_subscription(db: AsyncSession, subscription: Subscription)
Активирует только если подписка была DISABLED и ещё не истекла.
Не логирует если реактивация не требуется.
"""
now = datetime.utcnow()
now = datetime.now(UTC)
# Тихо выходим если реактивация не нужна
if subscription.status != SubscriptionStatus.DISABLED.value:
@@ -734,16 +739,18 @@ async def reactivate_subscription(db: AsyncSession, subscription: Subscription)
async def get_expiring_subscriptions(db: AsyncSession, days_before: int = 3) -> list[Subscription]:
threshold_date = datetime.utcnow() + timedelta(days=days_before)
threshold_date = datetime.now(UTC) + timedelta(days=days_before)
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
User.status == UserStatus.ACTIVE.value,
Subscription.end_date <= threshold_date,
Subscription.end_date > datetime.utcnow(),
Subscription.end_date > datetime.now(UTC),
)
)
)
@@ -753,17 +760,25 @@ async def get_expiring_subscriptions(db: AsyncSession, days_before: int = 3) ->
async def get_expired_subscriptions(db: AsyncSession) -> list[Subscription]:
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(selectinload(Subscription.user))
.where(and_(Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.end_date <= datetime.utcnow()))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
User.status == UserStatus.ACTIVE.value,
Subscription.end_date <= datetime.now(UTC),
)
)
)
return result.scalars().all()
async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]:
current_time = datetime.utcnow()
current_time = datetime.now(UTC)
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
@@ -771,6 +786,7 @@ async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]:
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
User.status == UserStatus.ACTIVE.value,
Subscription.autopay_enabled == True,
Subscription.is_trial == False,
)
@@ -811,7 +827,7 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
paid_subscriptions = active_subscriptions - trial_subscriptions
today = datetime.utcnow().date()
today = datetime.now(UTC).date()
today_result = await db.execute(
select(func.count(Subscription.id)).where(
and_(Subscription.created_at >= today, Subscription.is_trial == False)
@@ -819,7 +835,7 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
)
purchased_today = today_result.scalar()
week_ago = datetime.utcnow() - timedelta(days=7)
week_ago = datetime.now(UTC) - timedelta(days=7)
week_result = await db.execute(
select(func.count(Subscription.id)).where(
and_(Subscription.created_at >= week_ago, Subscription.is_trial == False)
@@ -827,7 +843,7 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
)
purchased_week = week_result.scalar()
month_ago = datetime.utcnow() - timedelta(days=30)
month_ago = datetime.now(UTC) - timedelta(days=30)
month_result = await db.execute(
select(func.count(Subscription.id)).where(
and_(Subscription.created_at >= month_ago, Subscription.is_trial == False)
@@ -880,7 +896,7 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
async def get_trial_statistics(db: AsyncSession) -> dict:
now = datetime.utcnow()
now = datetime.now(UTC)
total_trials_result = await db.execute(select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True)))
total_trials = total_trials_result.scalar() or 0
@@ -913,7 +929,7 @@ async def get_trial_statistics(db: AsyncSession) -> dict:
async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> int:
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(Subscription)
@@ -970,7 +986,7 @@ async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) ->
async def update_subscription_usage(db: AsyncSession, subscription: Subscription, used_gb: float) -> Subscription:
subscription.traffic_used_gb = used_gb
subscription.updated_at = datetime.utcnow()
subscription.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
@@ -1522,7 +1538,7 @@ async def calculate_addon_cost_for_remaining_period(
async def expire_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = datetime.utcnow()
subscription.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
@@ -1532,7 +1548,7 @@ async def expire_subscription(db: AsyncSession, subscription: Subscription) -> S
async def check_and_update_subscription_status(db: AsyncSession, subscription: Subscription) -> Subscription:
current_time = datetime.utcnow()
current_time = datetime.now(UTC)
logger.info(
'🔍 Проверка статуса подписки , текущий статус дата окончания текущее время',
@@ -1595,7 +1611,7 @@ async def create_subscription_no_commit(
"""
if end_date is None:
end_date = datetime.utcnow() + timedelta(days=3)
end_date = datetime.now(UTC) + timedelta(days=3)
if connected_squads is None:
connected_squads = []
@@ -1645,7 +1661,7 @@ async def create_subscription(
autopay_days_before: int | None = None,
) -> Subscription:
if end_date is None:
end_date = datetime.utcnow() + timedelta(days=3)
end_date = datetime.now(UTC) + timedelta(days=3)
if connected_squads is None:
connected_squads = []
@@ -1693,7 +1709,7 @@ async def create_pending_subscription(
is_trial: If True, marks the subscription as a trial subscription.
"""
trial_label = 'триальная ' if is_trial else ''
current_time = datetime.utcnow()
current_time = datetime.now(UTC)
end_date = current_time + timedelta(days=duration_days)
existing_subscription = await get_subscription_by_user_id(db, user_id)
@@ -1809,7 +1825,7 @@ async def activate_pending_subscription(db: AsyncSession, user_id: int, period_d
)
# Обновляем статус подписки на ACTIVE
current_time = datetime.utcnow()
current_time = datetime.now(UTC)
pending_subscription.status = SubscriptionStatus.ACTIVE.value
# Если указан период, обновляем дату окончания
@@ -1870,7 +1886,7 @@ async def activate_pending_trial_subscription(
)
# Обновляем статус подписки на ACTIVE
current_time = datetime.utcnow()
current_time = datetime.now(UTC)
pending_subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем даты
@@ -1914,12 +1930,13 @@ async def get_daily_subscriptions_for_charge(db: AsyncSession) -> list[Subscript
"""
from app.database.models import Tariff
now = datetime.utcnow()
now = datetime.now(UTC)
one_day_ago = now - timedelta(hours=24)
query = (
select(Subscription)
.join(Tariff, Subscription.tariff_id == Tariff.id)
.join(User, Subscription.user_id == User.id)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
@@ -1929,6 +1946,7 @@ async def get_daily_subscriptions_for_charge(db: AsyncSession) -> list[Subscript
Tariff.is_daily.is_(True),
Tariff.is_active.is_(True),
Subscription.status == SubscriptionStatus.ACTIVE.value,
User.status == UserStatus.ACTIVE.value,
Subscription.is_daily_paused.is_(False),
Subscription.is_trial.is_(False), # Не списываем с триальных подписок
# Списания ещё не было ИЛИ прошло более 24 часов
@@ -1967,6 +1985,7 @@ async def get_disabled_daily_subscriptions_for_resume(
Tariff.is_daily.is_(True),
Tariff.is_active.is_(True),
Subscription.status == SubscriptionStatus.DISABLED.value,
User.status == UserStatus.ACTIVE.value,
Subscription.is_trial.is_(False),
# Баланс пользователя >= суточной цены тарифа
User.balance_kopeks >= Tariff.daily_price_kopeks,
@@ -2020,8 +2039,8 @@ async def resume_daily_subscription(
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем время последнего списания для корректного расчёта следующего
subscription.last_daily_charge_at = datetime.utcnow()
subscription.end_date = datetime.utcnow() + timedelta(days=1)
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
logger.info(
'✅ Суточная подписка восстановлена из в ACTIVE',
subscription_id=subscription.id,
@@ -2044,7 +2063,7 @@ async def update_daily_charge_time(
charge_time: datetime = None,
) -> Subscription:
"""Обновляет время последнего суточного списания и продлевает подписку на 1 день."""
now = charge_time or datetime.utcnow()
now = charge_time or datetime.now(UTC)
subscription.last_daily_charge_at = now
# Продлеваем подписку на 1 день от текущего момента
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import func, select
@@ -20,7 +20,7 @@ async def create_subscription_conversion(
) -> SubscriptionConversion:
conversion = SubscriptionConversion(
user_id=user_id,
converted_at=datetime.utcnow(),
converted_at=datetime.now(UTC),
trial_duration_days=trial_duration_days,
payment_method=payment_method,
first_payment_amount_kopeks=first_payment_amount_kopeks,
@@ -82,7 +82,7 @@ async def get_conversion_statistics(db: AsyncSession) -> dict:
avg_first_payment_result = await db.execute(select(func.avg(SubscriptionConversion.first_payment_amount_kopeks)))
avg_first_payment = avg_first_payment_result.scalar() or 0
month_ago = datetime.utcnow() - timedelta(days=30)
month_ago = datetime.now(UTC) - timedelta(days=30)
month_conversions_result = await db.execute(
select(func.count(SubscriptionConversion.id)).where(SubscriptionConversion.converted_at >= month_ago)
)
+2 -2
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 typing import Any
from sqlalchemy import and_, func, select
@@ -32,7 +32,7 @@ async def create_subscription_event(
amount_kopeks=amount_kopeks,
currency=currency,
message=message,
occurred_at=occurred_at or datetime.utcnow(),
occurred_at=occurred_at or datetime.now(UTC),
extra=extra or None,
)
db.add(event)
+8 -9
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import and_, desc, func, or_, select, update
@@ -154,18 +154,17 @@ class TicketCRUD:
tickets = result.scalars().all()
if not tickets:
return None
from datetime import datetime
# Если есть вечная блокировка в любом тикете — блок активен без срока
for t in tickets:
if t.user_reply_block_permanent:
return datetime.max
return datetime.max.replace(tzinfo=UTC)
# Иначе ищем максимальный срок блокировки, если он в будущем
future_until = [t.user_reply_block_until for t in tickets if t.user_reply_block_until]
if not future_until:
return None
max_until = max(future_until)
return max_until if max_until > datetime.utcnow() else None
return max_until if max_until > datetime.now(UTC) else None
@staticmethod
async def get_all_tickets(
@@ -225,7 +224,7 @@ class TicketCRUD:
return False
ticket.status = status
ticket.updated_at = datetime.utcnow()
ticket.updated_at = datetime.now(UTC)
if status == TicketStatus.CLOSED.value and closed_at:
ticket.closed_at = closed_at
@@ -259,14 +258,14 @@ class TicketCRUD:
return False
ticket.user_reply_block_permanent = bool(permanent)
ticket.user_reply_block_until = until
ticket.updated_at = datetime.utcnow()
ticket.updated_at = datetime.now(UTC)
await db.commit()
return True
@staticmethod
async def close_ticket(db: AsyncSession, ticket_id: int) -> bool:
"""Закрыть тикет"""
return await TicketCRUD.update_ticket_status(db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow())
return await TicketCRUD.update_ticket_status(db, ticket_id, TicketStatus.CLOSED.value, datetime.now(UTC))
@staticmethod
async def close_all_open_tickets(
@@ -280,7 +279,7 @@ class TicketCRUD:
if not ticket_ids:
return []
now = datetime.utcnow()
now = datetime.now(UTC)
await db.execute(
update(Ticket)
.where(Ticket.id.in_(ticket_ids))
@@ -417,7 +416,7 @@ class TicketMessageCRUD:
except Exception:
pass
ticket.updated_at = datetime.utcnow()
ticket.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(message)
+6 -6
View File
@@ -1,6 +1,6 @@
"""CRUD operations for TicketNotification."""
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import desc, func, select, update
@@ -40,7 +40,7 @@ class TicketNotificationCRUD:
message=message,
is_for_admin=is_for_admin,
is_read=False,
created_at=datetime.utcnow(),
created_at=datetime.now(UTC),
)
db.add(notification)
await db.commit()
@@ -130,7 +130,7 @@ class TicketNotificationCRUD:
query = (
update(TicketNotification)
.where(TicketNotification.id == notification_id)
.values(is_read=True, read_at=datetime.utcnow())
.values(is_read=True, read_at=datetime.now(UTC))
)
result = await db.execute(query)
await db.commit()
@@ -146,7 +146,7 @@ class TicketNotificationCRUD:
TicketNotification.is_for_admin == False,
TicketNotification.is_read == False,
)
.values(is_read=True, read_at=datetime.utcnow())
.values(is_read=True, read_at=datetime.now(UTC))
)
result = await db.execute(query)
await db.commit()
@@ -161,7 +161,7 @@ class TicketNotificationCRUD:
TicketNotification.is_for_admin == True,
TicketNotification.is_read == False,
)
.values(is_read=True, read_at=datetime.utcnow())
.values(is_read=True, read_at=datetime.now(UTC))
)
result = await db.execute(query)
await db.commit()
@@ -178,7 +178,7 @@ class TicketNotificationCRUD:
TicketNotification.ticket_id == ticket_id,
TicketNotification.is_read == False,
)
.values(is_read=True, read_at=datetime.utcnow())
.values(is_read=True, read_at=datetime.now(UTC))
)
if is_admin:
+11 -11
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import and_, func, or_, select
@@ -47,7 +47,7 @@ async def create_transaction(
payment_method=payment_method.value if payment_method else None,
external_id=external_id,
is_completed=is_completed,
completed_at=datetime.utcnow() if is_completed else None,
completed_at=datetime.now(UTC) if is_completed else None,
**({'created_at': created_at} if created_at else {}),
)
@@ -150,7 +150,7 @@ async def get_user_transactions_count(
async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
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 == user_id,
Transaction.is_completed.is_(True),
@@ -163,7 +163,7 @@ async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction:
transaction.is_completed = True
transaction.completed_at = datetime.utcnow()
transaction.completed_at = datetime.now(UTC)
await db.commit()
await db.refresh(transaction)
@@ -198,9 +198,9 @@ async def get_transactions_statistics(
db: AsyncSession, start_date: datetime | None = None, end_date: datetime | None = None
) -> dict:
if not start_date:
start_date = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
start_date = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if not end_date:
end_date = datetime.utcnow()
end_date = datetime.now(UTC)
# Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения)
income_result = await db.execute(
@@ -229,7 +229,7 @@ async def get_transactions_statistics(
total_expenses = expenses_result.scalar()
subscription_income_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.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed == True,
@@ -279,7 +279,7 @@ async def get_transactions_statistics(
row.payment_method: {'count': row.count, 'amount': row.total_amount} for row in payment_methods_result
}
today = datetime.utcnow().date()
today = datetime.now(UTC).date()
today_result = await db.execute(
select(func.count(Transaction.id)).where(
and_(Transaction.is_completed == True, Transaction.created_at >= today)
@@ -316,7 +316,7 @@ async def get_transactions_statistics(
async def get_revenue_by_period(db: AsyncSession, days: int = 30) -> list[dict]:
"""Доход по дням - только реальные платежи."""
start_date = datetime.utcnow() - timedelta(days=days)
start_date = datetime.now(UTC) - timedelta(days=days)
result = await db.execute(
select(
@@ -363,7 +363,7 @@ async def find_tribute_transactions_by_payment_id(
async def check_tribute_payment_duplicate(
db: AsyncSession, payment_id: str, amount_kopeks: int, user_telegram_id: int
) -> Transaction | None:
cutoff_time = datetime.utcnow() - timedelta(hours=24)
cutoff_time = datetime.now(UTC) - timedelta(hours=24)
exact_external_id = f'donation_{payment_id}'
@@ -400,7 +400,7 @@ async def create_unique_tribute_transaction(
existing = await get_transaction_by_external_id(db, external_id, PaymentMethod.TRIBUTE)
if existing:
timestamp = int(datetime.utcnow().timestamp())
timestamp = int(datetime.now(UTC).timestamp())
external_id = f'donation_{payment_id}_{amount_kopeks}_{timestamp}'
logger.info('Создан уникальный external_id для избежания дубликатов', external_id=external_id)
+30 -28
View File
@@ -86,7 +86,7 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -106,7 +106,7 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> User |
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -131,7 +131,7 @@ async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -152,7 +152,7 @@ async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Use
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.promo_group),
selectinload(User.referrer),
)
@@ -171,7 +171,7 @@ async def get_user_by_remnawave_uuid(db: AsyncSession, remnawave_uuid: str) -> U
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.promo_group),
selectinload(User.referrer),
)
@@ -195,7 +195,7 @@ async def create_unique_referral_code(db: AsyncSession) -> str:
if not existing_user:
return code
timestamp = str(int(datetime.utcnow().timestamp()))[-6:]
timestamp = str(int(datetime.now(UTC).timestamp()))[-6:]
return f'ref{timestamp}'
@@ -379,7 +379,7 @@ async def update_user(db: AsyncSession, user: User, **kwargs) -> User:
if hasattr(user, field):
setattr(user, field, value)
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
@@ -399,7 +399,7 @@ async def add_user_balance(
try:
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
if create_transaction:
from app.database.crud.transaction import create_transaction as create_trans
@@ -564,7 +564,7 @@ async def subtract_user_balance(
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
if create_transaction:
from app.database.crud.transaction import (
@@ -620,7 +620,7 @@ async def subtract_user_balance(
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(User).where(
User.promo_offer_discount_percent > 0,
@@ -719,7 +719,7 @@ async def get_users_list(
order_by_purchase_count: bool = False,
) -> list[User]:
query = select(User).options(
selectinload(User.subscription),
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.promo_group),
selectinload(User.referrer),
)
@@ -879,7 +879,7 @@ async def get_referrals(db: AsyncSession, user_id: int) -> list[User]:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -899,12 +899,12 @@ async def get_referrals(db: AsyncSession, user_id: int) -> list[User]:
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[User]:
now = datetime.utcnow()
now = datetime.now(UTC)
base_query = (
select(User)
.options(
selectinload(User.subscription),
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.promo_group),
selectinload(User.referrer),
)
@@ -961,12 +961,12 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[Us
async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]:
threshold_date = datetime.utcnow() - timedelta(days=months * 30)
threshold_date = datetime.now(UTC) - timedelta(days=months * 30)
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -986,7 +986,7 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]:
async def delete_user(db: AsyncSession, user: User) -> bool:
user.status = UserStatus.DELETED.value
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
user_id_display = user.telegram_id or user.email or f'#{user.id}'
@@ -1001,19 +1001,19 @@ async def get_users_statistics(db: AsyncSession) -> dict:
active_result = await db.execute(select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value))
active_users = active_result.scalar()
today = datetime.utcnow().date()
today = datetime.now(UTC).date()
today_result = await db.execute(
select(func.count(User.id)).where(and_(User.created_at >= today, User.status == UserStatus.ACTIVE.value))
)
new_today = today_result.scalar()
week_ago = datetime.utcnow() - timedelta(days=7)
week_ago = datetime.now(UTC) - timedelta(days=7)
week_result = await db.execute(
select(func.count(User.id)).where(and_(User.created_at >= week_ago, User.status == UserStatus.ACTIVE.value))
)
new_week = week_result.scalar()
month_ago = datetime.utcnow() - timedelta(days=30)
month_ago = datetime.now(UTC) - timedelta(days=30)
month_result = await db.execute(
select(func.count(User.id)).where(and_(User.created_at >= month_ago, User.status == UserStatus.ACTIVE.value))
)
@@ -1037,7 +1037,7 @@ async def get_users_with_active_subscriptions(db: AsyncSession) -> list[User]:
Returns:
Список пользователей с активными подписками и remnawave_uuid
"""
current_time = datetime.utcnow()
current_time = datetime.now(UTC)
result = await db.execute(
select(User)
@@ -1050,7 +1050,7 @@ async def get_users_with_active_subscriptions(db: AsyncSession) -> list[User]:
Subscription.end_date > current_time,
)
)
.options(selectinload(User.subscription))
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
)
return result.scalars().unique().all()
@@ -1177,7 +1177,7 @@ async def set_email_change_pending(
user.email_change_new = new_email
user.email_change_code = code
user.email_change_expires = expires_at
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
@@ -1201,7 +1201,7 @@ async def verify_and_apply_email_change(db: AsyncSession, user: User, code: str)
if not user.email_change_new or not user.email_change_code:
return False, 'No pending email change'
if user.email_change_expires and datetime.utcnow() > user.email_change_expires:
if user.email_change_expires and datetime.now(UTC) > user.email_change_expires:
# Clear expired data
user.email_change_new = None
user.email_change_code = None
@@ -1227,11 +1227,11 @@ async def verify_and_apply_email_change(db: AsyncSession, user: User, code: str)
# Apply the change
user.email = new_email
user.email_verified = True
user.email_verified_at = datetime.utcnow()
user.email_verified_at = datetime.now(UTC)
user.email_change_new = None
user.email_change_code = None
user.email_change_expires = None
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
@@ -1251,7 +1251,7 @@ async def clear_email_change_pending(db: AsyncSession, user: User) -> None:
user.email_change_new = None
user.email_change_code = None
user.email_change_expires = None
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
logger.info('Email change cancelled for user', user_id=user.id)
@@ -1286,7 +1286,7 @@ async def set_user_oauth_provider_id(db: AsyncSession, user: User, provider: str
return
value: str | int = int(provider_id) if provider == 'vk' else provider_id
setattr(user, column_name, value)
user.updated_at = datetime.now(UTC).replace(tzinfo=None)
user.updated_at = datetime.now(UTC)
logger.info('Linked (id=) to user', provider=provider, provider_id=provider_id, user_id=user.id)
@@ -1300,6 +1300,7 @@ async def create_user_by_oauth(
last_name: str | None = None,
username: str | None = None,
language: str = 'ru',
referred_by_id: int | None = None,
) -> User:
"""Create a new user via OAuth provider."""
referral_code = await create_unique_referral_code(db)
@@ -1319,6 +1320,7 @@ async def create_user_by_oauth(
first_name=sanitize_telegram_name(first_name) if first_name else None,
last_name=sanitize_telegram_name(last_name) if last_name else None,
language=normalized_language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
+3 -3
View File
@@ -1,5 +1,5 @@
import random
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import func, select
@@ -111,7 +111,7 @@ async def update_user_message(
if sort_order is not None:
message.sort_order = sort_order
message.updated_at = datetime.utcnow()
message.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(message)
@@ -127,7 +127,7 @@ async def toggle_user_message_status(db: AsyncSession, message_id: int) -> UserM
return None
message.is_active = not message.is_active
message.updated_at = datetime.utcnow()
message.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(message)
+2 -2
View File
@@ -1,6 +1,6 @@
"""CRUD операции для связи пользователей с промогруппами (Many-to-Many)."""
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import and_, desc, select
@@ -36,7 +36,7 @@ async def _sync_user_primary_promo_group(
if user.promo_group_id != new_primary_id:
user.promo_group_id = new_primary_id
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
except Exception as error:
logger.error('Ошибка синхронизации primary промогруппы пользователя', user_id=user_id, error=error)
+3 -3
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Iterable
from datetime import datetime
from datetime import UTC, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -71,7 +71,7 @@ async def update_token(
for key, value in kwargs.items():
if hasattr(token, key):
setattr(token, key, value)
token.updated_at = datetime.utcnow()
token.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(token)
return token
@@ -86,7 +86,7 @@ async def set_tokens_active_status(
await db.execute(
update(WebApiToken)
.where(WebApiToken.id.in_(list(token_ids)))
.values(is_active=is_active, updated_at=datetime.utcnow())
.values(is_active=is_active, updated_at=datetime.now(UTC))
)
+4 -4
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -94,7 +94,7 @@ async def update_webhook(
if is_active is not None:
webhook.is_active = is_active
webhook.updated_at = datetime.utcnow()
webhook.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(webhook)
return webhook
@@ -127,7 +127,7 @@ async def record_webhook_delivery(
response_body=response_body,
error_message=error_message,
attempt_number=attempt_number,
delivered_at=datetime.utcnow() if status == 'success' else None,
delivered_at=datetime.now(UTC) if status == 'success' else None,
)
db.add(delivery)
await db.commit()
@@ -145,7 +145,7 @@ async def update_webhook_stats(
webhook.success_count += 1
else:
webhook.failure_count += 1
webhook.last_triggered_at = datetime.utcnow()
webhook.last_triggered_at = datetime.now(UTC)
await db.commit()
await db.refresh(webhook)
return webhook
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import func, select, update
@@ -77,7 +77,7 @@ async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
if welcome_text:
welcome_text.is_enabled = not welcome_text.is_enabled
welcome_text.updated_at = datetime.utcnow()
welcome_text.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(welcome_text)
@@ -182,7 +182,7 @@ async def update_welcome_text(
if is_active is not None:
welcome_text.is_active = is_active
welcome_text.updated_at = datetime.utcnow()
welcome_text.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(welcome_text)
+6 -6
View File
@@ -2,7 +2,7 @@
CRUD операции для колеса удачи (Fortune Wheel).
"""
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import structlog
@@ -65,7 +65,7 @@ async def update_wheel_config(db: AsyncSession, **kwargs) -> WheelConfig:
if hasattr(config, key) and value is not None:
setattr(config, key, value)
config.updated_at = datetime.utcnow()
config.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(config)
logger.info('🎡 Обновлена конфигурация колеса', kwargs=kwargs)
@@ -143,7 +143,7 @@ async def update_wheel_prize(db: AsyncSession, prize_id: int, **kwargs) -> Wheel
if hasattr(prize, key) and value is not None:
setattr(prize, key, value)
prize.updated_at = datetime.utcnow()
prize.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(prize)
logger.info('🎁 Обновлен приз колеса ID', prize_id=prize_id, kwargs=kwargs)
@@ -204,7 +204,7 @@ async def create_wheel_spin(
prize_value_kopeks=prize_value_kopeks,
generated_promocode_id=generated_promocode_id,
is_applied=is_applied,
applied_at=datetime.utcnow() if is_applied else None,
applied_at=datetime.now(UTC) if is_applied else None,
)
db.add(spin)
await db.commit()
@@ -219,7 +219,7 @@ async def mark_spin_applied(db: AsyncSession, spin_id: int) -> WheelSpin | None:
spin = result.scalar_one_or_none()
if spin:
spin.is_applied = True
spin.applied_at = datetime.utcnow()
spin.applied_at = datetime.now(UTC)
await db.commit()
await db.refresh(spin)
return spin
@@ -227,7 +227,7 @@ async def mark_spin_applied(db: AsyncSession, spin_id: int) -> WheelSpin | None:
async def get_user_spins_today(db: AsyncSession, user_id: int) -> int:
"""Получить количество спинов пользователя за сегодня."""
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
result = await db.execute(
select(func.count(WheelSpin.id)).where(
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import and_, select, update
@@ -88,7 +88,7 @@ async def update_yookassa_payment_status(
captured_at: datetime | None = None,
payment_method_type: str | None = None,
) -> YooKassaPayment | None:
update_data = {'status': status, 'is_paid': is_paid, 'is_captured': is_captured, 'updated_at': datetime.utcnow()}
update_data = {'status': status, 'is_paid': is_paid, 'is_captured': is_captured, 'updated_at': datetime.now(UTC)}
if captured_at:
update_data['captured_at'] = captured_at
@@ -125,7 +125,7 @@ async def link_yookassa_payment_to_transaction(
await db.execute(
update(YooKassaPayment)
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
.values(transaction_id=transaction_id, updated_at=datetime.utcnow())
.values(transaction_id=transaction_id, updated_at=datetime.now(UTC))
)
await db.commit()
+124 -98
View File
@@ -1,24 +1,25 @@
import asyncio
import time
from collections.abc import AsyncGenerator, Callable
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
from functools import wraps
from typing import TypeVar
from typing import ParamSpec, TypeVar
import structlog
from sqlalchemy import bindparam, event, inspect, text
from sqlalchemy import bindparam, event, text
from sqlalchemy.engine import Engine
from sqlalchemy.exc import InterfaceError, OperationalError, ProgrammingError
from sqlalchemy.exc import InterfaceError, OperationalError
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import AsyncAdaptedQueuePool, NullPool
from app.config import settings
from app.database.models import Base
logger = structlog.get_logger(__name__)
T = TypeVar('T')
P = ParamSpec('P')
R = TypeVar('R')
# ============================================================================
# PRODUCTION-GRADE CONNECTION POOLING
@@ -67,7 +68,7 @@ _pg_connect_args = {
engine = create_async_engine(
DATABASE_URL,
poolclass=poolclass,
echo=settings.DEBUG,
echo='debug' if settings.DEBUG else False,
future=True,
# Кеш скомпилированных запросов (правильное размещение)
query_cache_size=500,
@@ -103,7 +104,7 @@ def with_db_retry(
attempts: int = DEFAULT_RETRY_ATTEMPTS,
delay: float = DEFAULT_RETRY_DELAY,
backoff: float = 2.0,
) -> Callable:
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
"""
Декоратор для автоматического retry при сбоях подключения к БД.
@@ -113,10 +114,10 @@ def with_db_retry(
backoff: Множитель задержки для каждой следующей попытки
"""
def decorator(func: Callable) -> Callable:
def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@wraps(func)
async def wrapper(*args, **kwargs):
last_exception = None
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
last_exception: Exception | None = None
current_delay = delay
for attempt in range(1, attempts + 1):
@@ -137,9 +138,9 @@ def with_db_retry(
else:
logger.error('Ошибка БД: все попыток исчерпаны. Последняя ошибка', attempts=attempts, e=str(e))
raise last_exception
raise last_exception # type: ignore[misc]
return wrapper
return wrapper # type: ignore[return-value]
return decorator
@@ -150,7 +151,10 @@ async def execute_with_retry(
attempts: int = DEFAULT_RETRY_ATTEMPTS,
):
"""Выполнение SQL с retry логикой."""
last_exception = None
if attempts < 1:
raise ValueError(f'attempts must be >= 1, got {attempts}')
last_exception: Exception | None = None
delay = DEFAULT_RETRY_DELAY
for attempt in range(1, attempts + 1):
@@ -163,7 +167,7 @@ async def execute_with_retry(
await asyncio.sleep(delay)
delay *= 2
raise last_exception
raise last_exception # type: ignore[misc]
# ============================================================================
@@ -201,7 +205,7 @@ def _validate_database_url(url: str | None) -> str | None:
return None
# Простая проверка на валидный формат
if not ('://' in url or url.startswith('sqlite')):
logger.warning('Невалидный DATABASE_URL', url=url[:20])
logger.warning('Невалидный DATABASE_URL (не содержит ://)')
return None
return url
@@ -234,7 +238,10 @@ class DatabaseManager:
expire_on_commit=False,
autoflush=False,
)
logger.info('Read replica настроена', replica_url=replica_url[:30] + '...')
from sqlalchemy.engine import make_url
safe_url = make_url(replica_url).render_as_string(hide_password=True)
logger.info('Read replica настроена', replica_url=safe_url)
except Exception as e:
logger.error('Не удалось настроить read replica', e=e)
self.read_replica_engine = None
@@ -402,88 +409,7 @@ batch_ops = BatchOperations()
# ============================================================================
async def init_db():
"""Инициализация БД с оптимизациями"""
logger.info('🚀 Создание таблиц базы данных...')
try:
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, checkfirst=True))
except (ProgrammingError, Exception) as e:
# Игнорируем ошибки дублирования индексов/таблиц - они уже существуют
# Это может произойти если таблицы были созданы вручную или через миграции
error_str = str(e).lower()
error_type = type(e).__name__.lower()
# Проверяем оригинальную ошибку для asyncpg
orig_error = getattr(e, 'orig', None)
if orig_error:
orig_type = type(orig_error).__name__.lower()
if 'duplicatetableerror' in orig_type or 'duplicatekeyerror' in orig_type:
logger.warning(
'⚠️ Некоторые индексы/таблицы уже существуют в БД, это нормально. Продолжаем инициализацию...'
)
return
# Проверяем, является ли это ошибкой дублирования
is_duplicate_error = (
'already exists' in error_str
or 'duplicate' in error_str
or 'duplicatetableerror' in error_type
or 'duplicatekeyerror' in error_type
)
if is_duplicate_error:
logger.warning(
'⚠️ Некоторые объекты БД уже существуют (таблицы/индексы), это нормально. Продолжаем инициализацию...'
)
# Продолжаем выполнение, так как основные таблицы могут быть созданы
else:
# Для других ошибок пробрасываем исключение
logger.error('❌ Ошибка при создании таблиц', error=e)
raise
if not IS_SQLITE:
logger.info('Создание индексов для оптимизации...')
async with engine.begin() as conn:
indexes = [
('users', 'CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)'),
(
'subscriptions',
'CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)',
),
(
'subscriptions',
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
),
(
'payments',
'CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)',
),
]
for table_name, index_sql in indexes:
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
if not table_exists:
logger.debug(
'Пропускаем создание индекса : таблица отсутствует', index_sql=index_sql, table_name=table_name
)
continue
try:
await conn.execute(text(index_sql))
except Exception as e:
logger.debug('Index creation skipped for', table_name=table_name, e=e)
logger.info('База данных успешно инициализирована')
health = await db_manager.health_check()
logger.info('Database health', health=health)
async def close_db():
async def close_db() -> None:
"""Корректное закрытие всех соединений"""
logger.info('Закрытие соединений с БД...')
@@ -495,6 +421,106 @@ async def close_db():
logger.info('Все подключения к базе данных закрыты')
# ============================================================================
# SEQUENCE SYNCHRONIZATION (after DB restores)
# ============================================================================
def _quote_ident(name: str) -> str:
"""Quote a PostgreSQL identifier to prevent SQL injection."""
return '"' + name.replace('"', '""') + '"'
async def sync_postgres_sequences() -> bool:
"""Ensure PostgreSQL sequences match the current max values after restores."""
if IS_SQLITE:
logger.debug('Пропускаем синхронизацию последовательностей: SQLite')
return True
try:
async with engine.begin() as conn:
result = await conn.execute(
text(
"""
SELECT
cols.table_schema,
cols.table_name,
cols.column_name,
pg_get_serial_sequence(
format('%I.%I', cols.table_schema, cols.table_name),
cols.column_name
) AS sequence_path
FROM information_schema.columns AS cols
WHERE cols.column_default LIKE 'nextval(%'
AND cols.table_schema NOT IN ('pg_catalog', 'information_schema')
"""
)
)
sequences = result.fetchall()
if not sequences:
logger.info('Не найдено последовательностей PostgreSQL для синхронизации')
return True
for table_schema, table_name, column_name, sequence_path in sequences:
if not sequence_path:
continue
q_col = _quote_ident(column_name)
q_schema = _quote_ident(table_schema)
q_table = _quote_ident(table_name)
max_result = await conn.execute(text(f'SELECT COALESCE(MAX({q_col}), 0) FROM {q_schema}.{q_table}'))
max_value = max_result.scalar() or 0
# pg_get_serial_sequence returns e.g. '"public"."users_id_seq"'.
# Split on '"."' to handle quoted identifiers that may contain dots.
if '"."' in sequence_path:
seq_schema, seq_name = sequence_path.split('"."', 1)
seq_schema = seq_schema.strip('"')
seq_name = seq_name.strip('"')
else:
parts = sequence_path.split('.')
if len(parts) == 2:
seq_schema, seq_name = parts
else:
seq_schema, seq_name = 'public', parts[-1]
q_seq_schema = _quote_ident(seq_schema)
q_seq_name = _quote_ident(seq_name)
current_result = await conn.execute(
text(f'SELECT last_value, is_called FROM {q_seq_schema}.{q_seq_name}')
)
current_row = current_result.fetchone()
if current_row:
current_last, is_called = current_row
current_next = current_last + 1 if is_called else current_last
if current_next > max_value:
continue
await conn.execute(
text(
"""
SELECT setval(:sequence_name, :new_value, TRUE)
"""
),
{'sequence_name': sequence_path, 'new_value': max_value},
)
logger.info(
'Последовательность синхронизирована',
sequence_path=sequence_path,
max_value=max_value,
next_id=max_value + 1,
)
return True
except Exception as error:
logger.error('Ошибка синхронизации последовательностей PostgreSQL', error=error)
return False
# ============================================================================
# CONNECTION POOL METRICS (для мониторинга)
# ============================================================================
+71
View File
@@ -0,0 +1,71 @@
"""Programmatic Alembic migration runner for bot startup."""
from pathlib import Path
import structlog
from alembic import command
from alembic.config import Config
from sqlalchemy import inspect
logger = structlog.get_logger(__name__)
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
_ALEMBIC_INI = _PROJECT_ROOT / 'alembic.ini'
def _get_alembic_config() -> Config:
"""Build Alembic Config pointing at the project root."""
from app.config import settings
cfg = Config(str(_ALEMBIC_INI))
cfg.set_main_option('sqlalchemy.url', settings.get_database_url())
return cfg
async def _needs_auto_stamp() -> bool:
"""Check if DB has existing tables but no alembic_version (transition from universal_migration)."""
from app.database.database import engine
async with engine.connect() as conn:
has_alembic = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table('alembic_version'))
if has_alembic:
return False
has_users = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table('users'))
return has_users
_INITIAL_REVISION = '0001'
async def run_alembic_upgrade() -> None:
"""Run ``alembic upgrade head``, auto-stamping existing databases first."""
import asyncio
if await _needs_auto_stamp():
logger.warning(
'Обнаружена существующая БД без alembic_version — автоматический stamp 0001 (переход с universal_migration)'
)
await _stamp_alembic_revision(_INITIAL_REVISION)
cfg = _get_alembic_config()
loop = asyncio.get_running_loop()
# run_in_executor offloads to a thread where env.py can safely
# call asyncio.run() to create its own event loop.
await loop.run_in_executor(None, command.upgrade, cfg, 'head')
logger.info('Alembic миграции применены')
async def stamp_alembic_head() -> None:
"""Stamp the DB as being at head without running migrations (for existing DBs)."""
await _stamp_alembic_revision('head')
async def _stamp_alembic_revision(revision: str) -> None:
"""Stamp the DB at a specific revision without running migrations."""
import asyncio
cfg = _get_alembic_config()
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, command.stamp, cfg, revision)
logger.info('Alembic: база отмечена как актуальная', revision=revision)
+292 -216
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11
View File
@@ -553,8 +553,19 @@ class RemnaWaveAPI:
if active_internal_squads is not None:
data['activeInternalSquads'] = active_internal_squads
logger.info(
'PATCH /api/users payload',
uuid=uuid,
hwidDeviceLimit=data.get('hwidDeviceLimit'),
status=data.get('status'),
)
response = await self._make_request('PATCH', '/api/users', data)
user = self._parse_user(response['response'])
logger.info(
'PATCH /api/users response',
uuid=uuid,
response_hwidDeviceLimit=user.hwid_device_limit,
)
return await self.enrich_user_with_happ_link(user)
async def delete_user(self, uuid: str) -> bool:
+4 -4
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import base64
import json
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
import aiohttp
@@ -33,12 +33,12 @@ class WataPublicKeyProvider:
async def get_public_key(self) -> str | None:
"""Returns a cached public key or fetches a new one from WATA."""
now = datetime.utcnow()
now = datetime.now(UTC)
if self._cached_key and self._expires_at and now < self._expires_at:
return self._cached_key
async with self._lock:
now = datetime.utcnow()
now = datetime.now(UTC)
if self._cached_key and self._expires_at and now < self._expires_at:
return self._cached_key
@@ -46,7 +46,7 @@ class WataPublicKeyProvider:
if key:
self._cached_key = key
if self._cache_seconds > 0:
self._expires_at = datetime.utcnow() + timedelta(seconds=self._cache_seconds)
self._expires_at = datetime.now(UTC) + timedelta(seconds=self._cache_seconds)
else:
self._expires_at = None
logger.debug('Получен и закеширован публичный ключ WATA')
+4 -3
View File
@@ -1,3 +1,4 @@
import html
from datetime import datetime
import structlog
@@ -154,7 +155,7 @@ async def create_backup_handler(callback: types.CallbackQuery, db_user: User, db
)
else:
await progress_msg.edit_text(
f'❌ <b>Ошибка создания бекапа</b>\n\n{message}',
f'❌ <b>Ошибка создания бекапа</b>\n\n{html.escape(message)}',
parse_mode='HTML',
reply_markup=get_backup_main_keyboard(db_user.language),
)
@@ -431,11 +432,11 @@ async def handle_backup_file_upload(message: types.Message, db_user: User, db: A
inline_keyboard=[
[
InlineKeyboardButton(
text='✅ Восстановить', callback_data=f'backup_restore_uploaded_{temp_path.name}'
text='✅ Восстановить', callback_data=f'backup_restore_execute_{temp_path.name}'
),
InlineKeyboardButton(
text='🗑️ Очистить и восстановить',
callback_data=f'backup_restore_uploaded_clear_{temp_path.name}',
callback_data=f'backup_restore_clear_{temp_path.name}',
),
],
[InlineKeyboardButton(text='❌ Отмена', callback_data='backup_panel')],
+4 -4
View File
@@ -3,7 +3,7 @@ import io
import math
import time
from collections.abc import Iterable
from datetime import datetime
from datetime import UTC, datetime
from aiogram import Dispatcher, F, types
from aiogram.filters import BaseFilter, StateFilter
@@ -742,7 +742,7 @@ async def export_settings(
keys = sorted(set(keys))
lines = [
'# RemnaWave bot configuration export',
f'# Generated at {datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")}',
f'# Generated at {datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")}',
]
for setting_key in keys:
@@ -753,7 +753,7 @@ async def export_settings(
lines.append(f'{setting_key}={raw_value}')
content = '\n'.join(lines)
filename = f'bot-settings-{datetime.utcnow().strftime("%Y%m%d-%H%M%S")}.env'
filename = f'bot-settings-{datetime.now(UTC).strftime("%Y%m%d-%H%M%S")}.env'
file = types.BufferedInputFile(content.encode('utf-8'), filename=filename)
await callback.message.answer_document(
@@ -807,7 +807,7 @@ async def handle_import_message(
content = ''
if message.document:
buffer = io.BytesIO()
await message.document.download(destination=buffer)
await message.bot.download(message.document, destination=buffer)
buffer.seek(0)
content = buffer.read().decode('utf-8', errors='ignore')
else:
+3 -3
View File
@@ -399,7 +399,7 @@ async def delete_contest(
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
return
now_utc = datetime.utcnow()
now_utc = datetime.now(UTC)
if contest.is_active or contest.end_at > now_utc:
await callback.answer(
texts.t('ADMIN_CONTEST_DELETE_RESTRICT', 'Удалять можно только завершённые конкурсы.'),
@@ -628,8 +628,8 @@ async def finalize_contest_creation(message: types.Message, state: FSMContext, d
await message.answer(texts.t('ADMIN_CONTEST_INVALID_DATE', 'Не удалось распознать дату.'))
return
start_at = datetime.fromisoformat(start_at_raw).astimezone(UTC).replace(tzinfo=None)
end_at = datetime.fromisoformat(end_at_raw).astimezone(UTC).replace(tzinfo=None)
start_at = datetime.fromisoformat(start_at_raw).astimezone(UTC)
end_at = datetime.fromisoformat(end_at_raw).astimezone(UTC)
contest_type = data.get('contest_type') or 'referral_paid'
+10 -10
View File
@@ -1,5 +1,5 @@
import json
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Dispatcher, F, types
@@ -171,7 +171,7 @@ async def start_round_now(
await db.refresh(tpl)
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.utcnow()
now = datetime.now(UTC)
ends = now + timedelta(hours=tpl.cooldown_hours)
await create_round(
db,
@@ -182,8 +182,8 @@ async def start_round_now(
)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
now,
ends,
)
await callback.answer(texts.t('ADMIN_ROUND_STARTED', 'Раунд запущен'), show_alert=True)
await show_daily_contest(callback, db_user, db)
@@ -214,7 +214,7 @@ async def manual_start_round(
# Для ручного старта не включаем конкурс, если он выключен
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.utcnow()
now = datetime.now(UTC)
ends = now + timedelta(hours=tpl.cooldown_hours)
await create_round(
db,
@@ -227,8 +227,8 @@ async def manual_start_round(
# Анонсируем всем пользователям (как тест)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
now,
ends,
)
await callback.answer(texts.t('ADMIN_ROUND_STARTED', 'Тестовый раунд запущен'), show_alert=True)
await show_daily_contest(callback, db_user, db)
@@ -431,7 +431,7 @@ async def start_all_contests(
continue # уже запущен
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.utcnow()
now = datetime.now(UTC)
ends = now + timedelta(hours=tpl.cooldown_hours)
await create_round(
db,
@@ -442,8 +442,8 @@ async def start_all_contests(
)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
now,
ends,
)
started_count += 1
+53 -36
View File
@@ -1,6 +1,6 @@
import asyncio
import html
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Dispatcher, F, types
@@ -134,6 +134,7 @@ async def _persist_broadcast_result(
sent_count: int,
failed_count: int,
status: str,
blocked_count: int = 0,
) -> None:
"""
Сохраняет результаты рассылки в НОВОЙ сессии.
@@ -147,8 +148,9 @@ async def _persist_broadcast_result(
sent_count: Количество успешно отправленных сообщений
failed_count: Количество неудачных отправок
status: Финальный статус рассылки ('completed', 'partial', 'failed')
blocked_count: Количество пользователей, заблокировавших бота
"""
completed_at = datetime.utcnow()
completed_at = datetime.now(UTC)
max_retries = 3
retry_delay = 1.0
@@ -164,15 +166,17 @@ async def _persist_broadcast_result(
broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count
broadcast_history.blocked_count = blocked_count
broadcast_history.status = status
broadcast_history.completed_at = completed_at
await session.commit()
logger.info(
'Результаты рассылки сохранены (id sent failed status=)',
'Результаты рассылки сохранены (id sent failed blocked status=)',
broadcast_id=broadcast_id,
sent_count=sent_count,
failed_count=failed_count,
blocked_count=blocked_count,
status=status,
)
return
@@ -309,7 +313,7 @@ async def toggle_pinned_message_position(
return
pinned_message.send_before_menu = not pinned_message.send_before_menu
pinned_message.updated_at = datetime.utcnow()
pinned_message.updated_at = datetime.now(UTC)
await db.commit()
await show_pinned_message_menu(callback, db_user, db, state)
@@ -329,7 +333,7 @@ async def toggle_pinned_message_start_mode(
return
pinned_message.send_on_every_start = not pinned_message.send_on_every_start
pinned_message.updated_at = datetime.utcnow()
pinned_message.updated_at = datetime.now(UTC)
await db.commit()
await show_pinned_message_menu(callback, db_user, db, state)
@@ -1215,8 +1219,8 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
# Глобальная пауза при FloodWait — тормозим ВСЕ отправки, а не один слот семафора
flood_wait_until: float = 0.0
async def send_single_broadcast(telegram_id: int) -> bool:
"""Отправляет одно сообщение. Возвращает True при успехе."""
async def send_single_broadcast(telegram_id: int) -> str:
"""Отправляет одно сообщение. Возвращает 'sent', 'blocked' или 'failed'."""
nonlocal flood_wait_until
for attempt in range(_MAX_SEND_RETRIES):
@@ -1260,7 +1264,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
return True
return 'sent'
except TelegramRetryAfter as e:
# Глобальная пауза — тормозим все корутины
@@ -1276,11 +1280,14 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
await asyncio.sleep(wait_seconds)
except TelegramForbiddenError:
return False
return 'blocked'
except TelegramBadRequest as e:
err = str(e).lower()
if 'bot was blocked' in err or 'user is deactivated' in err or 'chat not found' in err:
return 'blocked'
logger.debug('BadRequest при рассылке пользователю', telegram_id=telegram_id, e=e)
return False
return 'failed'
except Exception as e:
logger.error(
@@ -1293,7 +1300,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
if attempt < _MAX_SEND_RETRIES - 1:
await asyncio.sleep(0.5 * (attempt + 1))
return False
return 'failed'
# =========================================================================
# Прогресс-бар в реальном времени (как в сканере заблокированных)
@@ -1308,26 +1315,29 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
current_failed: int,
total: int,
phase: str = 'sending',
current_blocked: int = 0,
) -> str:
processed = current_sent + current_failed
processed = current_sent + current_failed + current_blocked
percent = round(processed / total * 100, 1) if total > 0 else 0
bar_length = 20
filled = int(bar_length * processed / total) if total > 0 else 0
bar = '' * filled + '' * (bar_length - filled)
if phase == 'sending':
blocked_line = f'• Заблокировали бота: {current_blocked}\n' if current_blocked else ''
return (
f'📨 <b>Рассылка в процессе...</b>\n\n'
f'[{bar}] {percent}%\n\n'
f'📊 <b>Прогресс:</b>\n'
f'• Отправлено: {current_sent}\n'
f'{blocked_line}'
f'• Ошибок: {current_failed}\n'
f'• Обработано: {processed}/{total}\n\n'
f'⏳ Не закрывайте диалог — рассылка продолжается...'
)
return ''
async def _update_progress_message(current_sent: int, current_failed: int) -> None:
async def _update_progress_message(current_sent: int, current_failed: int, current_blocked: int = 0) -> None:
"""Безопасно обновляет сообщение с прогрессом."""
nonlocal last_progress_update, progress_message
now = asyncio.get_event_loop().time()
@@ -1335,7 +1345,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
return
last_progress_update = now
text = _build_progress_text(current_sent, current_failed, total_recipients)
text = _build_progress_text(current_sent, current_failed, total_recipients, current_blocked=current_blocked)
try:
await progress_message.edit_text(text, parse_mode='HTML')
except TelegramRetryAfter as e:
@@ -1357,6 +1367,9 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
# Первое обновление прогресса
await _update_progress_message(0, 0)
blocked_count = 0
blocked_telegram_ids: list[int] = []
# =========================================================================
# Основной цикл рассылки — батчами по _BATCH_SIZE
# =========================================================================
@@ -1369,10 +1382,13 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
return_exceptions=True,
)
for result in results:
if isinstance(result, bool):
if result:
for idx, result in enumerate(results):
if isinstance(result, str):
if result == 'sent':
sent_count += 1
elif result == 'blocked':
blocked_count += 1
blocked_telegram_ids.append(batch[idx])
else:
failed_count += 1
elif isinstance(result, Exception):
@@ -1381,7 +1397,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
# Обновляем прогресс каждые _PROGRESS_UPDATE_INTERVAL батчей
if batch_idx % _PROGRESS_UPDATE_INTERVAL == 0:
await _update_progress_message(sent_count, failed_count)
await _update_progress_message(sent_count, failed_count, blocked_count)
# Задержка между батчами для соблюдения rate limits
await asyncio.sleep(_BATCH_DELAY)
@@ -1391,7 +1407,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
if skipped_email_users > 0:
logger.info('Пропущено email-only пользователей при рассылке', skipped_email_users=skipped_email_users)
status = 'completed' if failed_count == 0 else 'partial'
status = 'completed' if failed_count == 0 and blocked_count == 0 else 'partial'
# Сохраняем результат в НОВОЙ сессии (старая уже мертва)
await _persist_broadcast_result(
@@ -1399,15 +1415,18 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
sent_count=sent_count,
failed_count=failed_count,
status=status,
blocked_count=blocked_count,
)
success_rate = round(sent_count / total_users_count * 100, 1) if total_users_count else 0
media_info = f'\n🖼️ <b>Медиафайл:</b> {media_type}' if has_media else ''
blocked_line = f'• Заблокировали бота: {blocked_count}\n' if blocked_count else ''
result_text = (
f'✅ <b>Рассылка завершена!</b>\n\n'
f'📊 <b>Результат:</b>\n'
f'• Отправлено: {sent_count}\n'
f'{blocked_line}'
f'• Не доставлено: {failed_count}\n'
f'• Всего пользователей: {total_users_count}\n'
f'• Успешность: {success_rate}%{media_info}\n\n'
@@ -1449,8 +1468,6 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
async def get_target_users_count(db: AsyncSession, target: str) -> int:
"""Быстрый подсчёт пользователей через SQL COUNT вместо загрузки всех в память."""
from datetime import datetime, timedelta
from sqlalchemy import distinct, func as sql_func
base_filter = User.status == UserStatus.ACTIVE.value
@@ -1503,7 +1520,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
if target == 'expiring':
# Истекающие в ближайшие 3 дня
now = datetime.utcnow()
now = datetime.now(UTC)
expiry_threshold = now + timedelta(days=3)
query = (
select(sql_func.count(distinct(User.id)))
@@ -1520,7 +1537,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
if target == 'expiring_subscribers':
# Истекающие в ближайшие 7 дней
now = datetime.utcnow()
now = datetime.now(UTC)
expiry_threshold = now + timedelta(days=7)
query = (
select(sql_func.count(distinct(User.id)))
@@ -1537,7 +1554,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
if target == 'expired':
# Истекшие подписки
now = datetime.utcnow()
now = datetime.now(UTC)
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
query = (
select(sql_func.count(distinct(User.id)))
@@ -1556,7 +1573,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
if target == 'expired_subscribers':
# То же что и expired
now = datetime.utcnow()
now = datetime.now(UTC)
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
query = (
select(sql_func.count(distinct(User.id)))
@@ -1634,7 +1651,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
# Custom filters — быстрый COUNT вместо загрузки всех пользователей
if target.startswith('custom_'):
now = datetime.utcnow()
now = datetime.now(UTC)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
criteria = target[len('custom_') :]
@@ -1704,7 +1721,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
return [sub.user for sub in expiring_subs if sub.user]
if target == 'expired':
now = datetime.utcnow()
now = datetime.now(UTC)
expired_statuses = {
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
@@ -1755,7 +1772,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
return [sub.user for sub in expiring_subs if sub.user]
if target == 'expired_subscribers':
now = datetime.utcnow()
now = datetime.now(UTC)
expired_statuses = {
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
@@ -1782,7 +1799,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
]
if target == 'trial_ending':
now = datetime.utcnow()
now = datetime.now(UTC)
in_3_days = now + timedelta(days=3)
return [
user
@@ -1794,7 +1811,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
]
if target == 'trial_expired':
now = datetime.utcnow()
now = datetime.now(UTC)
return [
user
for user in users
@@ -1804,7 +1821,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
if target == 'autopay_failed':
from app.database.models import SubscriptionEvent
week_ago = datetime.utcnow() - timedelta(days=7)
week_ago = datetime.now(UTC) - timedelta(days=7)
stmt = (
select(SubscriptionEvent.user_id)
.where(
@@ -1826,15 +1843,15 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
]
if target == 'inactive_30d':
threshold = datetime.utcnow() - timedelta(days=30)
threshold = datetime.now(UTC) - timedelta(days=30)
return [user for user in users if user.last_activity and user.last_activity < threshold]
if target == 'inactive_60d':
threshold = datetime.utcnow() - timedelta(days=60)
threshold = datetime.now(UTC) - timedelta(days=60)
return [user for user in users if user.last_activity and user.last_activity < threshold]
if target == 'inactive_90d':
threshold = datetime.utcnow() - timedelta(days=90)
threshold = datetime.now(UTC) - timedelta(days=90)
return [user for user in users if user.last_activity and user.last_activity < threshold]
# Фильтр по тарифу
@@ -1855,7 +1872,7 @@ async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
async def get_custom_users(db: AsyncSession, criteria: str) -> list:
now = datetime.utcnow()
now = datetime.now(UTC)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
@@ -1884,7 +1901,7 @@ async def get_custom_users(db: AsyncSession, criteria: str) -> list:
async def get_users_statistics(db: AsyncSession) -> dict:
now = datetime.utcnow()
now = datetime.now(UTC)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
+13 -12
View File
@@ -1,5 +1,5 @@
import asyncio
from datetime import datetime, timedelta
from datetime import UTC, date, datetime, timedelta
import structlog
from aiogram import F, Router
@@ -139,7 +139,7 @@ def _build_notification_settings_view(language: str):
def _build_notification_preview_message(language: str, notification_type: str):
texts = get_texts(language)
now = datetime.now()
now = datetime.now(UTC)
price_30_days = settings.format_price(settings.PRICE_30_DAYS)
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
@@ -694,7 +694,7 @@ async def force_check_callback(callback: CallbackQuery):
Истекающих подписок: {results['expiring']}
Готовых к автооплате: {results['autopay_ready']}
🕐 <b>Время проверки:</b> {datetime.now().strftime('%H:%M:%S')}
🕐 <b>Время проверки:</b> {datetime.now(UTC).strftime('%H:%M:%S')}
Нажмите "Назад" для возврата в меню мониторинга.
"""
@@ -748,7 +748,7 @@ async def traffic_check_callback(callback: CallbackQuery):
Порог дельты: {threshold_gb} ГБ
Возраст snapshot: {snapshot_age:.1f} мин
🕐 <b>Время проверки:</b> {datetime.now().strftime('%H:%M:%S')}
🕐 <b>Время проверки:</b> {datetime.now(UTC).strftime('%H:%M:%S')}
"""
if violations:
@@ -862,7 +862,7 @@ async def test_notifications_callback(callback: CallbackQuery):
📊 <b>Статус системы:</b>
Мониторинг: {'🟢 Работает' if monitoring_service.is_running else '🔴 Остановлен'}
Уведомления: {'🟢 Включены' if settings.ENABLE_NOTIFICATIONS else '🔴 Отключены'}
Время теста: {datetime.now().strftime('%H:%M:%S %d.%m.%Y')}
Время теста: {datetime.now(UTC).strftime('%H:%M:%S %d.%m.%Y')}
Если вы получили это сообщение, система уведомлений работает корректно!
"""
@@ -887,7 +887,7 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
mon_status = await monitoring_service.get_monitoring_status(db)
week_ago = datetime.now() - timedelta(days=7)
week_ago = datetime.now(UTC) - timedelta(days=7)
week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
@@ -1013,7 +1013,7 @@ async def nalogo_force_process_callback(callback: CallbackQuery):
sub_stats = await get_subscriptions_statistics(db)
mon_status = await monitoring_service.get_monitoring_status(db)
week_ago = datetime.now() - timedelta(days=7)
week_ago = datetime.now(UTC) - timedelta(days=7)
week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
week_success = sum(1 for log in week_logs if log['is_success'])
@@ -1235,8 +1235,6 @@ async def receipts_missing_callback(callback: CallbackQuery):
async def receipts_link_old_callback(callback: CallbackQuery):
"""Привязать старые чеки из NaloGO к транзакциям по сумме и дате."""
try:
from datetime import date, timedelta
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import and_, select
@@ -1245,7 +1243,7 @@ async def receipts_link_old_callback(callback: CallbackQuery):
await callback.answer('🔄 Загружаю чеки из NaloGO...', show_alert=False)
TRACKING_START_DATE = datetime(2024, 12, 29, 0, 0, 0)
TRACKING_START_DATE = datetime(2024, 12, 29, 0, 0, 0, tzinfo=UTC)
async with AsyncSessionLocal() as db:
# Получаем старые транзакции без чеков
@@ -1311,9 +1309,12 @@ async def receipts_link_old_callback(callback: CallbackQuery):
try:
from dateutil.parser import isoparse
t.receipt_created_at = isoparse(operation_time)
parsed_time = isoparse(operation_time)
t.receipt_created_at = (
parsed_time if parsed_time.tzinfo else parsed_time.replace(tzinfo=UTC)
)
except Exception:
t.receipt_created_at = datetime.utcnow()
t.receipt_created_at = datetime.now(UTC)
linked += 1
if linked > 0:
+2 -3
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import html
import math
from datetime import UTC, datetime
import structlog
from aiogram import Dispatcher, F, types
@@ -822,9 +823,7 @@ async def export_payments(
file_bytes = json_content.encode('utf-8')
# Отправляем файл
from datetime import datetime
filename = f'payments_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
filename = f'payments_export_{datetime.now(UTC).strftime("%Y%m%d_%H%M%S")}.json'
await callback.message.answer_document(
document=BufferedInputFile(file_bytes, filename=filename),
+4 -4
View File
@@ -4,7 +4,7 @@ import asyncio
import html
import re
from collections.abc import Sequence
from datetime import datetime
from datetime import UTC, datetime
import structlog
from aiogram import Dispatcher, F, types
@@ -260,7 +260,7 @@ def _format_offer_remaining(offer, texts) -> str:
if not offer.expires_at:
return texts.t('ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY', 'без срока')
remaining_seconds = int((offer.expires_at - datetime.utcnow()).total_seconds())
remaining_seconds = int((offer.expires_at - datetime.now(UTC)).total_seconds())
if remaining_seconds <= 0:
return texts.t('ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT_EXPIRED', 'истекло')
@@ -1512,7 +1512,7 @@ async def show_selected_user_details(
lines.append('')
lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION', '💳 Подписка отсутствует'))
now = datetime.utcnow()
now = datetime.now(UTC)
percent = 0
try:
percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0)
@@ -1772,7 +1772,7 @@ async def show_selected_user_details(
)
if subscription:
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(SubscriptionTemporaryAccess)
.options(selectinload(SubscriptionTemporaryAccess.offer))
+4 -4
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Dispatcher, F, types
@@ -722,7 +722,7 @@ async def process_promocode_expiry(message: types.Message, db_user: User, state:
valid_until = None
if expiry_days > 0:
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
valid_until = datetime.now(UTC) + timedelta(days=expiry_days)
type_map = {
'balance': PromoCodeType.BALANCE,
@@ -803,7 +803,7 @@ async def process_discount_hours(message: types.Message, db_user: User, state: F
valid_until = None
if expiry_days > 0:
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
valid_until = datetime.now(UTC) + timedelta(days=expiry_days)
# Создаем DISCOUNT промокод
# balance_bonus_kopeks = процент скидки (НЕ копейки!)
@@ -877,7 +877,7 @@ async def handle_edit_expiry(message: types.Message, db_user: User, state: FSMCo
valid_until = None
if expiry_days > 0:
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
valid_until = datetime.now(UTC) + timedelta(days=expiry_days)
await update_promocode(db, promo, valid_until=valid_until)

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