Compare commits

...

289 Commits

Author SHA1 Message Date
c0mrade 0879b8b218 Merge pull request #2873 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.46.0
2026-04-13 19:47:36 +03:00
github-actions[bot] 1d91382b8e chore(main): release 3.46.0 2026-04-13 16:37:54 +00:00
c0mrade 3768b18a39 Merge pull request #2872 from BEDOLAGA-DEV/dev
Bugfixes: campaign, tickets, NaloGO, devices, broadcasts, menu editor
2026-04-13 19:37:16 +03:00
c0mrade 1eeeb39779 fix: exclude users with active subscriptions from expired broadcast
In multi-subscription mode, a user with an expired trial AND an active
paid subscription was incorrectly included in expired broadcast targets.

Fixed in 3 places:
- get_target_users_count (SQL): added NOT EXISTS active sub subquery
- get_target_users 'expired' (Python): skip if has_active
- get_target_users 'expired_subscribers' (Python): same check
2026-04-13 19:21:52 +03:00
c0mrade 570af82dfd fix: raise MAX_BUTTONS_PER_ROW to 8 and allow tg:// deep links in menu editor
MAX_BUTTONS_PER_ROW was 3, causing Pydantic 422 when adding multiple
custom buttons to a row. Telegram allows up to 8 buttons per row.

Also added tg:// to URL_PATTERN so admins can use Telegram deep links
(tg://resolve, tg://user, etc.) in custom menu buttons. webapp mode
still requires https:// as enforced by existing validation.
2026-04-13 17:07:19 +03:00
c0mrade bc3893b934 fix: use MAX_DEVICES_LIMIT instead of hardcoded 10 for device buttons
Admin user device editor had hardcoded limit of 10 in text, validation,
and inline buttons. Now uses settings.MAX_DEVICES_LIMIT dynamically,
generating buttons 1..N in rows of 4. Falls back to text-only input
if limit exceeds Telegram's 100-button cap.
2026-04-13 14:42:16 +03:00
c0mrade 16d91638bc fix: enforce max_attempts limit in NaloGO receipt queue
The _max_attempts property existed but was never checked as a limit.
Receipts were retried indefinitely (55+ attempts observed in logs).
Now receipts exceeding max_attempts are removed from the queue.
2026-04-13 14:42:06 +03:00
c0mrade eb18b3a0f9 fix: handle TelegramBadRequest when deleting old ticket notifications
Messages older than 48 hours cannot be deleted via Telegram API.
Now falls back to editing the message text instead of crashing.
2026-04-13 14:41:59 +03:00
c0mrade a8e2b62f4b feat: save campaign_slug during standalone email registration
Campaign slug was lost during email registration flow — it was only
sent at verification time from localStorage, which is empty if the
user opens the verification link in a different browser/webview.

Now campaign_slug is accepted in the registration request, saved to
user.pending_campaign_slug, and used as fallback during email
verification. Also processed immediately for auto-verified test emails.
2026-04-13 14:41:49 +03:00
c0mrade fb8d2b3ee4 fix: upsert refresh tokens (ON CONFLICT) + periodic cleanup of expired/revoked tokens 2026-04-10 18:08:27 +03:00
c0mrade 2321667ecb fix: add TRAFFIC_WARNING_ALERT and LOW_BALANCE_ALERT localization keys to all locales 2026-04-10 17:58:04 +03:00
c0mrade 113304b212 style: remove unused import (ruff fix) 2026-04-10 16:47:41 +03:00
c0mrade 0300044b00 feat: add category field to broadcast API schemas and routes 2026-04-10 16:10:13 +03:00
c0mrade 931abfe7a5 feat: add broadcast category (system/news/promo) + filter recipients by user prefs 2026-04-10 16:05:43 +03:00
c0mrade 1d96f80f60 feat: add traffic % warning check using user's threshold preference 2026-04-10 15:59:32 +03:00
c0mrade 4e50419171 feat: implement low balance alert + respect user notification preferences
- Add _check_low_balance_alerts to monitoring service
- Notify users with autopay when balance drops below their threshold
- Uses notification_prefs helper for per-user settings
2026-04-10 15:43:32 +03:00
c0mrade 7208a52c94 feat: respect user traffic_warning notification preference in webhook handler 2026-04-10 15:37:33 +03:00
c0mrade 63fdfe4a42 feat: respect user subscription_expiry notification preferences 2026-04-10 15:37:00 +03:00
c0mrade e0e2edf816 feat: add user notification preferences helper utility 2026-04-10 15:35:30 +03:00
c0mrade 522a8779d6 style: fix ruff format for all sync-related changes 2026-04-10 15:13:59 +03:00
c0mrade be32010d63 fix: trial activation fallback to trial-eligible servers when tariff has no squads (BUG-12) + fix misleading button text 2026-04-10 15:04:38 +03:00
c0mrade 7e920fa30f fix: add retry queue to all remaining RemnaWave error handlers 2026-04-10 14:22:10 +03:00
c0mrade 1b376baeca fix: add retry queue to cabinet subscription operation RemnaWave errors 2026-04-10 11:56:48 +03:00
c0mrade 91a756a33e fix: add retry queue to payment webhook and renewal service RemnaWave errors 2026-04-10 11:56:24 +03:00
c0mrade 970dc549df fix: add retry queue to classic mode bot purchase handler 2026-04-10 11:40:20 +03:00
c0mrade 65120f0bad fix: add retry queue to daily subscription service RemnaWave errors 2026-04-10 11:39:39 +03:00
c0mrade 9cb559ff39 fix: enqueue retry on RemnaWave API failure in all purchase flows (BUG-2, BUG-10)
Add remnawave_retry_queue.enqueue() calls in all 10 purchase error handlers
where RemnaWave API failure was caught and swallowed without scheduling a retry:
- cabinet purchase.py: purchase_tariff() and activate_trial() (2 places)
- subscription_purchase_service.py: miniapp purchase flow (1 place)
- tariff_purchase.py: custom, standard, daily, renewal, switch, daily-switch,
  and instant-switch flows (7 places)
2026-04-10 11:04:02 +03:00
c0mrade 8f1882f24c feat: start RemnaWave retry queue on app startup 2026-04-10 10:48:14 +03:00
c0mrade 8542a39305 fix: always sync squads in auto-purchase renewal (BUG-4) 2026-04-10 10:48:01 +03:00
c0mrade 646ac4cfa1 fix: match tariff_id when creating subscriptions from panel sync (BUG-11) 2026-04-10 10:41:50 +03:00
c0mrade abdf296767 feat: add RemnaWave retry queue for failed API calls (BUG-2, BUG-10) 2026-04-10 10:41:49 +03:00
c0mrade a1b6d9bb61 fix: use update_remnawave_user when UUID exists in tariff_purchase (BUG-3) 2026-04-10 10:38:00 +03:00
c0mrade cf19e4e1f7 fix: protect OAuth users with remnawave_uuid from sync deactivation (BUG-6) 2026-04-10 10:36:16 +03:00
c0mrade 35412e9f21 fix: sync connected_squads from panel during sync (BUG-5) 2026-04-10 10:36:02 +03:00
c0mrade 6aed7d355b fix: default sync_squads=True in update_remnawave_user (BUG-4) 2026-04-10 10:34:56 +03:00
c0mrade 9c08ce6948 fix: resync RemnaWave after account merge (BUG-7) 2026-04-10 10:33:45 +03:00
c0mrade 862352139e fix: use 'is not None' for telegram_id in create_user API (BUG-9) 2026-04-10 10:33:25 +03:00
c0mrade d465ccb3ac fix: resync RemnaWave after Telegram account linking (BUG-1) 2026-04-10 10:32:39 +03:00
c0mrade b57f185258 feat: add remnawave_resync_service for identity-change sync
Introduces resync_user_subscriptions_with_panel(), a standalone async
helper that re-pushes all active subscriptions to the RemnaWave panel
after any identity change (TG linking, account merge, email verification).
Handles multi-tariff vs. single-tariff mode, create vs. update branching,
tariff eager-loading, and returns a synced/failed/total stats dict.
2026-04-10 10:31:12 +03:00
c0mrade ffbb3fb8be Merge pull request #2861 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.45.2
2026-04-08 18:46:58 +03:00
github-actions[bot] f01dbff000 chore(main): release 3.45.2 2026-04-08 15:44:09 +00:00
c0mrade 31adcfded4 Merge pull request #2860 from BEDOLAGA-DEV/dev
fix: batch bug fixes from user complaints
2026-04-08 18:42:47 +03:00
c0mrade 78f963bf5e fix: batch bug fixes from user complaints
- 100% discount: daily tariff fallback to smallest configured period discount
- 100% discount: purchase blocked by safety guard (base_price → original_total in 6 guards)
- Gift subscription reset existing days (replace → extend for active/trial subs)
- Cabinet broadcast: target alias active_subscribers not mapped to active
- Promo code: error always "expired" — split into inactive/not_yet_valid/expired
- Multi-tariff: add delete subscription button in admin bot
- Multi-tariff → single: select subscription with most remaining time (end_date DESC)
- Gift purchases not counted in total spent (added GIFT_PAYMENT type)
- Remnawave API: retry on 502/503/504 (was only 429)
- Heleket: add from_referral_code to invoice payload
- Whitespace fix in blacklist_service
2026-04-08 18:33:17 +03:00
Egor 357d94d1b0 Merge pull request #2855 from andreycoast/fix/blacklist-parsing-logic
fix: исправление парсинга черного списка (поддержка '#' и извлечение username)
2026-04-07 15:49:54 +03:00
Egor 0fb4a2c235 Merge pull request #2856 from BEDOLAGA-DEV/main
w
2026-04-07 15:49:20 +03:00
andreycoast 2f7184627a fix: исправление парсинга черного списка (поддержка '#' и извлечение username) 2026-04-07 15:38:32 +03:00
c0mrade d55e9db62a Merge pull request #2849 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.45.1
2026-04-03 20:58:07 +03:00
github-actions[bot] 57adfaf4f3 chore(main): release 3.45.1 2026-04-03 17:57:12 +00:00
c0mrade 4165eaea7a Merge pull request #2848 from BEDOLAGA-DEV/dev
fix: add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before uniq…
2026-04-03 20:56:51 +03:00
c0mrade 3b5d5a18a1 fix: add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before unique index in migration 0053 2026-04-03 20:50:13 +03:00
c0mrade eef41c4bca Merge pull request #2846 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.45.0
2026-04-03 19:13:53 +03:00
github-actions[bot] 987c3c93c2 chore(main): release 3.45.0 2026-04-03 16:12:28 +00:00
c0mrade 7d24e8d704 Merge pull request #2845 from BEDOLAGA-DEV/dev
fix: subscription system bugfixes + torrent notifications + user deletion cleanup
2026-04-03 19:12:06 +03:00
c0mrade 819f09a68e fix: restore missing import + rewrite user.deleted webhook to properly deactivate all subscriptions
- Fix NameError in admin_users.py: re-add get_traffic_reset_strategy import that ruff auto-removed
- user.deleted: remove auto-recreation logic — deleted means deleted, no more recreating users back in panel
- user.deleted: deactivate primary subscription unconditionally (expire + clear all linkage)
- user.deleted: sweep sibling subscriptions — verify each via panel API, deactivate only those whose panel user is gone (safe for multi-tariff where only one of N panel users may be deleted)
- Works across multi-tariff, single-tariff, and classic modes
2026-04-03 18:56:14 +03:00
c0mrade 2f9d00343b feat: send torrent blocker notification to user (not just admin)
- torrent_blocker.report is now a dual event: admin notification + user message
- New _handle_torrent_detected user handler sends WEBHOOK_TORRENT_DETECTED
- process_event handles events registered in both admin and user handlers
- Webhook router passes DB session for dual events (needs_db_session check)
- Add WEBHOOK_NOTIFY_TORRENT_DETECTED setting (default: true)
- Add WEBHOOK_TORRENT_DETECTED locale texts (ru/en/ua/zh/fa)
2026-04-03 18:19:45 +03:00
c0mrade 9b7ac47f16 fix: resolve multiple subscription bugs — LIMITED status, trial tariff blocking, traffic reset strategy, classic mode pricing, 100% discount support
- Include LIMITED status in subscription lookups (get_active_subscriptions_by_user_id, get_subscription_by_user_and_tariff) — fixes duplicate subscriptions when traffic exhausted
- Migration 0053: update partial unique index to include LIMITED
- Trial subscriptions no longer block tariff purchase — excluded from purchased_tariff_ids, handle_extend_subscription routes trial+tariff to tariff extend flow
- Replace hardcoded TrafficLimitStrategy.MONTH with get_traffic_reset_strategy() across all sync/create paths (remnawave_service, monitoring_service, admin_users)
- Subscriptions with tariff_id always use tariff pricing flow regardless of global sales mode — fixes 0₽ renewal in classic mode
- Support 100% promo group discount across all purchase/renewal flows — balance checks skip when price=0, validation allows final_total=0 when base_price>0
2026-04-03 17:22:42 +03:00
Egor 0d5638f778 Merge pull request #2838 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.44.0
2026-04-02 07:24:58 +03:00
github-actions[bot] 7836720db3 chore(main): release 3.44.0 2026-04-02 04:24:32 +00:00
Egor dcb90d6139 Merge pull request #2837 from BEDOLAGA-DEV/dev
Dev
2026-04-02 07:24:07 +03:00
Fringg 96c420e917 style: fix ruff format for severpay.py 2026-04-02 07:17:36 +03:00
Fringg 9d63635502 feat: add SberPay as KassaAI sub-method (payment_system_id=43)
Adds SberPay alongside existing SBP (44) and Card (36) sub-methods.

Changes across all 8 required files:
- config.py: KASSA_AI_SBERPAY_ENABLED, display name, helper methods
- kassa_ai_service.py: payment_system_id=43 in KASSA_AI_SUB_METHODS
- kassa_ai.py handler: method config + start_kassa_ai_sberpay_topup
- main.py: route tuple, import, handler registration
- payment_service.py: guest purchase flow method check
- cabinet/balance.py: KASSA_AI_OPTION_MAP sberpay=43
- payment_method_config_service.py: sub_option for admin panel
- inline.py: keyboard button + fallback condition guard

Env vars: KASSA_AI_SBERPAY_ENABLED, KASSA_AI_SBERPAY_DISPLAY_NAME
2026-04-02 06:47:57 +03:00
Egor c4c5d330be Merge pull request #2836 from BEDOLAGA-DEV/main
w
2026-04-02 06:39:28 +03:00
Fringg 977950b97f fix: address review issues in PR #2829 webhook intentional deletion guard
5 issues found by review agents and fixed:

1. Intentional deletion guard was at top of process_event, skipping ALL
   cleanup (subscription URLs, server counts, expired marking). Moved
   check into _handle_user_deleted so cleanup still runs but re-creation
   is suppressed via subscription_still_valid=False.

2. Variable shadowing: telegram_id loop var in any() generator shadowed
   the outer telegram_id local. Renamed to tid/uid.

3. No hard cap on in-memory dicts: added _MAX_INTENTIONAL_ENTRIES=10000
   with early return in mark_intentional_panel_deletion.

4. mark_intentional_panel_deletion called inside for-loop with single
   UUID — race window if webhook from first delete arrives before
   second UUID is marked. Moved to before the loop with all UUIDs.

5. Tests: @pytest.mark.anyio → @pytest.mark.anyio('asyncio') for
   consistency. Replaced process_event(db=None) test with direct
   mark+detect unit tests + hard cap test.
2026-04-02 06:34:35 +03:00
Fringg 6f6b9fa039 Merge branch 'yazhog/main' into dev 2026-04-02 06:30:45 +03:00
Fringg 6713921887 fix: Pal24 card/sbp option not passed to API in cabinet balance topup
The payment_option (card/sbp) was parsed from the request but never
passed to create_pal24_payment as payment_method. Without it,
_normalize_payment_method(None) defaults to 'sbp', so both Card and
SBP buttons always created SBP payments.
2026-04-02 06:20:21 +03:00
Fringg 2d42152f54 fix: NameError in SeverPay guest payment flow
user variable was unbound when user_id=None (guest purchase path).
Added user=None in the else branch to prevent NameError when
constructing the fallback email.
2026-04-02 06:18:37 +03:00
Fringg 08ca947b2b fix: send telegram_id@telegram.org as email to SeverPay
SeverPay requires a non-empty client_email field. When user has no
email, fallback to {telegram_id}@telegram.org instead of empty string.
2026-04-02 06:16:17 +03:00
Fringg b04157c913 fix: notification sent for non-deactivated subs + webhook race condition
Two fixes for channel subscription enforcement:

1. Middleware notification guard: the deactivation notification was sent
   even when deactivated_subs was empty (no subs actually deactivated).
   Also fixed len(active_subs) -> len(deactivated_subs) for multi-tariff
   notification text selection.

2. Webhook echo race condition: when a user quickly leaves and rejoins
   a channel, the delayed user.disabled webhook from RemnaWave could
   re-deactivate a subscription that was already reactivated.
   Fix: stamp last_webhook_update_at on reactivation (both channel_member
   handler and middleware), then guard _handle_user_disabled against
   re-deactivating recently-reactivated ACTIVE subscriptions using the
   existing is_recently_updated_by_webhook (60s window).
2026-04-02 06:14:45 +03:00
Fringg f284351c51 fix: middleware disables panel VPN for all subs ignoring per-channel settings
In _deactivate_subscription_on_unsubscribe, the loop calling
disable_remnawave_user iterated over all active_subs instead of only
the subscriptions that passed the should_disable_subscription check.

This caused paid subscriptions to be disabled at the RemnaWave panel
level even when disable_paid_on_leave=False, while the DB record
stayed ACTIVE. On rejoin, reactivation found no DISABLED subs in DB
so enable_remnawave_user was never called — VPN stayed off permanently.

Fixed by collecting actually-deactivated subs into a separate list
and using that for both panel disable calls and notifications.
2026-04-02 06:09:39 +03:00
Fringg b607993854 fix: prevent nested state saves and None state loss in promo handler
Two bugs found during review of the previous FSM state restore fix:

1. Re-entering promo flow created nested _prev_data (unbounded growth).
   Now skips save if already in PromoCodeStates.waiting_for_code and
   strips _prev_ keys from saved data to prevent nesting.

2. _restore_previous_state used `if prev_state:` which treated saved
   None state (user at menu) same as "no saved state". Now uses a
   sentinel to distinguish the two cases, correctly restoring None
   state with its data instead of calling state.clear().
2026-04-02 05:59:28 +03:00
Fringg 246659032d fix: promo code activation destroys balance input FSM state
When a user was in BalanceStates.waiting_for_amount and activated a
promo code, process_promocode called state.clear() on all exit paths,
wiping the balance state. Typing the amount then hit the fallback
"Не понимаю эту команду" handler.

Now show_promocode_menu saves the previous FSM state/data before
entering promo flow, and _restore_previous_state restores it after
promo code processing completes.
2026-04-02 05:55:29 +03:00
Fringg 3dc72b00e7 fix: send telegram_id@telegram.org as email to Kassa AI
Kassa AI requires email in format {telegram_id}@telegram.org but we
were sending user_{order_id}@telegram.org as fallback when email was
not provided. Now the fallback uses user.telegram_id directly.
2026-04-02 05:49:01 +03:00
Fringg 033d0da5e0 fix: remove non-existent Platega method code 10, rename 11 to Карты (RUB)
Platega API defines: 2=СБП, 11=Карточный эквайринг, 12=Международная,
13=Крипто. Code 10 does not exist in their API but was defined in our
config as "Банковские карты (RUB)", while real code 11 was mislabeled
as "Банковские карты". This caused two card options to appear in the
admin panel, one of which didn't work.

- Removed code 10 from definitions, defaults, allowed set, .env.example
- Renamed code 11: "Банковские карты" → "Карты (RUB)"
- Removed redundant filter in handlers/balance/platega.py
- Updated tests to match
2026-04-02 05:44:24 +03:00
Fringg 991f0b43e1 fix: autopay failure notifications ignoring 6h cooldown
Two bugs caused users to receive autopay error notifications every
monitoring cycle (hourly) instead of respecting the 6-hour cooldown:

1. subtract_user_balance failure path had no cooldown check at all
2. cache.exists() silently returns False when Redis is disconnected,
   bypassing the try/except cooldown guard

Extracted shared _check_autopay_fail_cooldown / _set_autopay_fail_cooldown
methods with in-memory fallback dict that works even without Redis.
Added cleanup of expired in-memory entries in _cleanup_notification_cache.
2026-04-02 05:44:13 +03:00
c0mrade c9524cb703 Merge pull request #2832 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.43.1
2026-03-31 20:24:44 +03:00
github-actions[bot] 76d4a2124c chore(main): release 3.43.1 2026-03-31 17:20:24 +00:00
c0mrade 4fa230c07f Merge pull request #2831 from BEDOLAGA-DEV/dev
Release: dev → main
2026-03-31 20:19:53 +03:00
c0mrade d580a78403 Merge remote-tracking branch 'origin/main' into dev 2026-03-31 15:13:46 +03:00
c0mrade 312cc728a9 docs: add Platega partnership to README, highlight partner payment providers 2026-03-31 13:04:40 +03:00
yazhog b1820c651d Fix RemnaWave webhook deletion race 2026-03-30 15:56:45 +03:00
c0mrade 0c284b9e99 fix: use subscription-level remnawave_uuid in multi-tariff mode for sync and detail pages
In multi-tariff mode, remnawave_uuid lives on the subscription object,
not the user. The sync status and user detail endpoints were always
returning user.remnawave_uuid, causing some users to see no UUID.
2026-03-30 14:41:58 +03:00
c0mrade 72170b35f5 fix: prevent MissingGreenlet on subscription.tariff lazy load in webhook handlers
Replace unsafe getattr(subscription, 'tariff', None) with sa_inspect().dict.get()
to avoid triggering lazy loads after db.commit()/refresh() in async context.
2026-03-29 17:31:38 +03:00
Egor 9058a9c5d3 Merge pull request #2824 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.43.0
2026-03-29 07:59:08 +03:00
github-actions[bot] be0934c5e5 chore(main): release 3.43.0 2026-03-29 04:58:33 +00:00
Egor cab1946382 Merge pull request #2823 from BEDOLAGA-DEV/dev
Dev
2026-03-29 07:57:34 +03:00
Fringg fd247bc4f4 style: format devices.py with ruff 2026-03-29 07:33:45 +03:00
Fringg adb39c6ef4 fix: load buyer relationship before gift notification, clean up recipient logic
Add 'buyer' to db.refresh attribute_names so the buyer relationship
is available when building the admin notification (prevents expired
attribute access in async context). Restructure recipient icon logic
to only compute when a recipient value exists.
2026-03-29 07:33:03 +03:00
Fringg 48eaa6b072 fix: distinguish cabinet gift notifications from landing page
Cabinet gift purchases now show "ПОДАРОК ИЗ КАБИНЕТА" instead of
"ПОКУПКА В ПОДАРОК С ЛЕНДИНГА". Buyer is resolved from the user
relationship with @username. Recipient shows "по коду активации"
when no direct recipient specified. Landing page slug line removed
for cabinet purchases.
2026-03-29 07:30:20 +03:00
Fringg 972614511f fix: address remaining review issues in device limit patch
- Migrate get_device_reduction_info to get_user_devices_all (was raw _make_request)
- Migrate admin_users and miniapp callers to get_user_devices_all
- Migrate reset_user_devices internal call to paginated version
- Fix tariff_max_devices falsy-zero in handlers (use explicit is not None and > 0)
- Fix device deletion sort: dateless devices now sort last (candidates for removal)
2026-03-29 07:26:35 +03:00
Fringg 34aec0323b fix: address review issues in device limit patch
- Use paginated get_user_devices_all in delete_all_devices and get_devices
- Fix tariff_max falsy-zero check: use explicit `is not None and > 0`
- Unify keyboard fallback to 100 in both get_devices_keyboard and change
- Remove dead expression `devices_count - current_devices`
- Fix stale error message referencing tariff minimum
- Migrate f-string logger to structlog kwargs style
2026-03-29 07:22:45 +03:00
Fringg 931eeb3568 fix: device limit decrease, HWID pagination, tariff max enforcement
1. Device decrease minimum is now always 1 (was incorrectly using
   tariff.device_limit as floor, blocking decrease e.g. 3/3)
2. Cabinet "Already at minimum device limit" fixed — same root cause
3. Added get_user_devices_all() with pagination for HWID cleanup
4. add_subscription_devices now caps by tariff.max_device_limit
5. Keyboard range expanded to 100 when no global limit set (was 20)
2026-03-29 07:18:55 +03:00
Fringg 2628012097 feat: expose MULTI_TARIFF_ENABLED and MAX_ACTIVE_SUBSCRIPTIONS in admin settings
Register both settings under SUBSCRIPTIONS_CORE category with detailed
hints, descriptions, and dependency info for the cabinet admin panel.
2026-03-29 07:04:29 +03:00
Fringg 7f899a7e41 chore: ruff format account_merge_service.py 2026-03-29 06:49:13 +03:00
Fringg 6dbbe5950e chore: ruff format auth.py and remnawave_service.py 2026-03-29 06:48:59 +03:00
Fringg f93c51a677 fix: gift code activation and multi-tariff subscription sync
Gift activation:
- Encode underscores as %5F in share URLs to prevent Telegram markdown corruption
- Strip GIFT-/GIFT_ prefix from URL code params in frontend
- Backend accepts both GIFT- and GIFT_ prefix on activation
- Bot shows feedback on failed gift auto-activation (self-gift, already activated)

Multi-tariff sync (panel↔bot):
- _sync_users_from_panel_multi now creates subscriptions for unmatched panel users
- sync_users_to_panel matches panel users by username suffix (_short_id) instead of taking arbitrary existing_users[0]
- Save sub.remnawave_uuid after update (was pass/noop)
- Generate remnawave_short_id for all new subscriptions
- Include activeInternalSquads in multi-tariff panel dict
- Append _short_id suffix to username in create_kwargs

Email/OAuth sync:
- _sync_subscription_from_panel_by_email loops ALL panel users in multi-tariff
- Auto-verify path now triggers panel subscription sync
- OAuth new users with verified email get panel sync
- cleanup_orphaned_subscriptions skips email-only users (was force-cleaning them)
2026-03-29 06:47:42 +03:00
Fringg da11ec6f94 fix: assign promo group from tariff on guest purchase
When a guest purchases a tariff with allowed_promo_groups, assign the
first allowed group instead of the default one.

Cherry-picked from PR #2819
2026-03-29 05:03:21 +03:00
Fringg 23d1830644 feat(api): expose email field in UserResponse
Cherry-picked from PR #2821 (without version bump artifacts)
2026-03-29 05:00:44 +03:00
Fringg e3d8d21b66 fix: suppress empty reward alerts and clean up referral notifications
- Silence "link for new users only" message for no-reward ad campaigns
- Show commission % in referral notifications only when > 0
- Show fixed bonus in referral notifications only when > 0
- When both bonus and commission are 0, don't promise a reward

Cherry-picked logic from PR #2822 (without version bump artifacts)
2026-03-29 04:59:23 +03:00
Fringg 2c12a4773c fix: account linking broken in multi-tariff mode (MULTI_TARIFF_ENABLED=true)
- Fix subscription tariff conflict during merge: detect
  uq_subscriptions_user_tariff_active violations before they happen,
  resolve by keeping the subscription with later end_date (NULL=lifetime wins)
- Add merge flow to /auth/email/register: when email belongs to another
  active user, return merge_required+token instead of blocking with 400
- Fix missing remnawave_uuid on subscriptions created by panel email sync
  in multi-tariff mode (_sync_subscription_from_panel_by_email)
- Add rate limiting (5/60s) to email register endpoint
- Filter deleted users from email existence check
- Reorder "already has verified email" guard before merge branch
- Clear autopay_enabled on expired subscriptions during conflict resolution
2026-03-29 04:50:03 +03:00
Fringg 7f60196033 feat: support email/OAuth users in referral editing and add remove endpoints
- Bot handler: add email and internal ID (#123) lookup to referral editor
  (previously only supported telegram_id and @username)
- Cabinet API: add DELETE /{user_id}/referrer endpoint to unbind referrer
- Cabinet API: add DELETE /{user_id}/referrals/{referral_user_id} endpoint
  to remove specific referral
- Both endpoints include permission checks and admin action logging
2026-03-29 03:43:29 +03:00
Fringg b59c581e91 feat: include countryEmoji and providerName in realtime metrics 2026-03-28 23:55:29 +03:00
Fringg 81505c8c1d chore: remove debug logging from get_nodes_realtime_usage 2026-03-28 23:47:50 +03:00
Fringg d6a49e8331 debug: log raw metrics structure to find correct key 2026-03-28 23:45:13 +03:00
Fringg 7ecd95aec0 debug: log raw metrics response structure 2026-03-28 23:40:35 +03:00
Fringg 1471320606 fix: parse_bytes now handles IEC units (GiB, MiB, KiB) from API 2026-03-28 23:37:59 +03:00
Fringg 3d0b874cb4 style: format remnawave_api.py per ruff 2026-03-28 23:28:49 +03:00
Fringg 5d173c806a feat: expose per-inbound traffic breakdown in nodes realtime API
get_nodes_realtime_usage() now preserves the per-inbound and
per-outbound traffic stats from /api/system/nodes/metrics instead
of summing them into node-level totals. Each node in the response
includes inbounds[] and outbounds[] arrays with tag, download,
upload, and total bytes.
2026-03-28 22:47:16 +03:00
Fringg 6d167d2922 fix: harden node info display against injection and type errors
- HTML-escape all external strings (cpuModel, node name, address,
  last_status_message, provider_uuid, versions) in Telegram HTML messages
- Add _safe_int() helper for defensive xrayUptime parsing
- Remove redundant int() casts in route serializers
2026-03-28 22:34:54 +03:00
Fringg 173cc374bb refactor: update remnawave API integration for v2.7.0
- Node: replace flat fields (cpuCount, cpuModel, totalRam, xrayVersion,
  nodeVersion) with nested versions and system dicts, add activePluginUuid
- Node: xrayUptime changed from string to int (seconds), usersOnline
  now non-nullable
- User: remove subLastOpenedAt and subLastUserAgent (dropped in v2.7.0)
- System stats: remove cpu.physicalCores, memory.available/active
- Update all layers: dataclass, service, Pydantic schemas, route
  serializers, Telegram admin handlers
- Fix variable scoping in show_node_statistics error fallback
2026-03-28 22:32:15 +03:00
Fringg 960aa44b00 fix: prevent sync/from-panel cross-subscription data mismatch
When a specific subscription_id is provided but that subscription
has no remnawave_uuid yet, block the sync with a clear error instead
of falling through to the all-UUID iteration which could fetch
panel data from a different subscription.
2026-03-28 19:54:40 +03:00
Fringg 54a19a9c50 feat: add subscription_id to admin sync endpoints for multi-tariff
All 3 sync endpoints now accept optional subscription_id query param:
- GET /sync/status: compares specified subscription with its panel data
- POST /sync/from-panel: syncs panel data to specified subscription
- POST /sync/to-panel: pushes specified subscription to panel

In multi-tariff mode, uses subscription.remnawave_uuid for panel
lookup instead of user.remnawave_uuid. Response includes
subscription_id and subscription_tariff_name for UI context.

When subscription_id is not provided, existing first-active-sub
behavior is preserved for backward compatibility.
2026-03-28 19:50:06 +03:00
Fringg f6f330db4a fix: improve UX for legacy users migrating to tariff mode
- instant_switch handler: redirect legacy users (tariff_id=NULL) to
  tariff_switch migration flow instead of dead-end popup
- autopay skip: notify user once (7-day cooldown) when autopay is
  skipped for legacy subscription, explaining they need to choose
  a tariff for autopay to work
2026-03-28 19:33:18 +03:00
Fringg aa36549bb3 fix: fix MiniApp renewal options 500 error for legacy subscriptions
The early-return response for blocked legacy users used wrong field
name 'balance_currency' instead of 'currency' (a required field in
MiniAppSubscriptionRenewalOptionsResponse), causing Pydantic
ValidationError / 500 Internal Server Error.

Fixed to use correct field names and added status_message explaining
why renewal is blocked, plus balance_label and sales_mode fields.
2026-03-28 19:32:21 +03:00
Fringg 78209c8623 fix: block legacy subscription renewal bypass in tariff mode
When switching from configurator to tariff mode, users with old
subscriptions (tariff_id=NULL) could still renew them through
unguarded paths, bypassing tariff pricing entirely.

Vulnerable paths fixed:
- MiniApp POST /subscription/renewal/options: returns empty list
  for classic subscriptions in tariff mode
- MiniApp POST /subscription/renewal: raises 400 with
  classic_subscription_blocked error code
- Bot confirm_extend_subscription: blocks stale extend_period_
  callbacks with tariff mode check
- Monitoring _process_autopayments: skips classic subscriptions
  (tariff_id=NULL) in autopay loop when tariff mode active

Already protected (no changes needed):
- Cabinet GET/POST renewal endpoints (renewal.py:51,117)
- Auto-purchase service (_prepare_auto_extend_context:244)
- Bot handle_extend_subscription menu (purchase.py:1657)
- Tariff extend flow (tariff_purchase.py:2047)
2026-03-28 19:27:08 +03:00
Fringg cddb8d6332 fix: add tariff identification to remaining notification gaps
- MiniApp renewal success message: add tariff label to _build_renewal_success_message
- Admin buy subscription: add tariff line to user notification
- Promocode subscription days: add tariff label to effect message
- Fix cosmetic double-space in autopay success tariff line
2026-03-28 19:19:18 +03:00
Fringg 092b9f63b2 feat: add SeverPay support to cabinet balance top-up
SeverPay was missing from the cabinet create_topup() endpoint,
causing "This payment method is only available through the Telegram bot"
error when users tried to pay via SeverPay in the web cabinet.
2026-03-28 19:17:56 +03:00
Fringg 7dd67e36b3 feat: add tariff identification to all notifications for multi-tariff mode
When MULTI_TARIFF_ENABLED=true users can have multiple subscriptions,
so notifications must identify which tariff they relate to.

- Webhook notifications: _notify_user auto-injects tariff_label from
  subscription.tariff.name into all 16 webhook notification strings
- Monitoring service: tariff labels in expired, expiring, trial ending,
  follow-up waves, autopay success/failed notifications
- Daily subscription: tariff in insufficient balance notification
- Recurrent payments: tariff in card autopay success/failed
- Auto-purchase: tariff in all 4 auto-purchase notification paths,
  with pre-captured names to avoid MissingGreenlet after db.commit()
- Channel checker: plural form for multi-subscription deactivation
- Payment providers: tariff in YooKassa and Stars activation messages
- Admin: tariff in bulk expiry reminder
- Localization: {tariff_label} in 20 notification keys across all 5
  locales (ru, en, ua, zh, fa) + _MULTI channel keys
- Fix: selectinload(Subscription.tariff) in trial expiring query
- Fix: capture tariff_name before expire_subscription to prevent
  MissingGreenlet from db.refresh() expiring ORM relationships
2026-03-28 19:12:34 +03:00
c0mrade 565c08366b feat: add Remnawave panel 2.7.0 API support
- Add MONTH_ROLLING traffic reset strategy (enum, UI, mapping)
- Fix squad remove-users HTTP method POST → DELETE
- Add forceRestart param to restart_all_nodes (API, service, routes)
- Replace removed /bandwidth-stats/nodes/realtime with /system/nodes/metrics
- Fix parse_bytes suffix matching bug (B was catching GB/MB/KB/TB)
- Add torrent_blocker.report webhook event with admin notifications
- Add subpage_config_changed → auto-invalidate app config cache
- Pass webhook meta field to handlers (notConnectedAfterHours)
- Improve CRM/login webhook formatting (providerName, loginAttempt)
- Update bandwidth display: remove /s suffix, show inbound traffic totals
2026-03-28 12:34:15 +03:00
c0mrade 34b5a9ab3a refactor: remove dead multi-tariff check in guest purchase activation
The else branch of is_multi_tariff_enabled() contained a duplicate
is_multi_tariff_enabled() check that could never execute.
2026-03-27 13:11:51 +03:00
c0mrade 181ef1501b fix: test access promo applies to all active subscriptions in multi-tariff
Previously test squad was added to only one subscription. Now iterates
all active non-daily subscriptions and adds the test squad to each,
creating SubscriptionTemporaryAccess entries per subscription and
syncing each with RemnaWave.
2026-03-27 12:31:12 +03:00
c0mrade cd6913cb84 fix: block classic subscription renewal/autopay when tariff mode enabled
Classic subscriptions (without tariff_id) now cannot be renewed or
auto-renewed when tariff mode is active. Users must purchase a tariff.

Blocked in: cabinet renewal endpoints, cabinet autopay, bot autopay
toggle, and auto-purchase service.
2026-03-27 11:23:45 +03:00
c0mrade 84357a1e87 fix: add selectinload for GuestPurchase.user/tariff in gift activation
Bare select() without eager loading caused MissingGreenlet when
accessing purchase.user and purchase.tariff in async handler.
2026-03-26 20:45:23 +03:00
c0mrade 3bec6620b6 fix: async tariff loading in promocode serialization
Replace lazy-loaded promocode.tariff with explicit async
get_tariff_by_id query to avoid MissingGreenlet error.
2026-03-26 20:27:20 +03:00
c0mrade 3bbcc1b560 style: ruff format 2026-03-26 20:20:09 +03:00
c0mrade b8662b8bf6 fix: trial promo extends existing subscription with same tariff
Instead of blocking when user already has the trial tariff, extend
that subscription by the promo days.
2026-03-26 20:17:25 +03:00
c0mrade 63e4296197 feat: add tariff_id to promo codes for trial subscription type
- Add tariff_id column to promocodes table (migration 0052)
- Admin can now select any tariff when creating trial_subscription promo
- Activation uses promocode.tariff_id if set, falls back to system
  trial tariff
- Multi-tariff: blocks trial only if user already has that specific tariff
2026-03-26 20:09:20 +03:00
c0mrade 3cbe09ddd5 fix: promocode system broken in multi-tariff mode
- Remove duplicate `from app.config import settings` inside function that
  shadowed the module-level import, causing UnboundLocalError for all
  SUBSCRIPTION_DAYS and TRIAL_SUBSCRIPTION promo types
- TRIAL_SUBSCRIPTION now resolves trial tariff via get_trial_tariff() /
  TRIAL_TARIFF_ID and passes tariff_id, traffic_limit_gb, device_limit,
  connected_squads to create_trial_subscription (was creating bare trial
  without any tariff params)
- Multi-tariff: trial promo only blocks if user already has the specific
  trial tariff, not any active subscription
- Cabinet endpoint now accepts subscription_id and returns
  select_subscription response for multi-tariff SUBSCRIPTION_DAYS promos
2026-03-26 19:43:22 +03:00
c0mrade 31c67d1565 fix: cabinet admin create subscription now creates new RemnaWave user in multi-tariff mode
When subscription.remnawave_uuid was None, the fallback to user.remnawave_uuid
caused the new subscription to overwrite the existing panel user instead of
creating a separate one.
2026-03-26 16:40:27 +03:00
c0mrade 95ba739958 fix: admin tariff purchase now creates separate RemnaWave user per tariff
In multi-tariff mode _resolve_admin_subscription was returning any active
subscription regardless of tariff_id, causing new tariff purchases to
overwrite the existing RemnaWave user instead of creating a new one.
2026-03-26 16:34:30 +03:00
c0mrade e39c358d5c fix: show all subscriptions in main menu for multi-tariff mode
Display all user subscriptions inside a single blockquote instead of
showing only one via the deprecated user.subscription property.
2026-03-26 16:15:17 +03:00
c0mrade a12ffb1d6c fix: delete subscription from RemnaWave panel + prevent phantom webhook notifications
- Replace disable_remnawave_user() with delete_remnawave_user() on subscription deletion
  so the panel stops sending webhooks for deleted subscriptions
- Add early return in all webhook handlers when subscription is None (already deleted from DB):
  expired, disabled, enabled, limited, traffic_reset, revoked, expiring reminders
- Add "Delete subscription" button in Telegram bot for expired/disabled subscriptions
  with confirmation step and full cleanup (panel delete + server counts + DB hard delete)
2026-03-26 15:59:36 +03:00
Fringg 6d9bd9915c fix: persist referral to Redis on /start to prevent loss when user opens miniapp
When user clicks /start ref_CODE, the referral code was stored only in
FSM state. If the user opened miniapp/cabinet before completing bot
registration, the referral was lost.

Now /start immediately saves pending_referral:{telegram_id} to Redis
(7-day TTL). The referral is consumed by whichever path creates the
user first — bot create_user(), cabinet auth (initdata/widget/oidc).
Redis key is cleared after consumption to prevent double-referral.

- referral_service: save/get/clear_pending_referral Redis helpers
- start.py: save pending referral for new users only
- crud/user.py: create_user checks Redis if no referred_by_id
- cabinet/auth.py: initdata/widget/oidc routes check + cleanup Redis
2026-03-26 11:55:20 +03:00
Egor d97c8531a3 Delete docs/multi-tariff-review.md 2026-03-26 11:23:48 +03:00
Fringg 94ed282381 fix: multi-tariff MEDIUM/LOW batch — 20 issues across 17 files
MEDIUM fixes:
- config: MAX_ACTIVE_SUBSCRIPTIONS=10 limit + check in tariff purchase
- tariff_purchase: add_user_balance return check → _persist_failed_refund
- tariff_purchase: promo restoration atomic with refund (commit=False)
- purchase: handle_toggle_daily_subscription_pause multi-tariff guard
- my_subscriptions: respect HIDE_SUBSCRIPTION_LINK setting
- multi_tariff: delete_subscription uses actual_status
- tariff_switch: guard against switching to already-owned tariff
- monitoring: i18n button texts via texts.t() + per-subscription dedup
- promo_offer_service: multi-tariff subscription resolution
- bot.py: register DisplayNameRestrictionMiddleware + SubscriptionStatusMiddleware on pre_checkout_query
- crud/user: add_user_balance accepts commit=False
- crud/subscription: update_daily_charge_time refresh only on commit, autopay days_before preserved
- daily_subscription: remove redundant outer commit in process_traffic_resets

LOW fixes:
- inline.py: remove dead get_subscription_expiring_keyboard function
- promocode_service: current_uses +1 in response (post-increment)
- yookassa: use already-resolved subscription for admin notification
- subscription_utils: remove dead ensure_single_subscription + update_or_create_subscription
- devices: merge identical daily/non-daily branches in confirm_change_devices
2026-03-26 11:00:15 +03:00
Fringg fec374edba chore: ruff format 2026-03-26 10:34:14 +03:00
Fringg 58d899aab8 fix: renewal status check, int() safety, daily charge atomicity
- renewal.py: block renew/renewal-options for PENDING/DISABLED subscriptions
  (extend_subscription doesn't transition these to ACTIVE — user would pay
  for nothing)
- autopay.py: wrap 2x bare int() card_id parsing in try/except
- devices.py: wrap 2x bare int() device_count parsing in try/except
- daily_subscription_service: atomic daily charge — subtract_user_balance,
  create_transaction, update_daily_charge_time all use commit=False, single
  db.commit() after all three succeed. Prevents re-charge on partial failure.
- subscription.py: update_daily_charge_time accepts commit=False kwarg
2026-03-26 10:05:52 +03:00
Fringg 1bc2581669 fix: cabinet purchase_tariff — handle IntegrityError with compensating refund
When partial unique index (user_id + tariff_id) fires on concurrent or
duplicate tariff purchase via cabinet, the balance was already deducted
but no subscription was created. Now catches IntegrityError, rollbacks,
refunds via add_user_balance, and returns HTTP 409.
2026-03-26 09:57:07 +03:00
Fringg b0273dc8ae fix: account merge no longer nulls transferred subscriptions' remnawave_uuid
In multi-tariff mode, _handle_subscription_merge transfers ALL secondary
subscriptions to primary. Step 14 then iterated stale secondary.subscriptions
and nulled their remnawave_uuid, breaking the panel link for transferred subs.
Removed the UUID-nulling loop since all subs are already on primary.
2026-03-26 09:38:04 +03:00
Fringg 948e4791f4 fix: multi-tariff Stage 5 fixes — auth sync, notifications, cart, race guard
HIGH fixes:
- auth.py: profile description sync now iterates all per-subscription
  remnawave_uuids in multi-tariff mode
- admin_users: sync_from_panel uses subscription UUIDs for panel lookup,
  does not overwrite user.remnawave_uuid in multi-tariff

MEDIUM fixes:
- monitoring_service: _send_subscription_expired_notification now takes
  subscription param, uses se:{sub_id} in multi-tariff
- remnawave_webhook_service: _get_renew_keyboard accepts subscription_id,
  all 7 callers pass it
- recurrent_payment_service: _build_extend_keyboard with subscription_id
- user_service: balance notification keyboards use menu_subscription in
  multi-tariff instead of bare subscription_extend
- autopay.py + purchase.py: per-subscription cart deletion instead of
  global delete_user_cart where subscription context available
- subscription_auto_purchase_service: 60-sec race guard changed from
  per-user to per-subscription (checks subscription.updated_at)
2026-03-26 09:15:50 +03:00
Fringg a49e52cc92 fix: multi-tariff Stage 4 critical fixes — keyboards, guest purchase, monitoring, tariff deletion
- inline.py: open_subscription_link/subscription_connect callbacks now include
  :{subscription_id} suffix in multi-tariff mode. Main menu uses subscription_connect
  (picker) instead of bare open_subscription_link.
- guest_purchase_service: activate_purchase non-tariff path uses proper ordering
  (non-daily, max days_left) instead of arbitrary _active[0]
- monitoring_service: _send_expired_day1_notification and discount notification
  keyboards use se:{subscription.id} in multi-tariff (2 more hardcoded callbacks fixed)
- admin/tariffs: delete_tariff_confirmed now checks active subscription count
  before deletion (RESTRICT FK). Prompt shows blocking message when active subs exist.
  New CRUD function get_active_subscriptions_count_by_tariff_id.
2026-03-26 08:46:00 +03:00
Fringg 6dc5879ffa docs: add Stage 3+4 audit results to multi-tariff review 2026-03-26 08:39:45 +03:00
Fringg aa7e461c44 fix: multi-tariff Stage 3 HIGH fixes — phantom, cart, yookassa, auto-extend
- phantom_service: iterate all subscriptions for panel sync after claim
  (was using deprecated user.subscription singular property)
- tariff_purchase: 6x delete_user_cart replaced with per-subscription
  delete_subscription_cart in multi-tariff mode
- yookassa: recurrent payment subscription_id mismatch now resolves
  correct subscription from metadata instead of just logging warning
- subscription_auto_purchase: try_auto_extend_expired and
  try_resume_disabled_daily now query ALL subs (not just active) to find
  expired/disabled subscriptions that need processing
2026-03-26 08:31:49 +03:00
Fringg 49db5f5eed fix: multi-tariff Stage 3 critical fixes — panel sync UUID, admin grant, wheel
- remnawave_service: sync_users_to_panel uses sub.remnawave_uuid in
  multi-tariff instead of user.remnawave_uuid (was targeting wrong panel user)
- admin/users: admin_buy_subscription_execute saves UUID to
  subscription.remnawave_uuid in multi-tariff (was saving to user)
- wheel_service: _process_days_payment and _apply_prize require subscription
  in multi-tariff mode, fallback converts to balance bonus for prizes
2026-03-26 08:26:09 +03:00
Fringg c6bedc6a06 fix: multi-tariff Stage 2 HIGH fixes — 18 issues across 12 files
Bot handlers (H1-H5):
- confirm_extend_subscription: error alert instead of wrong sub fallback
- open_subscription_link/subscription_connect: startswith registration
- handle_subscription_settings: multi-tariff guard
- confirm_reset_traffic: FSM state check in multi-tariff

Services (H6-H12):
- subscription_service: 5 UUID fallback fixes — no user.remnawave_uuid in
  multi-tariff, return None if subscription.remnawave_uuid missing
- auto_purchase: use cart subscription_id for tariff match
- remnawave_service: migrate_squad_users checks subscription.remnawave_uuid
- campaign_service: extend existing sub or create new in multi-tariff
- broadcast_service: check ALL subs for paid-subscription guard
- blocked_users_service: remnawave_uuids list, iterate in cleanup
- user_service: log sub.remnawave_uuid in multi-tariff

Admin (H13-H16):
- grant_trial/paid_subscription: allow in multi-tariff mode
- promo_offers: pick sub with URL, aggregate squads from all subs

CRUD/Frontend (H17-H18):
- get_users_list: .unique() for outerjoin dedup
- refreshTraffic: withSubId in params instead of body
2026-03-26 08:09:07 +03:00
Fringg 4259ba1cb5 fix: multi-tariff Stage 2 critical fixes — panel sync, guest purchase, cart isolation
CRITICAL fixes:
- remnawave_service: panel_user.uuid AttributeError (3 places) — dict needs
  .get('uuid'), not .uuid attribute access. Silent fail caused duplicate subs.
- remnawave_service: removed traffic_limit_gb, device_limit, connected_squads
  overwrites from panel sync — bot is source of truth for these fields
- guest_purchase_service: multi-tariff now checks per-tariff (not any active
  sub), allowing purchase of different tariffs simultaneously
- subscription_auto_purchase_service + user_cart_service: per-subscription cart
  storage via user_cart:{user_id}:sub:{sub_id} keys. Cart resolution no longer
  falls through to heuristic when saved_subscription_id lookup fails.
  _delete_cart_for_subscription replaces delete_user_cart in all paths.
2026-03-26 07:57:59 +03:00
Fringg 40d2ec6718 docs: update multi-tariff review with Stage 2 full audit results
Stage 2 covered ~60 files across 6 parallel agents:
- Bot handlers (purchase, traffic, devices, links, start, menu, etc.)
- Core services (subscription, auto-purchase, daily, remnawave, guest, campaign)
- Admin handlers + cabinet modules
- CRUD functions + utilities
- Frontend cabinet (React/TypeScript)
- Remaining services (renewal, broadcast, blocked, yookassa, backup)

Found: 5 CRITICAL, 18 HIGH, 18 MEDIUM, 10 LOW issues
Confirmed correct: 15 components
2026-03-26 07:36:31 +03:00
Fringg 5724906517 fix: multi-tariff code review — 13 critical/high bugs fixed across 14 files
CRITICAL fixes:
- promocode_service: NameError (subscription_id not passed), TypeError (dict
  returns), savepoint without commit, dead else branch
- cabinet status/autopay/renewal: resolve_subscription() instead of
  user.subscription fallback in multi-tariff mode
- cabinet devices: MultipleResultsFound crash on 3 POST endpoints
- webhook service: IDOR returning cross-user subscription
- monitoring_service: real expiring notification keyboard with se:{sub_id}

HIGH fixes:
- subscription_purchase_service: FOR UPDATE on both branches of submit_purchase
- miniapp: 8 endpoints now pass subscription_id to _ensure_paid_subscription
- inline.py: se:{subscription_id} callback for expiring keyboard
- tariff_purchase: TransactionType.FAILED_REFUND + _persist_failed_refund()
- account_merge_service: panel sync after subscription transfer
- webhook service: .limit(1) on fallback queries to prevent MultipleResultsFound
2026-03-26 07:26:53 +03:00
c0mrade 1099c5224c Merge pull request #2814 from BEDOLAGA-DEV/feat/multi-subscription
feat: multi-subscription support
2026-03-25 18:48:31 +03:00
c0mrade bd46b4cf6d fix: UUID check in servers/tariff_switch, start.py refresh, delegation state passing
servers.py + tariff_switch.py: use subscription.remnawave_uuid in multi-tariff
instead of user.remnawave_uuid to prevent duplicate panel users.

start.py: exception handler uses ['subscriptions'] (plural) for db.refresh.

my_subscriptions.py: delegation handlers pass state to downstream so
_resolve_subscription can read active_subscription_id from FSM.
2026-03-25 18:40:38 +03:00
c0mrade a232d21edd fix: remove UUID fallback override in admin_tariffs + restore promo on IntegrityError
admin_tariffs.py: removed trailing `or (sub.user.remnawave_uuid ...)`
that silently used wrong user-level UUID when subscription UUID was None.

tariff_purchase.py: IntegrityError handler in confirm_tariff_purchase now
restores consumed promo offer discount, matching the generic Exception handler.
2026-03-25 17:52:07 +03:00
c0mrade dbe247ba6f fix: multi-tariff sync auto-links legacy user-level UUIDs to subscriptions
When sync finds a panel user whose UUID matches User.remnawave_uuid
(legacy single-tariff) but not any Subscription.remnawave_uuid, it now
auto-links that UUID to the user's best active non-daily subscription.
This handles migration from single-tariff to multi-tariff mode without
losing panel user associations.
2026-03-25 17:16:12 +03:00
c0mrade c3c2b8137b fix: RemnaWave sync finds user by Subscription.remnawave_uuid in multi-tariff
get_user_by_remnawave_uuid: fallback query searches Subscription table
when User-level UUID not found (multi-tariff stores UUID per-subscription).

Webhook _resolve_user_and_subscription: direct Subscription lookup before
returning None when user not found by telegram_id or User.remnawave_uuid.

Webhook user.deleted: refresh user.subscriptions before iterating to
ensure relationship is loaded from DB.

Account merge: clear subscription-level remnawave_uuid/short_uuid on
secondary user's subscriptions to prevent orphaned panel users.
2026-03-25 17:05:12 +03:00
c0mrade 87bf65c809 fix: renewal handlers use _resolve_subscription + store subscription_id in FSM
select_tariff_extend_period and confirm_tariff_extend now use
_resolve_subscription instead of next() by tariff_id, preventing
wrong subscription selection with duplicate tariff_ids.
FSM state now stores active_subscription_id alongside extend_tariff_id.
2026-03-25 16:49:36 +03:00
c0mrade 4e12ab3458 fix: daily tariff switch uses _resolve_subscription instead of searching by new tariff_id
confirm_daily_tariff_switch was searching for subscription matching the
TARGET tariff_id (the one being switched TO), which always returns None
since user doesn't have that tariff yet. Now uses _resolve_subscription
to get the source subscription (the one being switched FROM).
2026-03-25 16:42:22 +03:00
c0mrade 25b853d629 fix: post-payment keyboard checks all subscriptions instead of LIMIT 1
The MissingGreenlet fallback path in build_topup_success_keyboard
now queries all active/trial subscriptions and checks if ANY is active
paid, instead of only checking the most recently created one.
2026-03-25 16:30:01 +03:00
c0mrade e42bddb868 fix: comprehensive tariff switch/extend/back button fixes for multi-tariff
tariff_purchase.py:
- Switch lists filter ALL purchased tariffs, not just current one
- Switch handlers use _resolve_subscription (FSM state) instead of
  searching by new tariff_id
- Extend shows subscription picker when >1 active subs
- All success screens return to sm:{sub_id} in multi-tariff

purchase.py:
- confirm_extend_subscription reads active_subscription_id from FSM state

common.py:
- get_reset_devices_confirm_keyboard accepts back_callback param
- get_confirm_switch_traffic_keyboard accepts back_callback param

traffic.py:
- confirm_switch_traffic passes dynamic back_callback
2026-03-25 16:14:58 +03:00
c0mrade 9644135dd7 fix: tariff purchase shows purchased tariffs and blocks re-buying in multi-tariff
show_tariffs_list now fetches purchased_tariff_ids and passes them to
format_tariffs_list_text (marks with ) and get_tariffs_keyboard (marks button).
select_tariff blocks purchase of already-active tariff with alert showing
days remaining and directing user to "Мои подписки" for renewal.
2026-03-25 15:56:30 +03:00
c0mrade 59d4b353a6 fix: pass sub_id to show_devices_page to fix NameError in multi-tariff
show_devices_page used sub_id for back_callback but didn't receive it
as parameter. Added sub_id parameter and pass it from all 3 call sites.
2026-03-25 15:46:33 +03:00
c0mrade f925efbfb4 fix: devices button shows menu with buy + manage options in multi-tariff
When user clicks "Устройства" from subscription detail, shows intermediate
menu with two options:
- "Докупить устройства" (if tariff allows) → change device limit flow
- "Управление устройствами" → view/reset connected devices
Back button returns to subscription detail.
2026-03-25 15:43:14 +03:00
c0mrade 319941d33a fix: back buttons in devices/traffic return to subscription detail in multi-tariff
All keyboard builders (change_devices, confirm_change_devices,
devices_management, traffic_switch) now accept back_callback parameter.
In multi-tariff mode, back button returns to subscription detail (sm:{sub_id})
instead of legacy subscription_settings screen.
2026-03-25 15:37:33 +03:00
c0mrade a39e3554d8 fix: show subscription picker for traffic/connect buttons with multiple subs
When user has >1 active subscription and clicks "Докупить трафик" or
"Подключиться" from main menu, now shows inline subscription picker
instead of just an alert. User selects subscription, then proceeds
to the corresponding flow. Removed redundant parse_mode (set globally).
2026-03-25 15:26:42 +03:00
c0mrade 382e29d3dd fix: back button in subscriptions list uses correct callback
Changed callback_data from non-existent 'menu_main' to registered
'back_to_menu' handler so the back button actually returns to main menu.
2026-03-25 15:17:26 +03:00
c0mrade 05d1ae0560 fix: notifications include tariff name for multi-subscription clarity
Expiry, autopay success, daily charge, and traffic reset notifications
now append tariff name when multi-tariff is enabled, so users know which
subscription the notification is about.
2026-03-25 15:11:39 +03:00
c0mrade 9a27e6db31 fix: admin server/devices/traffic buttons pass subscription_id in multi-tariff
All 4 remaining admin buttons (change server, devices limit, traffic limit,
reset devices) now include _s{subscription_id} suffix in callback_data.
Handlers extract subscription_id and operate on the correct subscription.
2026-03-25 15:11:24 +03:00
c0mrade a1623d94b1 fix: web API routes use multi-subscription resolution for operations
miniapp.py: get_subscription_details, get_tariffs, purchase_tariff,
preview_tariff_switch all resolve subscription from subscriptions list
instead of user.subscription property.
subscriptions.py + users.py: replace_existing and deactivation use
smart selection with len==1 guard.
2026-03-25 11:47:17 +03:00
c0mrade f83ff26332 fix: cabinet routes use smart subscription fallback + per-subscription UUID
helpers.py resolve_subscription picks best non-daily when no subscription_id.
renewal, purchase, wheel: same smart selection in multi-tariff fallback.
devices: uses _resolve_panel_uuid helper for create/update decision.
admin_tariffs: squad sync uses subscription.remnawave_uuid.
admin_traffic: _load_user_map loads subscription-level UUIDs in multi-tariff.
2026-03-25 11:47:10 +03:00
c0mrade fe03b587db fix: UUID warnings, phantom merge, yookassa validation, contest prize notification
channel_member: warns on UUID fallback in multi-tariff.
start.py: phantom merge checks subscription-level UUIDs before user-level transfer.
yookassa: validates subscription_id from recurrent payment metadata.
contest prize: notification includes tariff name for multi-subscription clarity.
2026-03-25 11:47:02 +03:00
c0mrade afd7b6d7ec fix: remnawave service uses per-subscription UUID throughout multi-tariff
Squad sync, user sync, UUID assignment, force_cleanup all use
subscription.remnawave_uuid in multi-tariff. Fallback _subs[0] replaced
with smart selection. phantom_service refreshes 'subscriptions' (plural).
2026-03-25 11:46:55 +03:00
c0mrade f89e326a19 fix: auto-purchase processes each autopay subscription independently
Instead of skipping when multiple active subscriptions exist, auto-purchase
now selects the subscription with autopay_enabled and most urgent renewal
(fewest days left). Handles single/multiple autopay subscriptions correctly.
2026-03-25 11:46:50 +03:00
c0mrade 0866c2ea4b fix: services use smart subscription selection + per-subscription UUID
promocode, campaign, guest_purchase, subscription_purchase, user_service,
daily_subscription — all replace active_subs[0] with best non-daily selection.
daily_subscription_service uses subscription.remnawave_uuid in multi-tariff.
2026-03-25 11:46:42 +03:00
c0mrade 147ef6b22b fix: admin handlers use _resolve_admin_subscription + per-subscription UUID
Centralized admin subscription resolution with smart selection (non-daily,
most days left). All 12+ admin operations use the new helper. UUID operations
(disable, enable, reset devices, update) now use subscription.remnawave_uuid
in multi-tariff mode instead of user-level UUID.
2026-03-25 11:46:36 +03:00
c0mrade 76ba19da17 fix: eligibility and display use best non-daily subscription in multi-tariff
Contests, wheel spin, and menu now select best non-daily subscription
(most days remaining) instead of arbitrary active_subs[0].
2026-03-25 11:46:29 +03:00
c0mrade 90fb0a21e2 fix: pass FSM state to _resolve_subscription across all subscription handlers
All bot subscription handlers (traffic, autopay, devices, links, countries,
purchase, tariff_purchase) now pass state: FSMContext to _resolve_subscription
so multi-tariff subscription context is preserved from my_subscriptions flow.
2026-03-25 11:46:22 +03:00
c0mrade 684f286fcd fix: add_traffic handler passes FSM state to resolve_subscription for multi-tariff context 2026-03-24 22:01:43 +03:00
c0mrade d2bbeb8624 fix: contest prize applies to best non-daily subscription in multi-tariff 2026-03-24 21:36:31 +03:00
c0mrade 72d5bae531 fix: tariff_purchase next() fallbacks use None instead of active_subs[0] in multi-tariff 2026-03-24 21:33:50 +03:00
c0mrade 6d468e9ada fix: multi-subscription support for promocodes, contests, phantom merge
Promocodes with days:
- activate_promocode accepts subscription_id parameter
- Multi-tariff + >1 eligible subs: returns select_subscription for UI
- Bot handler: shows subscription picker keyboard, callback applies to chosen sub
- Single sub: auto-applies as before

Contests:
- _resolve_subscription_for_prize: prefers non-daily sub with most days_left
- All 5 contest endpoints use shared resolver

Phantom service:
- merge_phantom_into_user: uses subscriptions collection instead of single
- sync_remnawave_after_phantom_merge: syncs all subscriptions, not just first
2026-03-24 21:29:17 +03:00
c0mrade 355fef846e fix: centralize trial cleanup in CRUD + shared subscription resolver for bot
Trial cleanup:
- create_paid_subscription: auto-deactivates all trials when creating paid sub
- extend_subscription: auto-deactivates trials when extending converts to paid
- Works from ALL paths: bot, cabinet, miniapp, webhooks, auto-purchase

Bot handlers:
- Shared resolve_subscription_from_context in common.py with FSM state fallback
- Fixes nested callbacks losing subscription context in multi-tariff
- All 5 handlers (traffic, devices, autopay, links, countries) use shared resolver
- my_subscriptions stores active_subscription_id in FSM state on delegation
2026-03-24 20:00:11 +03:00
c0mrade 424fff4ac2 fix: trial reset in multi-tariff only deletes trial subscriptions, keeps paid
- Multi-tariff + has paid subs: only trial subscriptions deleted
- Multi-tariff + all trials: deletes all (correct — no paid to keep)
- Single-tariff: unchanged (deletes all as before)
2026-03-24 19:20:28 +03:00
c0mrade d04f2fc718 fix: set is_daily_paused=True when admin cancels/disables daily subscription to prevent auto-resume 2026-03-24 18:38:45 +03:00
c0mrade 56fffc2415 fix: admin panel per-subscription UUID in multi-tariff mode
_sync_subscription_to_panel: use subscription.remnawave_uuid
panel-info/node-usage/devices: accept subscription_id query param
delete/reset devices: per-subscription UUID
enable after add_traffic: per-subscription UUID
trial/subscription reset, disable user: iterate all subs
push_to_panel: per-subscription UUID + fallback fixes
2026-03-24 16:54:53 +03:00
c0mrade c27f144b76 feat: DELETE /subscriptions/:id for expired/disabled subscriptions 2026-03-24 15:30:40 +03:00
c0mrade 34bb87c7ba fix: import Subscription in wheel_service to fix NameError 2026-03-24 14:44:34 +03:00
c0mrade 24edfb6c3f feat: wheel subscription picker for multi-tariff mode
- SpinAvailability returns eligible_subscriptions (non-daily, enough days)
- spin() accepts subscription_id to target specific subscription
- _process_days_payment and _apply_prize use provided subscription
- WheelConfigResponse includes eligible_subscriptions for frontend picker
- SpinRequest accepts subscription_id in body
- Daily tariffs excluded from wheel eligibility
2026-03-24 14:31:19 +03:00
c0mrade 824d54b7dc fix: accept subscription_id from query param in renew endpoint (consistent with other endpoints) 2026-03-24 14:09:05 +03:00
c0mrade 71082f436c fix: re-fetch subscription after lock_user_for_pricing to prevent selectinload reset 2026-03-24 13:07:33 +03:00
c0mrade 4dd81702ce feat: return is_daily and is_daily_paused in subscription list API 2026-03-24 12:27:52 +03:00
c0mrade 4f76f53d55 fix: add missing ADMIN_PAYMENTS localization keys for ru and en 2026-03-24 12:08:29 +03:00
c0mrade 048d208bc1 feat: trial lifecycle + purchase-options filter for multi-tariff
Trial:
- create_trial_subscription: autopay_enabled=False always
- autopay endpoint: block enabling autopay for trial subscriptions
- Purchase flows: deactivate all user trials on paid purchase,
  transfer remaining days if TRIAL_ADD_REMAINING_DAYS_TO_PAID,
  disable trials on RemnaWave panel

Purchase options:
- Return is_purchased per tariff and all_tariffs_purchased flag
  in multi-tariff mode for frontend filtering
2026-03-24 10:47:14 +03:00
c0mrade 344852b852 fix: trial subscription lifecycle — autopay, cleanup on purchase, bonus days
- create_trial_subscription: always set autopay_enabled=False (trial is a
  probe, autopay makes no sense regardless of operator default setting)
- autopay endpoint: block enabling autopay on trial subscriptions via API
- purchase-tariff (cabinet): before creating/extending paid subscription,
  find and deactivate ALL user's trial subscriptions, collect remaining
  time for TRIAL_ADD_REMAINING_DAYS_TO_PAID, disable trials on RemnaWave
  panel, decrement server counts — works for both tariff-based and
  squad-based trials uniformly
- subscription_purchase_service (miniapp): same trial cleanup logic
- New CRUD: deactivate_user_trial_subscriptions() — finds all active
  trials for user, marks them disabled with is_trial=False
2026-03-23 23:11:18 +03:00
c0mrade 78a7eafcb6 fix: prevent sync from overwriting wrong subscription traffic in multi-tariff mode
Remove fallback to first subscription when panel user UUID doesn't match
any subscription. Previously, _subs_upd[0] was used as fallback, causing
panel data (including traffic_used_gb=0 after reset) from one subscription
to overwrite another subscription's data during periodic sync.
2026-03-23 21:15:06 +03:00
c0mrade 5a7b3d5962 fix: renumber multi-subscription migrations to avoid conflicts with dev
Rename 0041 → 0050 and 0042 → 0051, chain after dev's 0049
to resolve multiple head revisions error on deployment.
2026-03-23 19:16:35 +03:00
c0mrade cefdfc54cc fix: add period_days validation and zero-price guard to tariff purchase
Port validations from dev monolith that were missing after sub-router split:
- Validate period_days against tariff's configured periods
- Allow custom days only if tariff explicitly supports them
- Reject zero-price purchases for non-daily tariffs (defense in depth)
2026-03-23 18:47:57 +03:00
c0mrade b6cf361737 fix: remove unused imports and variables after rebase 2026-03-23 18:46:28 +03:00
c0mrade 4f20e0e4cb chore: update uv.lock 2026-03-23 18:45:46 +03:00
c0mrade d87fb47e88 fix: multi-subscription UUID resolution and ownership validation
- Add _resolve_panel_uuid helper for per-subscription UUID in multi-tariff mode
- Add user ownership validation (user_id check) to all subscription queries
- Add unique partial index on (user_id, tariff_id) for active subscriptions
- Generate remnawave_short_id for new subscriptions in all creation paths
- Fix trial endpoints to check all user subscriptions, not just first
- Fix channel member handler to enable/disable per-subscription UUIDs
- Fix channel checker middleware for multi-subscription iteration
- Fix tariff switch, traffic, and device endpoints to use correct panel UUID
- Fix monitoring, auto-purchase, renewal services for multi-subscription
- Fix user_service, miniapp, subscriptions and users webapi routes
2026-03-23 18:45:46 +03:00
c0mrade 82958801b5 fix: harden webhook signature verification across all payment providers
- Replace fail-open with fail-closed in CryptoBot, Heleket, Tribute webhooks
  (missing API key now rejects instead of accepting)
- Use hmac.compare_digest for timing-safe comparison in Freekassa, KassaAI,
  Pal24, and Platega webhook verification
- CloudPayments: reject webhooks when signature header is missing but
  API_SECRET is configured (check, pay, fail, universal endpoints)
- CloudPayments: return code 13 (reject) instead of code 0 on parse errors
  and exception handlers to prevent fail-open
2026-03-23 18:45:46 +03:00
c0mrade d071269b8c fix: comprehensive multi-subscription audit fixes across routes, handlers, and services
- Fix UUID resolution in monitoring and webhook services for multi-tariff mode
- Update cabinet routes to properly resolve per-subscription UUIDs
- Fix account merge service for multi-tariff subscription transfers
- Update admin handlers (users, promo_offers, servers) for multi-subscription
- Fix traffic, devices, servers, daily subscription modules
- Update payment handlers (stars, yookassa) and purchase services
- Fix broadcast, promocode, and subscription auto-purchase services
- Add multi-subscription support to keyboards and localization
- Fix CRUD operations for subscription queries
- Resolve code quality issues (ruff linting)
2026-03-23 18:40:23 +03:00
c0mrade 18f31c565c fix: validate_and_clean_subscription uses per-subscription UUID in multi-tariff mode, not user-level UUID 2026-03-23 18:40:23 +03:00
c0mrade 2f88b07f05 fix: remove user.subscription setter - use local variable instead 2026-03-23 18:40:23 +03:00
c0mrade c8ecec47a0 fix: use empty path instead of '/' for multi-tariff list endpoint to avoid 404 2026-03-23 18:40:23 +03:00
c0mrade 06feb3fff5 fix: disable redirect_slashes globally to prevent HTTP 307 on subscription endpoints 2026-03-23 18:40:23 +03:00
c0mrade f7f8ea87cf fix: add redirect_slashes=False to prevent HTTP 307 redirects on subscription endpoints 2026-03-23 18:40:23 +03:00
c0mrade e99f3d9a37 fix: rename refresh('subscription') to refresh('subscriptions') in all files 2026-03-23 18:40:23 +03:00
c0mrade 07ebc435cf fix: use '/' instead of empty path in subscription sub-routers 2026-03-23 18:37:18 +03:00
c0mrade 335be66980 feat: multi-subscription support (1 user = N subscriptions)
- Add MULTI_TARIFF_ENABLED feature flag for gradual rollout
- Migration 0041: remove unique constraint on subscriptions.user_id
- Migration 0042: add remnawave_short_id (NOT NULL, UNIQUE) to subscriptions
- Each subscription gets its own Remnawave user (user_{tg_id}_{short_id})
- Add _resolve_subscription() to all 30+ bot handlers for per-subscription routing
- Add my_subscriptions.py with list/detail views and delegation handlers
- Refactor cabinet subscription.py (4687 lines -> 10 focused modules)
- Add subscription_id parameter to all cabinet API endpoints
- Adapt all services: autopay, wheel, contests, campaign, guest purchase,
  monitoring, blocked users, account merge, promo codes, payments
- Replace all user.subscription (singular) with user.subscriptions iteration
- IDOR protection via get_subscription_by_id_for_user on all endpoints
- Full backward compatibility: MULTI_TARIFF_ENABLED=False = legacy behavior
2026-03-23 18:37:17 +03:00
Fringg c805cfd6d8 fix: transliterate Cyrillic slugs instead of stripping to 'untitled'
sanitize_slug validators now use _slugify() for proper transliteration.
2026-03-23 18:19:02 +03:00
Fringg 004dac5b7e fix: resolve MissingGreenlet error on article detail view
Build response dict before increment_views() commit expires ORM attributes.
2026-03-23 18:16:22 +03:00
Egor 7e9cc530e7 Merge pull request #2809 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.42.0
2026-03-23 16:57:38 +03:00
github-actions[bot] 69b824572a chore(main): release 3.42.0 2026-03-23 13:57:17 +00:00
Egor ab4785f8df Merge pull request #2808 from BEDOLAGA-DEV/dev
Dev
2026-03-23 16:56:36 +03:00
Fringg 1642be8bd6 chore: ruff format miniapp.py 2026-03-23 16:49:17 +03:00
Fringg 8175bc8bfe fix: comprehensive security hardening across payment and API layers
- CloudPayments: require webhook signature when secret configured (all 4 handlers)
- Platega: timing-safe HMAC comparison via hmac.compare_digest
- CryptoBot/Heleket: return False when API token unconfigured
- Tribute: return 503 when API key not configured
- Freekassa: use request.client.host instead of X-Forwarded-For
- Pal24: verify webhook amount matches stored payment amount
- YooKassa: reject test-mode payments in production; add YOOKASSA_TEST_MODE config
- CloudPayments: reject test-mode payments in production
- WebAPI: add upper bounds to duration_days, traffic_limit_gb, device_limit schemas
- WebAPI: bound balance update amount to ±100M kopeks
- WebAPI: sign-dispatch for balance updates (negative → subtract_user_balance)
- WebAPI miniapp: add blocked/deleted user checks, restriction_topup/subscription guards
- Admin handlers: add @admin_required and @error_handler to moderator panel
- add_user_balance: guard against negative amounts (use subtract_user_balance instead)
2026-03-23 16:44:38 +03:00
Fringg 4660ca5756 fix: validate period_days against tariff in purchase-tariff and auto-purchase
Critical security fix: the POST /cabinet/subscription/purchase-tariff
endpoint accepted arbitrary period_days from client without validating
against the tariff's configured periods. The pricing engine returned 0
for unknown periods, allowing free subscription creation.

Changes:
- Add period_days whitelist validation in /purchase-tariff endpoint
- Add zero-price safety guard as defense in depth
- Add period_days validation in _auto_purchase_tariff (saved cart)
- Add period_days validation in _prepare_auto_extend_context (saved cart)
2026-03-23 16:35:18 +03:00
Fringg 6bf41a72d0 chore: ruff format news_tags.py 2026-03-23 16:08:10 +03:00
Fringg 6d0b003591 chore: ruff format 2026-03-23 16:07:01 +03:00
Fringg 334db53868 feat: show Platega payment methods inline on main screen (#2720)
Show each Platega method (SBP, cards, crypto) as a separate button
on the main payment screen like YooKassa, instead of behind a sub-menu.

Controlled by PLATEGA_INLINE_METHODS env var (default: true).
Set to false to keep the old sub-menu behavior.
2026-03-23 16:04:27 +03:00
Fringg 76b1f9b036 fix: use IF EXISTS in downgrade for FK indexes 2026-03-23 15:43:07 +03:00
Fringg f0cdd5dc90 fix: validate FK existence, add FK indexes, expand video brand whitelist
- Validate category_id/tag_id exist before creating/updating articles (422 not misleading 409)
- Add indexes on news_articles.category_id and tag_id for efficient FK lookups
- Expand MP4 brand whitelist: iso2-4, qt (MOV/iPhone), 3gp, M4VH/VP, MSNV, NDAS/C/H/S/M/P
- Log unknown ftyp brands for debugging rejected video uploads
2026-03-23 15:40:16 +03:00
Fringg d9cda3a6d6 fix: register categories/tags/media routers before news to avoid route conflict
GET /admin/news/{article_id} was catching /admin/news/categories and
/admin/news/tags requests, parsing "categories" as int → 422.
2026-03-23 15:37:08 +03:00
Fringg 51392d1918 feat: add managed news categories and tags with DB-backed CRUD
- Add news_categories and news_tags tables with case-insensitive unique names
- Add category_id/tag_id FK columns to news_articles (ON DELETE SET NULL)
- CRUD endpoints for categories and tags (admin permissions)
- Sync legacy string fields from FK entities on create/update
- Clear legacy fields on category/tag deletion
- Alembic migration 0049 with backfill from existing article data
2026-03-23 15:29:21 +03:00
Fringg 89bfdc8ed6 fix: add explicit File(...) to UploadFile param to fix 422 on media upload 2026-03-23 15:17:10 +03:00
Fringg 0225fa155b fix: remove future annotations breaking UploadFile, harden media URL generation 2026-03-23 15:10:47 +03:00
Fringg fd410096ea fix: respect X-Forwarded-Proto in media URL generation to prevent mixed content 2026-03-23 15:04:55 +03:00
Fringg b5853ec3b6 feat: enforce single featured news article — unfeature others on toggle/create/update 2026-03-23 15:00:20 +03:00
Fringg 2f19c76357 fix: add user ID to payment descriptions for all providers and fix tuple bug
- Add telegram_user_id and user_db_id to get_balance_payment_description() across
  8 cabinet balance providers (heleket, mulenpay, pal24, wata, cloudpayments,
  freekassa, kassa_ai, riopay), all miniapp endpoints, and recurrent payments
- For email/OAuth users without telegram_id, fallback to DB ID with (U{id}) format
- Fix pre-existing tuple bug in bot_configuration.py (trailing comma created tuple)
- Fix typo in nalogo_queue_service.py log message ("Чек уже попыток")
2026-03-23 14:48:12 +03:00
Fringg 6658af6268 refactor: extract phantom service, replace lightweight merge with execute_merge
- Extract _claim_phantom_user and _merge_phantom_into_active_user from
  start.py into app/services/phantom_service.py
- Replace lightweight 3-4 table merge with full execute_merge (30+ tables)
- Add durable AdminAuditLog records for phantom claims and merges
- Use begin_nested() savepoints for audit log writes (session-safe)
- Move Remnawave panel sync to after commit (no HTTP inside locked txn)
- Fix remnawave_uuid transfer in account_merge_service with two-flush
  pattern (clear→flush→assign) to prevent unique constraint violations
- Add db.refresh after rollback in Path A to prevent stale object access
2026-03-23 14:26:36 +03:00
Fringg fad77f8c80 fix: phantom user merge on claim failure, referral assignment, account merge hardening
- Fix orphaned subscriptions/GuestPurchase when phantom claim fails with
  IntegrityError — now merges phantom into existing user across all 3 call sites
- Add explicit db.commit() after merge in both active-user and registration paths
- Fix remnawave_uuid transfer ordering (clear→flush→assign) to prevent unique
  constraint violation during flush
- Clear phantom.referral_code on soft-delete to prevent unique constraint issues
- Add status != DELETED filter to find_phantom_user_by_username (defense in depth)
- Add WARNING-level logging on phantom claims for admin audit trail
- Add functional index on lower(username) for phantom lookup performance (migration 0048)
- Add ON DELETE CASCADE to subscription_servers.subscription_id (migration 0047)
- Add admin endpoint POST /users/{id}/assign-referrer with recursive CTE cycle
  detection, self-enrichment prevention, and audit logging
- Harden account_merge_service: add SubscriptionServer, RioPayPayment,
  SeverPayPayment, SavedPaymentMethod, GuestPurchase, NewsArticle handling
- Fix logger key typo get= → error= in promocode activation
2026-03-23 13:59:38 +03:00
Fringg 172924df0e fix: catch DecompressionBombError, hoist MP4 brands to module level
- Add PIL.Image.DecompressionBombError to except clause (inherits from
  Exception, not ValueError/OSError — was escaping as unhandled 500)
- Move _MP4_VIDEO_BRANDS to module-level frozenset for consistency
2026-03-23 13:05:11 +03:00
Fringg 7ff73e8492 fix: reject HEIC as MP4, close UploadFile, narrow exception handling
- Add ftyp brand allowlist to reject HEIC/HEIF files misclassified as MP4
- Close UploadFile after read to release resources during processing
- Add exception chaining (from None) on HTTPException raises
- Narrow except to ValueError/OSError (let programming errors propagate)
- Add exc_info=True to thumbnail failure log for debuggability
- Type detect_file_type return as tuple[MediaType, str]
2026-03-23 12:42:21 +03:00
Fringg ce554cb2a8 fix: add Literal type to SavedMedia and close orphaned PIL Image objects
- SavedMedia.media_type now uses Literal['image', 'video'] matching Pydantic schema
- Explicitly close old Image objects after exif_transpose and convert('RGB')
2026-03-23 12:31:34 +03:00
Fringg 165d25ef5f fix: media upload security hardening from 6-agent review
- PIL Image resource leak: wrap in try/finally with img.close()
- Grayscale images: normalize all to RGB for consistent JPEG output
- exif_transpose: defensive None guard
- Thumbnail: explicit close after save
- media_type: use Literal['image', 'video'] in schema
- ensure_upload_dirs: run in asyncio.to_thread (not sync in event loop)
- _SAFE_FILENAME_RE: remove thumb_ prefix (prevent orphaned files)
2026-03-23 12:05:37 +03:00
Fringg 5ed3780f83 fix: create uploads subdirectories in Dockerfile for correct permissions 2026-03-23 12:02:05 +03:00
Fringg a0d40ad432 feat: add media upload/delete API for news articles
Local filesystem storage with Docker volume mount, magic byte validation,
PIL image resize/thumbnail generation, atomic writes, path traversal guards.
2026-03-23 11:58:07 +03:00
Fringg 3e69efe589 fix: replace asyncio.gather with sequential queries on shared session
AsyncSession does not support concurrent operations on the same
connection. Running gather caused InvalidRequestError on news list.
2026-03-23 11:34:25 +03:00
Fringg 015c2da297 fix: simplify 0046 migration downgrade to just drop_table
drop_table automatically removes all indexes, fixing downgrade failure
when indexes were added after initial migration was applied
2026-03-23 11:29:31 +03:00
Fringg 2b91808b0c fix: news module security hardening, perf optimizations, bug fixes
- Server-side HTML sanitization for article content
- URL scheme validation for featured_image_url (http/https only)
- Slug sanitization on create/update
- MissingGreenlet fix in delete (capture attrs before commit)
- Missing rollback after IntegrityError in CRUD
- nullslast() for published_at ordering
- asyncio.gather for parallel DB queries
- Removed selectinload(author) from list queries
- increment_views with RETURNING (no extra SELECT)
- Migration-model index alignment
- Pre-compiled regex, structlog.exception pattern
- View counter dedup cache (5min TTL)
2026-03-23 11:09:45 +03:00
Fringg b93240393f feat: add news articles module with admin CRUD and public API
- NewsArticle model with composite index, Alembic migration
- Admin routes: list, create, update, delete, toggle publish/featured
- Public routes: paginated list with category filter, article detail with view counter
- Pydantic schemas with strict hex color validation, slug auto-generation
- IntegrityError handling for slug race conditions
2026-03-23 10:51:12 +03:00
Fringg 89341baa62 fix: restore connected_squads and admin notification on daily subscription resume
When a daily subscription is resumed after user deletion from RemnaWave panel
and deactivation sync, connected_squads were cleared but never restored,
causing internal squads to not be assigned. Also, admin notifications were
missing from the Telegram bot handler path.

Fixed across all 5 resume code paths:
- cabinet /pause endpoint
- miniapp /subscription/daily/toggle-pause endpoint
- bot handle_toggle_daily_subscription_pause handler
- DailySubscriptionService._process_single_charge
- try_resume_disabled_daily_after_topup auto-resume

Changes in each path:
- Restore connected_squads from tariff.allowed_squads (fallback: all available servers)
- Branch create/update based on remnawave_uuid presence
- Follow-up PATCH after POST to ensure internal squads are assigned
- Use limit=10000 in get_all_server_squads to avoid silent truncation
- Separate try/except for squad restore vs RemnaWave sync for resilience
- Add admin notification in bot handler (was missing)
2026-03-23 09:31:34 +03:00
Fringg cbe630cab0 refactor: simplify referral invite text to single template
Replace 7 fragmented localization keys with one REFERRAL_INVITE_TEXT
template. Remove Share button (switch_inline_query). Wrap invite text
in blockquote+code for visual quote style with tap-to-copy. Update
instruction text in all 5 locales (ru, en, ua, fa, zh).
2026-03-23 08:25:19 +03:00
Fringg 9de34900a2 fix: comprehensive html.escape() for all user/admin data in Telegram HTML messages
Bot uses default HTML parse mode — all messages are HTML-parsed by Telegram.
Added html.escape() to all user-controlled and admin-controlled strings
before interpolation into HTML messages to prevent injection and parse errors.

49 files, ~250+ injection points fixed:
- user.full_name, first_name across all handlers and services
- tariff.name/description in purchase flow, admin panel, auto-purchase service
- campaign.name, start_parameter in admin and user-facing handlers
- group.name, promo_group.name across promo management
- contest.title, prize_text, leaderboard names (including public channels)
- transaction.description (contains raw user.full_name from referral service)
- restriction_reason across all balance and subscription handlers
- ticket.title, message_text, poll.title, poll.description
- welcome text template placeholders (first_name, username)
- maintenance reason, admin_name, selected_prize.display_name

New helpers in app/utils/formatting.py:
- safe_html_name() for escaping display names
- user_html_link() replacing 15+ duplicated inline link patterns
2026-03-23 08:06:15 +03:00
Fringg aec04f0085 fix: correctly price unlimited traffic (0 GB) in classic subscription mode
_calculate_traffic_price treated unlimited traffic as free because
base_gb=0 triggered the `if base_gb > 0 else 0` guard, skipping
the price lookup. Added early return for total_gb==0 to use the
configured unlimited tier price.
2026-03-23 06:42:57 +03:00
Fringg 0fe3c217f7 fix: suppress harmless TelegramBadRequest errors and fix discount promo display
- Reorder middleware: LoggingMiddleware now outermost, GlobalErrorMiddleware inside — prevents full traceback logging for suppressed errors (message not modified, query too old, bot blocked)
- Fix root cause in message_patch.py: _edit_with_photo missing try/except for _original_edit_text when ENABLE_LOGO_MODE=False
- Add "message is not modified" suppression in AuthMiddleware to prevent unnecessary db.rollback() and ERROR-level logging
- Fix discount promo display in admin notifications: subscription_days shown as hours (not days), balance_bonus_kopeks shown as percentage (not price)
2026-03-23 06:16:28 +03:00
Fringg 958ec489a2 fix: respect per-channel disable_on_leave settings in monitoring service
The background monitoring service was deactivating trial subscriptions
when users unsubscribed from channels, ignoring per-channel
disable_trial_on_leave and disable_paid_on_leave settings that the
real-time handler and middleware already respected.

Changes:
- Use shared should_disable_subscription() for all 3 deactivation paths
- Add global CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE override in should_disable_subscription
- Add admin skip in monitoring (consistent with handler/middleware)
- Replace inline reactivation with reactivate_subscription() CRUD
- Switch to enable_remnawave_user() instead of heavy update_remnawave_user()
- Add commit=False to deactivate/reactivate/record/clear_notification for batch atomicity
- Include paid subs in monitoring when any channel has disable_paid_on_leave=True
- Use skip_deactivation flag instead of early return to preserve reactivation path
- Commit batch before create_remnawave_user which internally commits
2026-03-23 05:54:26 +03:00
Egor df9985802b Merge pull request #2801 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.41.0
2026-03-22 10:56:39 +03:00
github-actions[bot] c66849db10 chore(main): release 3.41.0 2026-03-22 07:56:12 +00:00
Egor 8a1da85f3e Merge pull request #2800 from BEDOLAGA-DEV/dev
Dev
2026-03-22 10:55:46 +03:00
Fringg 0335f40b47 chore: ruff format rbac_bootstrap_service.py 2026-03-22 10:51:26 +03:00
Fringg 8ac1183670 chore: ruff format admin_referral_network.py 2026-03-22 10:47:01 +03:00
Fringg bcc761f9d3 fix: add missing total_subscription_revenue_kopeks in scoped graph early return
Prevents Pydantic ValidationError (500) when scoped_user_ids is empty
but campaign_ids is present.
2026-03-22 10:46:08 +03:00
Fringg 1eb4e18c17 fix: add abs() to all remaining subscription payment sum queries
Apply func.abs() consistently to all 5 remaining locations that sum
SUBSCRIPTION_PAYMENT amounts: branch revenue, campaign stats,
user detail branch revenue, campaign detail, and search results.
2026-03-22 10:39:20 +03:00
Fringg 056c13bc23 fix: use abs() for subscription payment amounts in referral network
SUBSCRIPTION_PAYMENT transactions are stored as negative values,
causing negative totals in stats panel and user detail card.
2026-03-22 10:36:13 +03:00
Fringg 2bdb7643f8 feat: add total subscription revenue to referral network stats
Expose total_subscription_revenue_kopeks in NetworkGraphResponse,
computed from the existing personal_spent data (sum of all
SUBSCRIPTION_PAYMENT transactions by scoped users).
2026-03-22 10:31:18 +03:00
Fringg 8b8f1b91f3 refactor: extract _compute_subscription_status shared helper
Eliminates duplicated status mapping logic between _fetch_subscription_info
and get_network_user_detail. Single source of truth for mapping
subscription fields to frontend status labels.
2026-03-22 10:08:50 +03:00
Fringg 5ed2f0c958 fix: treat expired and limited subscription statuses as inactive in referral network graph
Previously only disabled and pending statuses were forced to show as
expired in the network graph. Subscriptions with status='expired' or
status='limited' but with end_date > now would incorrectly display as
active. Now all four non-active statuses are treated as expired.
2026-03-22 09:54:59 +03:00
Fringg 454dc9321b fix: consider subscription status field in network graph
The subscription_status computation now checks the Subscription.status
field. Disabled and pending subscriptions are treated as expired
regardless of end_date, preventing incorrect "active" display.
Also added SubscriptionStatus import.
2026-03-22 09:51:59 +03:00
Fringg de91d3282f feat: add subscription status to referral network graph nodes
Add subscription_status field (trial_active, paid_active, trial_expired,
paid_expired) to NetworkUserNode and NetworkUserDetail schemas. Backend
computes status from Subscription.is_trial and end_date using window
function to pick latest subscription per user.
2026-03-22 09:07:29 +03:00
Fringg e0bedc8e78 fix: superadmin role managed exclusively via env config
Superadmin (level 999) assignments are now the sole domain of
ADMIN_IDS/ADMIN_EMAILS environment variables. On startup, bootstrap
reactivates env-listed users and revokes superadmin from users removed
from env. API assign/revoke endpoints return 403 for superadmin-level
roles. _ensure_role_by_email now requires email_verified (symmetric
with revocation check).
2026-03-22 08:41:36 +03:00
Egor 4a48818bc3 Merge pull request #2798 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.40.0
2026-03-22 07:36:14 +03:00
github-actions[bot] ff7388766c chore(main): release 3.40.0 2026-03-22 04:35:51 +00:00
Egor 6b36dc4df1 Merge pull request #2797 from BEDOLAGA-DEV/dev
Dev
2026-03-22 07:35:24 +03:00
Fringg fe847e35f0 chore: ruff format oauth, auth schemas, webhook service 2026-03-22 07:32:04 +03:00
Fringg 4c2cb63cf9 fix: accept stale Telegram initData to prevent MiniApp auth failures
Telegram Desktop/iOS cache initData with stale auth_date (tdesktop#28303).
Increase max_age_seconds from 24h to 30 days for all cabinet login and
account linking endpoints. HMAC signature still validates authenticity,
JWT tokens handle session expiration. Add structured logging for stale
initData acceptance monitoring.
2026-03-22 07:24:27 +03:00
Fringg d3c994083e fix: daily subscription pause not persisting in cabinet and miniapp
lock_user_for_pricing with populate_existing=True was overwriting the
pending is_daily_paused mutation before db.commit(), silently discarding
the pause toggle. Fix moves lock before state reads, uses commit=False
for subtract_user_balance and create_transaction to ensure single atomic
commit, and re-applies is_daily_paused after any populate_existing reload.
2026-03-22 06:54:39 +03:00
Fringg cce3b0c13b feat: allow inactive tariffs for trial subscription activation
Inactive tariffs with is_trial_available=True can now be used for trial
activation across bot, miniapp, and cabinet. This enables dedicated trial
tariffs with custom limits (traffic, devices, servers) without exposing
them in the regular purchase flow. Paid trial paths now properly resolve
trial tariff parameters instead of using global settings defaults.
2026-03-22 06:01:34 +03:00
Fringg 6c208581d9 fix: sanitize email dots in RemnaWave username generation
Email addresses with dots (e.g., john.doe@gmail.com) caused RemnaWave
API validation failure. Now sanitizes email prefix early in the
identifier construction, not just in the final result. Also sanitizes
the fallback username path for defense in depth.
2026-03-22 04:59:30 +03:00
Fringg c307278231 fix: prevent MESSAGE_TOO_LONG in promo groups list
With 20+ promo groups, the full details per group exceeded Telegram's
4096 char limit. Simplified list to one compact line per group with
name and member count. Full details remain in the group detail view.
2026-03-22 04:17:32 +03:00
Fringg 9eab802000 fix: handle spurious user.deleted webhooks — preserve active subscriptions and prevent orphaned panel users
- Smart end_date check: subscriptions with future end_date are preserved (not expired) and panel user is re-created automatically
- Recreation loop guard: in-memory 120s cooldown prevents unbounded recreate→delete→recreate cycles with stale entry eviction
- Race condition protection: guard timestamp stamped before any await point so concurrent coroutines are serialized
- Admin deletion: force_panel_delete=True ensures panel user is always removed, preventing orphaned subscriptions
2026-03-22 03:20:50 +03:00
Fringg 13ea3768b5 feat: custom broadcast buttons and fix home button to use bot menu
- Add custom buttons support: admins can add up to 10 custom buttons
  with callback_data or URL action types to broadcast messages
- CustomBroadcastButton Pydantic model with validation:
  callback_data checked in UTF-8 bytes (Telegram 64-byte limit),
  URLs restricted to https:// and tg:// schemes only
- Fix home button: removed from CABINET_MINIAPP_BUTTON_KEYS so it
  uses back_to_menu callback instead of opening cabinet WebApp
- Both legacy and combined broadcast endpoints pass custom_buttons
2026-03-22 01:54:05 +03:00
Fringg ed5a92ab96 fix: referral system — self-referral protection, race condition fix, deleted user re-registration
- Add telegram_id-based self-referral protection in all 3 Telegram auth endpoints
  (user doesn't exist yet at referral resolution, so telegram_id is used instead of user.id)
- Add SELECT FOR UPDATE + db.refresh in _process_referral_code to prevent TOCTOU race
  on concurrent referral assignment (matches _process_campaign_bonus pattern)
- Fix _process_referral_code to handle two cases: referred_by_id already set by
  create_user() → fire registration event; not set → resolve code, set, fire event
- Fix deleted user re-registration losing referral: keep status=DELETED in preparation
  block so complete_registration enters the DELETED branch (not "already active")
- Remove unused referral_code from DeepLinkPollRequest (deep link = existing users only)
- Fix OIDC exception handling inconsistency (ValueError/LookupError → Exception)
- Fix bare except clauses in start.py → except Exception
- Pass is_new_user to _finalize_oauth_login (only new user path passes True)
2026-03-21 15:06:10 +03:00
Fringg 48265f1cd4 chore: remove redundant comments from DISABLED status fix 2026-03-21 09:07:43 +03:00
Fringg 3b9568fcc1 style: format long lines in monitoring and subscription services 2026-03-21 09:06:01 +03:00
Fringg 79cfcbcece fix: send DISABLED instead of EXPIRED status to RemnaWave API
RemnaWave API only accepts ACTIVE/DISABLED for user status updates —
EXPIRED and LIMITED are managed internally. The bot was sending EXPIRED
status and past expireAt dates, causing 400 validation errors.

- Change UserStatus.EXPIRED → UserStatus.DISABLED in all 5 call sites
- Add 1-minute buffer to expire_at for inactive subscriptions to avoid
  "expiration date in the past" rejections (matches _safe_expire_at_for_panel)
- Include TRIAL status in is_actually_active checks (consistent with
  remnawave_service.py sync_users_to_panel)
2026-03-21 09:00:59 +03:00
Egor 6d5aceb4ca Merge pull request #2793 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.39.0
2026-03-21 07:41:49 +03:00
github-actions[bot] 905dbcc779 chore(main): release 3.39.0 2026-03-21 04:40:13 +00:00
Egor f33dfdf031 Merge pull request #2792 from BEDOLAGA-DEV/dev
Dev
2026-03-21 07:39:51 +03:00
Egor 59cd74d307 Merge pull request #2791 from BEDOLAGA-DEV/main
w
2026-03-21 07:38:22 +03:00
Fringg 90209ebef1 feat: add NaloGO fiscal receipts for code-only gift purchases
- Create NaloGO receipt when code-only gifts (no recipient) are paid via
  any gateway provider, not just directed gifts
- Add receipt_uuid and receipt_created_at columns to guest_purchases for
  persistent DB-level dedup (covers PENDING_ACTIVATION and code-only paths
  where no Transaction exists at receipt time)
- Use SELECT ... FOR UPDATE in try_fulfill_guest_purchase to prevent
  concurrent webhook double-processing race condition
- Expand idempotency guard to include code-only gifts already in PAID status
- Add db.refresh after PENDING_ACTIVATION nalogo call to guard against
  inner rollback expiring the ORM object
2026-03-21 07:37:03 +03:00
Fringg ab43e74ab7 fix: manual admin top-ups missing from sales statistics
Cabinet API and WebAPI created admin balance transactions with
payment_method=NULL instead of 'manual', making them invisible
to sales statistics filters.

Changes:
- Add payment_method=PaymentMethod.MANUAL to Cabinet and WebAPI
  balance update endpoints
- Add func.abs() to all transaction amount aggregations missing it
  across sales stats, dashboard stats, and reporting queries
- Remove redundant Python abs() on addon_revenue (SQL func.abs
  already applied)
- Add data migration 0044 to fix historical NULL payment_method
  records for admin top-ups
2026-03-21 07:01:22 +03:00
Fringg 4244962337 fix: add NaloGO fiscal receipt creation for landing page purchases
Landing page (guest) payments were completely skipping nalogo receipt
generation because the guest purchase flow returned early in payment
webhook handlers before reaching the nalogo code.

Added _create_nalogo_receipt_for_purchase() helper with:
- payment_id null-check (Redis dedup requires it)
- amount validation (skip zero/negative)
- transaction.receipt_uuid duplicate guard
- inner try/except with db.rollback() for receipt_uuid persistence
- sanitize_proxy_error for credential-safe error logging
- privacy: no telegram_user_id in receipt description sent to tax authority

Called in both DELIVERED and PENDING_ACTIVATION paths.
Added db.refresh(purchase) after nalogo call to handle potential
session expiry from rollback inside the helper.
2026-03-21 06:36:21 +03:00
Fringg ba79d03e38 fix: skip non-JSON payload rows in cryptobot payment index and query
payload column in cryptobot_payments contains plain strings like
"balance_2_10000" alongside JSON objects. CAST(payload AS json) fails
on these rows during CREATE INDEX CONCURRENTLY.

- Add AND payload LIKE '{%' to partial index WHERE clause in migration 0042
- Add .payload.like('{%') filter to guest_purchase_service query
2026-03-21 05:43:22 +03:00
Egor 8e4e2ddd1a Update README.md 2026-03-21 05:09:00 +03:00
Egor 38853cdd5a Merge pull request #2790 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.38.0
2026-03-21 04:32:38 +03:00
github-actions[bot] f837c0c244 chore(main): release 3.38.0 2026-03-21 01:32:11 +00:00
Egor 8a7b9cc651 Merge pull request #2789 from BEDOLAGA-DEV/dev
Dev
2026-03-21 04:31:51 +03:00
Fringg 3bf31055e7 fix: sanitize proxy credentials in all nalogo error paths
- Apply sanitize_proxy_error() to all 8 error handlers in nalogo_service
- Remove exc_info=True from error paths that could expose proxy creds
- Fix regex backreference to preserve original SOCKS scheme
- Consolidate proxy utility imports to module level
- Add source indicator (NALOGO_PROXY_URL vs fallback) to startup log
2026-03-21 04:27:15 +03:00
Fringg 3c5bf4fa22 feat: add SOCKS proxy support for nalogo (tax service) module
Route all nalog.ru API traffic through SOCKS proxy. Uses NALOGO_PROXY_URL
env var (falls back to PROXY_URL if not set). Adds httpx[socks] dependency.

- Thread proxy_url through Client → AuthProviderImpl + AsyncHTTPClient
- Extract mask_proxy_url() and sanitize_proxy_error() utilities
- Add socks5h:// scheme support for remote DNS resolution
- Sanitize proxy credentials in error messages
- Log masked proxy URL at startup and service init
2026-03-21 04:21:11 +03:00
Fringg 4990ddf9e4 fix: add diagnostic payload logging in create_user error path
Consistent with update_user — log full payload before re-raising
non-A039 errors to aid debugging.
2026-03-21 04:08:18 +03:00
Fringg de00612965 fix: retry Remnawave API calls without externalSquadUuid on A039 FK violation
When a tariff has a stale external_squad_uuid that no longer exists in
the Remnawave panel, PATCH/POST /api/users fails with A039 (P2003 FK
constraint violation). This caused subscriptions to not sync with the
panel even though balance was already charged.

Now both update_user() and create_user() catch A039 errors and
automatically retry without externalSquadUuid, logging a warning about
the stale UUID. The subscription sync succeeds without the external
squad assignment rather than failing entirely.
2026-03-21 03:58:21 +03:00
244 changed files with 27474 additions and 13466 deletions
+2 -1
View File
@@ -522,6 +522,7 @@ NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токен
NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды)
NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды)
NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека
# NALOGO_PROXY_URL=socks5://127.0.0.1:1080 # SOCKS прокси для nalog.ru (если не задан — используется PROXY_URL)
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
@@ -613,7 +614,7 @@ PLATEGA_RETURN_URL=
PLATEGA_FAILED_URL=
PLATEGA_CURRENCY=RUB
# Список ID активных методов из кабинета Platega (через запятую)
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
PLATEGA_ACTIVE_METHODS=2,11,12,13
PLATEGA_MIN_AMOUNT_KOPEKS=100
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
PLATEGA_WEBHOOK_PATH=/platega-webhook
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.37.0"
".": "3.46.0"
}
+355
View File
@@ -1,5 +1,360 @@
# Changelog
## [3.46.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.2...v3.46.0) (2026-04-13)
### New Features
* add broadcast category (system/news/promo) + filter recipients by user prefs ([931abfe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/931abfe7a5a7fb70e9638fbf6b566fa8d1a837e4))
* add category field to broadcast API schemas and routes ([0300044](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0300044b009f3e4b3aa3928652dfaf261a387dbc))
* add RemnaWave retry queue for failed API calls (BUG-2, BUG-10) ([abdf296](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/abdf2967675975e90f0c4d834f129281c1c28e7b))
* add remnawave_resync_service for identity-change sync ([b57f185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b57f185258be050d945cec5989ad6dc710980a6a))
* add traffic % warning check using user's threshold preference ([1d96f80](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d96f80f60ca445eb5108e7bc54e00d022a4cc9e))
* add user notification preferences helper utility ([e0e2edf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0e2edf81659fbeea361046d1bb2718c2149d884))
* implement low balance alert + respect user notification preferences ([4e50419](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e50419171176ee452371ff095bdfead3879e554))
* respect user subscription_expiry notification preferences ([63fdfe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/63fdfe4a421942b26caca35d8bd9b1d65f1fe7e2))
* respect user traffic_warning notification preference in webhook handler ([7208a52](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7208a52c9424d39757187fc86eec2c3460a2cdbb))
* save campaign_slug during standalone email registration ([a8e2b62](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a8e2b62f4bb0833ca32446b34ad4e0c5615fcd2a))
* start RemnaWave retry queue on app startup ([8f1882f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f1882f24c7d066e2d0fc756f38ce23bf082687e))
### Bug Fixes
* add retry queue to all remaining RemnaWave error handlers ([7e920fa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7e920fa30fc8e61ed2dd31e7a09151d57b7361ca))
* add retry queue to cabinet subscription operation RemnaWave errors ([1b376ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b376baeca120970b1ffcd406b1bbf7d9d43cee0))
* add retry queue to classic mode bot purchase handler ([970dc54](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/970dc549dfa06ba9945d5bd861374058acdca86b))
* add retry queue to daily subscription service RemnaWave errors ([65120f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/65120f0badc9a4581ba0a127acdae8a0b23e8501))
* add retry queue to payment webhook and renewal service RemnaWave errors ([91a756a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91a756a33ed4ce685bdf485cdb4e91c3e08799dd))
* add TRAFFIC_WARNING_ALERT and LOW_BALANCE_ALERT localization keys to all locales ([2321667](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2321667ecbe76bfe8dd37213e0bb4e45104e0fc5))
* always sync squads in auto-purchase renewal (BUG-4) ([8542a39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8542a393055a93d320d5c8c6d3aa7cc291cf8def))
* default sync_squads=True in update_remnawave_user (BUG-4) ([6aed7d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6aed7d355bc47c4dbd2aa761d78a5e5421c32edf))
* enforce max_attempts limit in NaloGO receipt queue ([16d9163](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/16d91638bc149c5eee8f4cdd266cbd195c411030))
* enqueue retry on RemnaWave API failure in all purchase flows (BUG-2, BUG-10) ([9cb559f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9cb559ff3994c0f1c6ba48a4ec09dec9b391e48b))
* exclude users with active subscriptions from expired broadcast ([1eeeb39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1eeeb39779982ebb2700cb7523760631229407da))
* handle TelegramBadRequest when deleting old ticket notifications ([eb18b3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18b3a0f9ac3a617a1b21d3293e1619980a2a71))
* match tariff_id when creating subscriptions from panel sync (BUG-11) ([646ac4c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/646ac4cfa18f738040fbc6498c5d86c1546e2b9a))
* protect OAuth users with remnawave_uuid from sync deactivation (BUG-6) ([cf19e4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf19e4e1f7148b21d9a8072f7a0b4ae97fd04e8a))
* raise MAX_BUTTONS_PER_ROW to 8 and allow tg:// deep links in menu editor ([570af82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/570af82dfdec980f42be96c8f816e1677f896f81))
* resync RemnaWave after account merge (BUG-7) ([9c08ce6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9c08ce69485b78f8fe818500e05ab6995115166a))
* resync RemnaWave after Telegram account linking (BUG-1) ([d465ccb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d465ccb3ac3a86add6313d6bb61d53d0fe143d5e))
* sync connected_squads from panel during sync (BUG-5) ([35412e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/35412e9f215680c0fdf1c55b5bf23496f662935c))
* trial activation fallback to trial-eligible servers when tariff has no squads (BUG-12) + fix misleading button text ([be32010](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be32010d63966498bc6216823a75d328346d9a37))
* upsert refresh tokens (ON CONFLICT) + periodic cleanup of expired/revoked tokens ([fb8d2b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fb8d2b3ee4566823840100b96fe2f3bc7d41edb7))
* use 'is not None' for telegram_id in create_user API (BUG-9) ([8623521](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/862352139e8a3545e144b0618fed5011470a9a67))
* use MAX_DEVICES_LIMIT instead of hardcoded 10 for device buttons ([bc3893b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc3893b934f0d4e5059eedbd03cdfbc628852ac1))
* use update_remnawave_user when UUID exists in tariff_purchase (BUG-3) ([a1b6d9b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1b6d9bb619ec3de038647fe6f2e5d38979298a8))
## [3.45.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.1...v3.45.2) (2026-04-08)
### Bug Fixes
* batch bug fixes from user complaints ([31adcfd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31adcfded4b161bf515d4d6b25b4395e543208f4))
* batch bug fixes from user complaints ([78f963b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/78f963bf5e7b3439d7614584c4041f88be5beb4a))
* исправление парсинга черного списка (поддержка '#' и извлечение username) ([357d94d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/357d94d1b0d7fc8036b00cbb6b75175c29821751))
* исправление парсинга черного списка (поддержка '#' и извлечение username) ([2f71846](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f7184627a0fb598a8c0208905cdecf0e4bb04a7))
## [3.45.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.0...v3.45.1) (2026-04-03)
### Bug Fixes
* add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before uniq… ([4165eae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4165eaea7adfdaf683b1ece16c8c93a9c4ed216d))
* add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before unique index in migration 0053 ([3b5d5a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3b5d5a18a1122ef50868fd038a09109d17795a74))
## [3.45.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.44.0...v3.45.0) (2026-04-03)
### New Features
* send torrent blocker notification to user (not just admin) ([2f9d003](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f9d00343bee2980cc89bd24361259073b97127a))
### Bug Fixes
* resolve multiple subscription bugs — LIMITED status, trial tariff blocking, traffic reset strategy, classic mode pricing, 100% discount support ([9b7ac47](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9b7ac47f16076e546da62062ff7ce18d7c308988))
* restore missing import + rewrite user.deleted webhook to properly deactivate all subscriptions ([819f09a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/819f09a68ec95237294bae97f31c644044a3623f))
* subscription system bugfixes + torrent notifications + user deletion cleanup ([7d24e8d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d24e8d7047c7a3a1c417e655a6fbccbe5ae577d))
## [3.44.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.43.1...v3.44.0) (2026-04-02)
### New Features
* add SberPay as KassaAI sub-method (payment_system_id=43) ([9d63635](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d636355026ad1e50d045e78ffa21e76cfef0774))
### Bug Fixes
* address review issues in PR [#2829](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2829) webhook intentional deletion guard ([977950b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/977950b97f07eecf089152d3f4e678fda373e1e6))
* autopay failure notifications ignoring 6h cooldown ([991f0b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/991f0b43e1e73446690a4fbec7c5c5642ac8c406))
* middleware disables panel VPN for all subs ignoring per-channel settings ([f284351](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f284351c51a6843db0771a92338ec770d5f0d8d2))
* NameError in SeverPay guest payment flow ([2d42152](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2d42152f5491b14cc45388e0ffccf8a61848a2f6))
* notification sent for non-deactivated subs + webhook race condition ([b04157c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b04157c91327d9031e9f603a6ad33c708e27d753))
* Pal24 card/sbp option not passed to API in cabinet balance topup ([6713921](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67139218878dca3e75974eb5b5a2ce91d5b1438e))
* prevent nested state saves and None state loss in promo handler ([b607993](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b607993854d1374e7d7c2afbb7fe5cc8824732f5))
* promo code activation destroys balance input FSM state ([2466590](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/246659032de812f1d4502029ab139f3104237d5c))
* remove non-existent Platega method code 10, rename 11 to Карты (RUB) ([033d0da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/033d0da5e0033a2310431586a291b529e3ccb89a))
* send telegram_id@telegram.org as email to Kassa AI ([3dc72b0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3dc72b00e751a69966d2d5830492c82e055b72e6))
* send telegram_id@telegram.org as email to SeverPay ([08ca947](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/08ca947b2b2bb29782c86e7b5d6bea71e2811751))
## [3.43.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.43.0...v3.43.1) (2026-03-31)
### Bug Fixes
* prevent MissingGreenlet on subscription.tariff lazy load in webhook handlers ([72170b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/72170b35f5d2af56aa7dcb579a70ecf6af2da3f6))
* use subscription-level remnawave_uuid in multi-tariff mode for sync and detail pages ([0c284b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c284b9e9941b516170fc68a3f63551096cb5a7b))
### Documentation
* add Platega partnership to README, highlight partner payment providers ([312cc72](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/312cc728a9321fe9ac90cf1f5201e38465f67f16))
## [3.43.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.42.0...v3.43.0) (2026-03-29)
### New Features
* add Remnawave panel 2.7.0 API support ([565c083](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/565c08366bdc1d3dcaba89a8522360e1e4d8c2d8))
* add SeverPay support to cabinet balance top-up ([092b9f6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/092b9f63b24e8129a8e6f9ac0040f19a35514295))
* add subscription_id to admin sync endpoints for multi-tariff ([54a19a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/54a19a9c50d58affd1cd8bc897e360318744e28d))
* add tariff identification to all notifications for multi-tariff mode ([7dd67e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7dd67e36b3a8a408239ec53d0ae2cc230dfd726c))
* add tariff_id to promo codes for trial subscription type ([63e4296](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/63e4296197d7943914267f503782e46befc151d4))
* **api:** expose email field in UserResponse ([23d1830](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/23d1830644be6ab71da629465604cbb296fa7c02))
* DELETE /subscriptions/:id for expired/disabled subscriptions ([c27f144](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c27f144b76ad392c5d6909f8f62cb2b065515eed))
* expose MULTI_TARIFF_ENABLED and MAX_ACTIVE_SUBSCRIPTIONS in admin settings ([2628012](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2628012097893ec93cfcb2b8fe190183cfe53a3b))
* expose per-inbound traffic breakdown in nodes realtime API ([5d173c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5d173c806af1e3c31854ae92b194016984cfd80e))
* include countryEmoji and providerName in realtime metrics ([b59c581](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b59c581e916e4f6511514463e149a03cc4fb6f8f))
* multi-subscription support ([1099c52](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1099c5224c07b4ddc17397b0200f64f22fbf8520))
* multi-subscription support (1 user = N subscriptions) ([335be66](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/335be66980bfefae9d39e8f549fb4df1780ce3d0))
* return is_daily and is_daily_paused in subscription list API ([4dd8170](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4dd81702ceb5ee9ff46af16249d0dd145da2e3b6))
* support email/OAuth users in referral editing and add remove endpoints ([7f60196](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7f60196033fa48f093edd06da8f2162936c76162))
* trial lifecycle + purchase-options filter for multi-tariff ([048d208](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/048d208bc1aa188c16fc4e01de836e9f1553b561))
* wheel subscription picker for multi-tariff mode ([24edfb6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/24edfb6c3f83726aff0a7b4566da5762ceb10d72))
### Bug Fixes
* accept subscription_id from query param in renew endpoint (consistent with other endpoints) ([824d54b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/824d54b7dc97a7a4df8a7f34e078e3ff018df442))
* account linking broken in multi-tariff mode (MULTI_TARIFF_ENABLED=true) ([2c12a47](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2c12a4773c8b0614a06acaf5bcef2784cc211a5a))
* account merge no longer nulls transferred subscriptions' remnawave_uuid ([b0273dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b0273dc8aeecbbdd3a72c41c885492026f0aea58))
* add missing ADMIN_PAYMENTS localization keys for ru and en ([4f76f53](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4f76f53d55588387251b80de6ac878589299817e))
* add period_days validation and zero-price guard to tariff purchase ([cefdfc5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cefdfc54cc8baea3b4e36740d6913b74a8072b6a))
* add redirect_slashes=False to prevent HTTP 307 redirects on subscription endpoints ([f7f8ea8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7f8ea87cfd5b182e7e395a9aa95c37bd40e165c))
* add selectinload for GuestPurchase.user/tariff in gift activation ([84357a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/84357a1e8715cb176191eace7d019544175822f4))
* add tariff identification to remaining notification gaps ([cddb8d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cddb8d63326e2849d674de64b2da508103d5922f))
* add_traffic handler passes FSM state to resolve_subscription for multi-tariff context ([684f286](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/684f286fcd9d2c29b1c02d5fc7c9ebf5631f4878))
* address remaining review issues in device limit patch ([9726145](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/972614511fb10d2cad91e0b8a52cac4c634fc6d7))
* address review issues in device limit patch ([34aec03](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/34aec0323bd78ff159c9e84bdcf584051b3b0fd4))
* admin handlers use _resolve_admin_subscription + per-subscription UUID ([147ef6b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/147ef6b22b9518fc806939aba435f81121828641))
* admin panel per-subscription UUID in multi-tariff mode ([56fffc2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/56fffc241572ec1d59370bfdb01945877e34e5a0))
* admin server/devices/traffic buttons pass subscription_id in multi-tariff ([9a27e6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9a27e6db3110e171a9cac02728a21cd4a002e9b1))
* admin tariff purchase now creates separate RemnaWave user per tariff ([95ba739](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/95ba73995820ac5c154226a370979437809d3f8c))
* assign promo group from tariff on guest purchase ([da11ec6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da11ec6f946e6a439111c31c6e5387dab4167303))
* async tariff loading in promocode serialization ([3bec662](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3bec6620b67f0dfe7926a030ee98ef0f08e5673e))
* auto-purchase processes each autopay subscription independently ([f89e326](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f89e326a19f37ec2189fe01814cd3a4cf69c4d02))
* back button in subscriptions list uses correct callback ([382e29d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/382e29d3dd20cca8f231e444181703bf72c45c86))
* back buttons in devices/traffic return to subscription detail in multi-tariff ([319941d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/319941d33a43552c09ba5fb95e1a165dd1603999))
* block classic subscription renewal/autopay when tariff mode enabled ([cd6913c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cd6913cb849ccefe2b36de786068890c8117b1d8))
* block legacy subscription renewal bypass in tariff mode ([78209c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/78209c862320ef030134ac7e77c57229b5701cfd))
* cabinet admin create subscription now creates new RemnaWave user in multi-tariff mode ([31c67d1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31c67d15657d88df24ba490e0d47a9c3104883af))
* cabinet purchase_tariff — handle IntegrityError with compensating refund ([1bc2581](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bc2581669ecc3b47b12283c7fb6988bbe34cb28))
* cabinet routes use smart subscription fallback + per-subscription UUID ([f83ff26](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f83ff26332c8057f5cbb1725f1d5db74e23af9bf))
* centralize trial cleanup in CRUD + shared subscription resolver for bot ([355fef8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/355fef846e786b638fdcf8823fc077ace89e8615))
* comprehensive multi-subscription audit fixes across routes, handlers, and services ([d071269](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d071269b8c4df08f91b3a94e09d3a37686082f8a))
* comprehensive tariff switch/extend/back button fixes for multi-tariff ([e42bddb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e42bddb868414aec8df55c071adab63a88b30696))
* contest prize applies to best non-daily subscription in multi-tariff ([d2bbeb8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d2bbeb8624ca1320b38a300de0417f64747a6286))
* daily tariff switch uses _resolve_subscription instead of searching by new tariff_id ([4e12ab3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e12ab3458c1c73ed4ffe62ca8688ff917a64e4c))
* delete subscription from RemnaWave panel + prevent phantom webhook notifications ([a12ffb1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a12ffb1d6c30bc2d73888f5c352c12fc3d0fd95c))
* device limit decrease, HWID pagination, tariff max enforcement ([931eeb3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/931eeb35689532a892e14bc76a05fea1a8f2cec3))
* devices button shows menu with buy + manage options in multi-tariff ([f925efb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f925efbfb47de8dc8e5932eab629ee6beb7c61e2))
* disable redirect_slashes globally to prevent HTTP 307 on subscription endpoints ([06feb3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06feb3fff589616b6cf78a2cdf6ba5c33cca7794))
* distinguish cabinet gift notifications from landing page ([48eaa6b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/48eaa6b0724a3f881d144abc723fa8373ee9a67f))
* eligibility and display use best non-daily subscription in multi-tariff ([76ba19d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/76ba19da175553597599fcbcc8513221078e01f6))
* fix MiniApp renewal options 500 error for legacy subscriptions ([aa36549](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa36549bb3bc1e04362750b7eecee23f9ba99d88))
* gift code activation and multi-tariff subscription sync ([f93c51a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f93c51a6773e706a6fc6f9b6016868e181400762))
* harden node info display against injection and type errors ([6d167d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d167d292266a660f0d6fd906db3cdea84e8c9cf))
* harden webhook signature verification across all payment providers ([8295880](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/82958801b5179ff5703e6d11b3b0b30f74581eeb))
* import Subscription in wheel_service to fix NameError ([34bb87c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/34bb87c7baf34b4d9c0c09a6b3fe2b76378d9a66))
* improve UX for legacy users migrating to tariff mode ([f6f330d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f6f330db4a2e6aa5d14a616b9fd2365577f928a1))
* load buyer relationship before gift notification, clean up recipient logic ([adb39c6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/adb39c6ef46507966482cdceed8ba03e861285ee))
* multi-subscription support for promocodes, contests, phantom merge ([6d468e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d468e9adacc8c111cdb5145dc048f7eea6e93e4))
* multi-subscription UUID resolution and ownership validation ([d87fb47](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d87fb47e886eebb3a1e75b1dae1982230f7bac72))
* multi-tariff code review — 13 critical/high bugs fixed across 14 files ([5724906](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57249065178900a8626181556119464c7d55fd74))
* multi-tariff MEDIUM/LOW batch — 20 issues across 17 files ([94ed282](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94ed282381fcc71efdef8b55c2e566b2fcd92c2c))
* multi-tariff Stage 2 critical fixes — panel sync, guest purchase, cart isolation ([4259ba1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4259ba1cb57020314f478d7efc78c915fce9e9b6))
* multi-tariff Stage 2 HIGH fixes — 18 issues across 12 files ([c6bedc6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c6bedc6a061cd6ff2010f1345d6bae42e4282fbd))
* multi-tariff Stage 3 critical fixes — panel sync UUID, admin grant, wheel ([49db5f5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49db5f5eedb452aac7b12739ce735abb8c3b99d2))
* multi-tariff Stage 3 HIGH fixes — phantom, cart, yookassa, auto-extend ([aa7e461](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa7e461c4451fc477ac9a1714c8e2243fe12ba49))
* multi-tariff Stage 4 critical fixes — keyboards, guest purchase, monitoring, tariff deletion ([a49e52c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a49e52cc92819b10d1d4afc823cf98b534a84a0f))
* multi-tariff Stage 5 fixes — auth sync, notifications, cart, race guard ([948e479](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/948e4791f49a0626100af8ada29da8b605392566))
* multi-tariff sync auto-links legacy user-level UUIDs to subscriptions ([dbe247b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dbe247ba6f8cea2ccb845ceb1cfd87d76d47dc52))
* notifications include tariff name for multi-subscription clarity ([05d1ae0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/05d1ae0560f63627c17b629d476088ce21398863))
* parse_bytes now handles IEC units (GiB, MiB, KiB) from API ([1471320](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/147132060659e828b005160003d5bff1a565e280))
* pass FSM state to _resolve_subscription across all subscription handlers ([90fb0a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90fb0a21e227d46ed5624051d8185a1e026b0806))
* pass sub_id to show_devices_page to fix NameError in multi-tariff ([59d4b35](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59d4b353a6456fc40682239df441b23bc8d63f6d))
* persist referral to Redis on /start to prevent loss when user opens miniapp ([6d9bd99](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d9bd9915cda01d819b4c3db02115777c8cb7500))
* post-payment keyboard checks all subscriptions instead of LIMIT 1 ([25b853d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25b853d629f9581a9f7871142751dcb2b1e73d3a))
* prevent sync from overwriting wrong subscription traffic in multi-tariff mode ([78a7eaf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/78a7eafcb65a175dd8f5ded304138ebd8c47acd8))
* prevent sync/from-panel cross-subscription data mismatch ([960aa44](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/960aa44b007b454f94403cedf98033c9a2c60dec))
* promocode system broken in multi-tariff mode ([3cbe09d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3cbe09ddd54bec6568b49fbd2ed961649160d21f))
* re-fetch subscription after lock_user_for_pricing to prevent selectinload reset ([71082f4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/71082f436c6a49409b84e84a367e6059abea850e))
* remnawave service uses per-subscription UUID throughout multi-tariff ([afd7b6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/afd7b6d7ec777fd380b43c77da55d3114ff837d7))
* RemnaWave sync finds user by Subscription.remnawave_uuid in multi-tariff ([c3c2b81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c3c2b8137b5b8e656b0368742e55a99c4d6a3ec3))
* remove unused imports and variables after rebase ([b6cf361](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6cf361737653f7601515126b2e2d073a8a5ca0f))
* remove user.subscription setter - use local variable instead ([2f88b07](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f88b07f05cb568c6a6f0bde9c7b7e91d4309d66))
* remove UUID fallback override in admin_tariffs + restore promo on IntegrityError ([a232d21](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a232d21edd9714082a7943f9e23db4241f188d66))
* rename refresh('subscription') to refresh('subscriptions') in all files ([e99f3d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e99f3d9a374b1ab32bbefd8be2036aa185e50f5c))
* renewal handlers use _resolve_subscription + store subscription_id in FSM ([87bf65c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/87bf65c8097d270a139402ab5e976b6215735c99))
* renewal status check, int() safety, daily charge atomicity ([58d899a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58d899aab89ff41ef86a2c578613a59720fdab31))
* renumber multi-subscription migrations to avoid conflicts with dev ([5a7b3d5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a7b3d59627f61b3afc7d23ec08d00d9c07c7f10))
* resolve MissingGreenlet error on article detail view ([004dac5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/004dac5b7ef76b2b1e98e0ab0f62d9fe7fcbd12b))
* services use smart subscription selection + per-subscription UUID ([0866c2e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0866c2ea4b3183dc63e5ea88391d83dd03f30428))
* set is_daily_paused=True when admin cancels/disables daily subscription to prevent auto-resume ([d04f2fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d04f2fc718d11a4766627fda83bdee3abf7f15ea))
* show all subscriptions in main menu for multi-tariff mode ([e39c358](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e39c358d5caf00674c8d148757b66d46b15485c2))
* show subscription picker for traffic/connect buttons with multiple subs ([a39e355](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a39e3554d8b91eac676cf4e3b4cfe6a0dd9caa82))
* suppress empty reward alerts and clean up referral notifications ([e3d8d21](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e3d8d21b662761dca1ffe5938e3ce4d72275be84))
* tariff purchase shows purchased tariffs and blocks re-buying in multi-tariff ([9644135](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9644135dd7a1c1e951274a73a56ec75fd715fe62))
* tariff_purchase next() fallbacks use None instead of active_subs[0] in multi-tariff ([72d5bae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/72d5bae531ae04a36fbc44a6803de7f17020f846))
* test access promo applies to all active subscriptions in multi-tariff ([181ef15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/181ef1501b31837585958f89631c1aa36b1872a2))
* transliterate Cyrillic slugs instead of stripping to 'untitled' ([c805cfd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c805cfd6d8335f147991458e42fa8a19121a5126))
* trial promo extends existing subscription with same tariff ([b8662b8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b8662b8bf6c341f391a25940dea928a043396f04))
* trial reset in multi-tariff only deletes trial subscriptions, keeps paid ([424fff4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/424fff4ac29e697268eeb57d5b5bacd498c691e8))
* trial subscription lifecycle — autopay, cleanup on purchase, bonus days ([344852b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/344852b85223b49fc52f1401d63b198e1a8412c5))
* use '/' instead of empty path in subscription sub-routers ([07ebc43](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/07ebc435cf2544c31e0c55bb543775ffcd252e01))
* use empty path instead of '/' for multi-tariff list endpoint to avoid 404 ([c8ecec4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c8ecec47a0af59911407d8d5a9d6df3f2afc3fb8))
* UUID check in servers/tariff_switch, start.py refresh, delegation state passing ([bd46b4c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd46b4cf6dcd7cd65a42456d741b957a22aa7863))
* UUID warnings, phantom merge, yookassa validation, contest prize notification ([fe03b58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe03b587db96eb365a718e21a0177dd6e6e6a480))
* validate_and_clean_subscription uses per-subscription UUID in multi-tariff mode, not user-level UUID ([18f31c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18f31c565c7c0217ebd2348389466c16dc17fc3f))
* web API routes use multi-subscription resolution for operations ([a1623d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1623d94b119025f59263749eaa727bc910dc467))
### Refactoring
* remove dead multi-tariff check in guest purchase activation ([34b5a9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/34b5a9ab3a0eab8561ddd6930b59b817bc2ef9b4))
* update remnawave API integration for v2.7.0 ([173cc37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/173cc374bb8391359d8f54569565f18a82fc28eb))
### Documentation
* add Stage 3+4 audit results to multi-tariff review ([6dc5879](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6dc5879ffa7edb77166291fbda9e03fbe532bc2d))
* update multi-tariff review with Stage 2 full audit results ([40d2ec6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/40d2ec67189064b41774292029a397fde2a1c863))
## [3.42.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.41.0...v3.42.0) (2026-03-23)
### New Features
* add managed news categories and tags with DB-backed CRUD ([51392d1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51392d1918d8e2e94645acfb3a11b8e16776a5d5))
* add media upload/delete API for news articles ([a0d40ad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a0d40ad432d858ebfe75485a9597d32e847d5746))
* add news articles module with admin CRUD and public API ([b932403](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b93240393f739f1243bbbfdd4298b90974b8fa87))
* enforce single featured news article — unfeature others on toggle/create/update ([b5853ec](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5853ec3b6769655f8d19914e51fd078ec9edccc))
* show Platega payment methods inline on main screen ([#2720](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2720)) ([334db53](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/334db53868ae2f9206fdde97fa575e953a83cbcf))
### Bug Fixes
* add explicit File(...) to UploadFile param to fix 422 on media upload ([89bfdc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/89bfdc8ed6bfba48cfcb61083240d5e6e870f49b))
* add Literal type to SavedMedia and close orphaned PIL Image objects ([ce554cb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ce554cb2a8e606b6060dbf63463e64087a3a7539))
* add user ID to payment descriptions for all providers and fix tuple bug ([2f19c76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f19c76357fe7de1636d65968cb13f784ee47c31))
* catch DecompressionBombError, hoist MP4 brands to module level ([172924d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/172924df0e1248b27d3f18f6acf23cf115040941))
* comprehensive html.escape() for all user/admin data in Telegram HTML messages ([9de3490](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9de34900a2a9047ab275c0bbf16fd314eb49a3ec))
* comprehensive security hardening across payment and API layers ([8175bc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8175bc8bfe56dc564a0783c45226451b695bdbd6))
* correctly price unlimited traffic (0 GB) in classic subscription mode ([aec04f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aec04f0085bd9c566bd033b8bb628389ff22bdf6))
* create uploads subdirectories in Dockerfile for correct permissions ([5ed3780](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ed3780f830023f7b1940dc96256fd38e3c86f4b))
* media upload security hardening from 6-agent review ([165d25e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/165d25ef5fc02eecf7c6d703072b6adbe88bfd98))
* news module security hardening, perf optimizations, bug fixes ([2b91808](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2b91808b0c72381cfb2f4e36eaff69e9102a32ee))
* phantom user merge on claim failure, referral assignment, account merge hardening ([fad77f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fad77f8c80a8fdecb0512f0ff91e5ae6d78ec8f3))
* register categories/tags/media routers before news to avoid route conflict ([d9cda3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9cda3a6d67c6249397e3778c1aefc6bdb6d9e4f))
* reject HEIC as MP4, close UploadFile, narrow exception handling ([7ff73e8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ff73e8492343be5ed77fad5eea7e730621eac76))
* remove future annotations breaking UploadFile, harden media URL generation ([0225fa1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0225fa155b7d4f3d1c6572a68651c206b1820db0))
* replace asyncio.gather with sequential queries on shared session ([3e69efe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3e69efe5891bbe8869a7feb2320185e14d83dd73))
* respect per-channel disable_on_leave settings in monitoring service ([958ec48](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/958ec489a2a1d01d19cb6e60f52fb2be56295104))
* respect X-Forwarded-Proto in media URL generation to prevent mixed content ([fd41009](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fd410096ea7e541d8443ec4336bfd72de63d03d9))
* restore connected_squads and admin notification on daily subscription resume ([89341ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/89341baa6243496d3b24c501e227ed156676c052))
* simplify 0046 migration downgrade to just drop_table ([015c2da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/015c2da297e4250ebfa50e32b3c1468c9a2143f3))
* suppress harmless TelegramBadRequest errors and fix discount promo display ([0fe3c21](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0fe3c217f752ced2e3c3f56b4ab5b4c898ba2d8e))
* use IF EXISTS in downgrade for FK indexes ([76b1f9b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/76b1f9b036e276646546aa4c89a3fe1d2ee58a40))
* validate FK existence, add FK indexes, expand video brand whitelist ([f0cdd5d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f0cdd5dc904926b55be390798a71a5427a20949b))
* validate period_days against tariff in purchase-tariff and auto-purchase ([4660ca5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4660ca5756f5f1ef8033f9202d68612d570ddb1a))
### Refactoring
* extract phantom service, replace lightweight merge with execute_merge ([6658af6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6658af6268c10db170e3eaabc75f857e5c664c3c))
* simplify referral invite text to single template ([cbe630c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cbe630cab0973d7d71dd72603b114807b237ac84))
## [3.41.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.40.0...v3.41.0) (2026-03-22)
### New Features
* add subscription status to referral network graph nodes ([de91d32](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de91d3282ffa15c0cec60c0d62871d39e7ee4c05))
* add total subscription revenue to referral network stats ([2bdb764](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2bdb7643f8fd142e99caee0fe989348161377348))
### Bug Fixes
* add abs() to all remaining subscription payment sum queries ([1eb4e18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1eb4e18c1776b2265a48e0b923a0ca4ee057d912))
* add missing total_subscription_revenue_kopeks in scoped graph early return ([bcc761f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bcc761f9d3f673bd2b404adf817762058d8e0df4))
* consider subscription status field in network graph ([454dc93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454dc9321bb9405c5ff0ff559ae4ced15533f3af))
* superadmin role managed exclusively via env config ([e0bedc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0bedc8e780a2f91509517110639773e90bb6125))
* treat expired and limited subscription statuses as inactive in referral network graph ([5ed2f0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ed2f0c95842a43ab57220dc05ca346748bd6adb))
* use abs() for subscription payment amounts in referral network ([056c13b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/056c13bc23e6737f44bbcb802a66b643349f75a9))
### Refactoring
* extract _compute_subscription_status shared helper ([8b8f1b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b8f1b91f37f829528f785a40e3a9cb98c85e043))
## [3.40.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.39.0...v3.40.0) (2026-03-22)
### New Features
* allow inactive tariffs for trial subscription activation ([cce3b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cce3b0c13bcbf0b567bd4dcf2670973382e7cab0))
* custom broadcast buttons and fix home button to use bot menu ([13ea376](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/13ea3768b516337c4e0320120bc60a9acb27a16b))
### Bug Fixes
* accept stale Telegram initData to prevent MiniApp auth failures ([4c2cb63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2cb63cf9f71fb392c3723a99e88ca3d02b127d))
* daily subscription pause not persisting in cabinet and miniapp ([d3c9940](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c994083e3b054d02d4911172968c914724d051))
* handle spurious user.deleted webhooks — preserve active subscriptions and prevent orphaned panel users ([9eab802](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9eab80200006e576967204b52f90bf9866875917))
* prevent MESSAGE_TOO_LONG in promo groups list ([c307278](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30727823169159b4b6b61f54897b209ced8dfd2))
* referral system — self-referral protection, race condition fix, deleted user re-registration ([ed5a92a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed5a92ab966dac54c15217050eae87f4b05eed62))
* sanitize email dots in RemnaWave username generation ([6c20858](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c208581d936f5ab7d6b978baafd50881b8ce9f1))
* send DISABLED instead of EXPIRED status to RemnaWave API ([79cfcbc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79cfcbcece3938f2daa83206f96ec1bffd0857e0))
## [3.39.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.38.0...v3.39.0) (2026-03-21)
### New Features
* add NaloGO fiscal receipts for code-only gift purchases ([90209eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90209ebef1a872665e622124a1898d52eff398e7))
### Bug Fixes
* add NaloGO fiscal receipt creation for landing page purchases ([4244962](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/424496233773b4cee4e389a1172e95208b3afeaf))
* manual admin top-ups missing from sales statistics ([ab43e74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ab43e74ab7484f8d3517f91e366ea395e1944b99))
* skip non-JSON payload rows in cryptobot payment index and query ([ba79d03](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba79d03e389afed972296fe2bc05104aa6b883f3))
## [3.38.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.37.0...v3.38.0) (2026-03-21)
### New Features
* add SOCKS proxy support for nalogo (tax service) module ([3c5bf4f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3c5bf4fa22d1cdf144269f4e6ab32a4523c8f1f3))
### Bug Fixes
* add diagnostic payload logging in create_user error path ([4990ddf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4990ddf9e46495b65fc3638ea8d6bed0cbe6b857))
* retry Remnawave API calls without externalSquadUuid on A039 FK violation ([de00612](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de006129657ce3dac2b1f2fc0ab1b91e23e44241))
* sanitize proxy credentials in all nalogo error paths ([3bf3105](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3bf31055e71ff64e6a6d94486bb7f7775ac7dc91))
## [3.37.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.36.1...v3.37.0) (2026-03-21)
+3 -2
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.37.0" # x-release-please-version
ARG VERSION="v3.46.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
@@ -33,7 +33,8 @@ WORKDIR /app
COPY --chown=app:app . .
RUN mkdir -p logs data && chown app:app logs data
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails && \
chown -R app:app logs data uploads
USER app
+28 -9
View File
@@ -41,21 +41,21 @@ Bedolaga — полнофункциональная платформа для п
### 📦 Подписки и тарифы
- 🎯 Гибкие тарифные планы (от 14 дней до года)
- 📊 Трафик: безлимит, фиксированный лимит или пакеты
- 📱 Управление устройствами (1–20 на подписку)
- 🌍 Автовыбор сервера или ручной выбор
- 🆓 Пробный период с конвертацией в платный
- 🎯 Гибкие тарифные планы (от X дней до X дней)
- 📊 Трафик: безлимит, фиксированный лимит или пакеты с возможностью докупки
- 📱 Управление устройствами (1–20 на подписку) или отключение лимитов
- 🌍 Автовыбор сервера(Тарифы) или ручной выбор(Конфигуратор подписки - с возможностью докупки)
- 🆓 Пробный период(Возможен платный) с конвертацией в платный
- 🛒 Умная корзина — сохраняет выбор при недостатке баланса
- 🔄 Автопродление за 3 дня до окончания
- 🎁 Подарочные подписки
- 🎁 Подарочные подписки и конфигурируемые лендинги для быстрой продажи в вебе без авторизации
</td>
<td width="50%" valign="top">
### 💳 Платежи
- 🏦 **14 платёжных провайдеров** одновременно
- 🏦 **15 платёжных провайдеров** одновременно
- 💰 Единый баланс: пополнение любым способом → покупка с баланса
- ⚡ Автопокупка подписки после пополнения
- 💾 Рекуррентные платежи (сохранённые карты)
@@ -72,11 +72,13 @@ Bedolaga — полнофункциональная платформа для п
- 🏷 Промокоды (деньги, дни подписки, триалы)
- 👥 Реферальная программа с выводом средств
- 👥 Партнерская система
- 📨 Рассылки по сегментам пользователей
- 🌐 Кастомные лендинги с аналитикой
- 🎮 Конкурсы и ежедневные игры с призами
- 🎯 Персональные предложения и скидки
- 📈 Маркетинговые кампании с трекингом
- 🌐 Обязательная мультиподписка на каналы с возможностью автоотключения подписки - при отписки от канала
</td>
<td width="50%" valign="top">
@@ -91,6 +93,9 @@ Bedolaga — полнофункциональная платформа для п
- 📡 Мониторинг трафика и аномалий
- 🤝 Партнёрская программа
- 🔐 RBAC: роли и гранулярные права доступа
- 📈 Детальная отчетность с возможностью визуализации Реф сети
- 🔐 Блокировка юзеров из общего черного списка
И многое др...
</td>
</tr>
@@ -113,8 +118,8 @@ Bedolaga — полнофункциональная платформа для п
| 💳 | **Freekassa** | NSPK СБП, карты | RUB |
| 💳 | **Kassa AI** | СБП, карты, SberPay | RUB |
| 💳 | **PayPalych (Pal24)** | Карты, СБП | RUB |
| 💳 | **Platega** | Карты, СБП, крипто | RUB |
| 💳 | **WATA** | СБП, Карты | RUB |
| 🤝 | **[Platega](https://t.me/ArstanPlatega)** 🔸 | Карты, СБП, крипто | RUB |
| 🤝 | **[WATA](https://t.me/wyrz_wata)** 🔸 | СБП, Карты | RUB |
| 💳 | **MulenPay** | Карты | RUB |
| 💳 | **RioPay** | Карты | RUB |
| 💳 | **SeverPay** | СБП, карты | RUB |
@@ -122,6 +127,8 @@ Bedolaga — полнофункциональная платформа для п
</div>
> 🔸 — официальный партнёр Bedolaga (особые условия по кодовому слову **`bedolaga`**)
>
> Все провайдеры работают параллельно через единый веб-сервер на порту 8080. Подробная настройка — в [документации](https://docs.bedolagam.ru/bot/payments).
<div align="center">
@@ -129,6 +136,18 @@ Bedolaga — полнофункциональная платформа для п
<tr>
<td align="center">
<img src=".github/assets/platega-logo.jpg" alt="Platega" width="60" />
**🤝 Официальный партнёр Platega**
Bedolaga — официальный партнёр платёжной системы **Platega**.<br>
Пользователи бота получают **особые условия** при подключении по кодовому слову **`bedolaga`**
📩 По вопросам: [@ArstanPlatega](https://t.me/ArstanPlatega)
</td>
<td align="center">
<img src=".github/assets/wata-logo.jpg" alt="WATA" width="60" />
**🤝 Официальный партнёр WATA**
+32 -7
View File
@@ -67,6 +67,7 @@ from app.middlewares.blacklist import BlacklistMiddleware
from app.middlewares.button_stats import ButtonStatsMiddleware
from app.middlewares.chat_type_filter import ChatTypeFilterMiddleware
from app.middlewares.context_binding import ContextVarsMiddleware
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
from app.middlewares.global_error import GlobalErrorMiddleware
from app.middlewares.logging import LoggingMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
@@ -101,12 +102,16 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
bot = create_bot()
proxy_url = settings.get_proxy_url()
if proxy_url:
from urllib.parse import urlparse
nalogo_proxy_url = settings.get_nalogo_proxy_url()
parsed = urlparse(proxy_url)
masked = f'{parsed.scheme}://***@{parsed.hostname}:{parsed.port}' if parsed.username else proxy_url
logger.info('Proxy configured', proxy_url=masked)
if proxy_url or nalogo_proxy_url:
from app.utils.proxy import mask_proxy_url
if proxy_url:
logger.info('Proxy configured', proxy_url=mask_proxy_url(proxy_url))
if nalogo_proxy_url:
source = 'NALOGO_PROXY_URL' if settings.NALOGO_PROXY_URL else 'PROXY_URL (fallback)'
logger.info('Nalogo proxy configured', proxy_url=mask_proxy_url(nalogo_proxy_url), source=source)
maintenance_service.set_bot(bot)
logger.info('Бот установлен в maintenance_service')
@@ -129,11 +134,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
chat_type_filter = ChatTypeFilterMiddleware()
dp.message.middleware(chat_type_filter)
dp.callback_query.middleware(chat_type_filter)
dp.message.middleware(LoggingMiddleware())
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(GlobalErrorMiddleware())
dp.callback_query.middleware(GlobalErrorMiddleware())
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
dp.message.middleware(LoggingMiddleware())
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(MaintenanceMiddleware())
dp.callback_query.middleware(MaintenanceMiddleware())
blacklist_middleware = BlacklistMiddleware()
@@ -158,8 +163,12 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.message.middleware(AuthMiddleware())
dp.callback_query.middleware(AuthMiddleware())
dp.pre_checkout_query.middleware(AuthMiddleware())
display_name_restriction = DisplayNameRestrictionMiddleware()
dp.message.middleware(display_name_restriction)
dp.callback_query.middleware(display_name_restriction)
dp.message.middleware(SubscriptionStatusMiddleware())
dp.callback_query.middleware(SubscriptionStatusMiddleware())
dp.pre_checkout_query.middleware(SubscriptionStatusMiddleware())
start.register_handlers(dp)
menu.register_handlers(dp)
subscription.register_handlers(dp)
@@ -271,12 +280,28 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
except Exception as e:
logger.warning('Failed to load menu layout cache', error=e)
try:
from app.services.remnawave_retry_queue import remnawave_retry_queue
await remnawave_retry_queue.start()
logger.info('RemnaWave retry queue запущен')
except Exception as e:
logger.error('Ошибка запуска RemnaWave retry queue', error=e)
logger.info('Бот успешно настроен')
return bot, dp
async def shutdown_bot():
try:
from app.services.remnawave_retry_queue import remnawave_retry_queue
await remnawave_retry_queue.stop()
logger.info('RemnaWave retry queue остановлен')
except Exception as e:
logger.error('Ошибка остановки RemnaWave retry queue', error=e)
try:
await maintenance_service.stop_monitoring()
logger.info('Мониторинг техработ остановлен')
+20
View File
@@ -49,7 +49,17 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
age = (datetime.now(UTC) - auth_time).total_seconds()
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
logger.warning(
'Telegram widget auth rejected: too old',
age_hours=round(age / 3600, 1),
max_age_hours=round(max_age_seconds / 3600, 1),
)
return False
if age > 86400:
logger.info(
'Telegram widget auth accepted with stale auth_date',
age_hours=round(age / 3600, 1),
)
except (ValueError, TypeError, OSError):
return False
@@ -96,7 +106,17 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
age = (datetime.now(UTC) - auth_time).total_seconds()
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
logger.warning(
'Telegram initData rejected: too old',
age_hours=round(age / 3600, 1),
max_age_hours=round(max_age_seconds / 3600, 1),
)
return None
if age > 86400:
logger.info(
'Telegram initData accepted with stale auth_date (Telegram caching bug)',
age_hours=round(age / 3600, 1),
)
except (ValueError, TypeError, OSError):
return None
+15 -1
View File
@@ -13,6 +13,10 @@ from .admin_channels import router as admin_channels_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_landings import router as admin_landings_router
from .admin_menu_layout import router as admin_menu_layout_router
from .admin_news import router as admin_news_router
from .admin_news_categories import router as admin_news_categories_router
from .admin_news_media import router as admin_news_media_router
from .admin_news_tags import router as admin_news_tags_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
@@ -42,6 +46,7 @@ from .gift import router as gift_router
from .info import router as info_router
from .landing import router as landing_router
from .media import router as media_router
from .news import router as news_router
from .notifications import router as notifications_router
from .oauth import router as oauth_router
from .partner_application import router as partner_application_router
@@ -50,6 +55,7 @@ from .promo import router as promo_router
from .promocode import router as promocode_router
from .referral import router as referral_router
from .subscription import router as subscription_router
from .subscription_modules.multi_tariff import router as multi_tariff_subscription_router
from .ticket_notifications import (
admin_router as admin_ticket_notifications_router,
router as ticket_notifications_router,
@@ -61,7 +67,7 @@ from .withdrawal import router as withdrawal_router
# Main cabinet router
router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
router = APIRouter(prefix='/cabinet', tags=['Cabinet'], redirect_slashes=False)
# Include all sub-routers
router.include_router(auth_router)
@@ -69,6 +75,7 @@ router.include_router(oauth_router)
router.include_router(account_linking_router)
router.include_router(merge_router)
router.include_router(subscription_router)
router.include_router(multi_tariff_subscription_router)
router.include_router(balance_router)
router.include_router(referral_router)
router.include_router(partner_application_router)
@@ -85,6 +92,7 @@ router.include_router(info_router)
router.include_router(branding_router)
router.include_router(landing_router)
router.include_router(media_router)
router.include_router(news_router)
# Wheel routes
router.include_router(wheel_router)
@@ -126,6 +134,12 @@ router.include_router(admin_apps_router)
router.include_router(admin_roles_router)
router.include_router(admin_policies_router)
router.include_router(admin_audit_log_router)
# Categories/tags/media routers MUST be before the main news router
# to avoid /admin/news/{article_id} catching /admin/news/categories etc.
router.include_router(admin_news_categories_router)
router.include_router(admin_news_tags_router)
router.include_router(admin_news_media_router)
router.include_router(admin_news_router)
# WebSocket route
router.include_router(websocket_router)
+41 -2
View File
@@ -487,7 +487,8 @@ async def link_telegram(
if request.init_data:
# Mini App flow: validate initData
user_data = validate_telegram_init_data(request.init_data)
# Generous max_age: Telegram Desktop/iOS cache initData with stale auth_date
user_data = validate_telegram_init_data(request.init_data, max_age_seconds=86400 * 30)
if not user_data or not user_data.get('id'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -560,7 +561,8 @@ async def link_telegram(
if request.photo_url is not None:
widget_data['photo_url'] = request.photo_url
if not validate_telegram_login_widget(widget_data):
# Generous max_age: Telegram caches auth data with stale auth_date
if not validate_telegram_login_widget(widget_data, max_age_seconds=86400 * 30):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid or expired Telegram Login Widget data',
@@ -620,6 +622,24 @@ async def link_telegram(
telegram_id=telegram_id,
user_id=user.id,
)
# BUG-1 fix: Sync all subscriptions with RemnaWave panel so it knows the new telegram_id
try:
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
resync_result = await resync_user_subscriptions_with_panel(db, user)
logger.info(
'Post-TG-link resync completed',
user_id=user.id,
telegram_id=telegram_id,
synced=resync_result['synced'],
failed=resync_result['failed'],
)
except Exception as resync_error:
logger.error(
'Post-TG-link resync failed (non-fatal)',
user_id=user.id,
error=resync_error,
)
return LinkCallbackResponse(success=True, message='linked')
@@ -865,6 +885,25 @@ async def execute_merge_endpoint(
detail='Failed to load merged user',
)
# BUG-7 fix: Resync merged user's subscriptions with RemnaWave panel
try:
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
resync_result = await resync_user_subscriptions_with_panel(db, merged_user)
logger.info(
'Post-merge resync completed',
primary_user_id=primary_user_id,
secondary_user_id=secondary_user_id,
synced=resync_result['synced'],
failed=resync_result['failed'],
)
except Exception as resync_error:
logger.error(
'Post-merge resync failed (non-fatal)',
primary_user_id=primary_user_id,
error=resync_error,
)
# 5. Create auth tokens for the merged user
try:
auth_response = await _create_auth_response(merged_user, db)
+7
View File
@@ -141,6 +141,7 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
created_at=broadcast.created_at,
completed_at=broadcast.completed_at,
progress_percent=progress,
category=getattr(broadcast, 'category', 'system') or 'system',
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
email_subject=getattr(broadcast, 'email_subject', None),
email_html_content=getattr(broadcast, 'email_html_content', None),
@@ -432,6 +433,7 @@ async def create_broadcast(
status='queued',
admin_id=admin.id,
admin_name=admin.username or f'Admin #{admin.id}',
category=request.category,
)
db.add(broadcast)
await db.commit()
@@ -453,6 +455,8 @@ async def create_broadcast(
selected_buttons=request.selected_buttons,
media=media_config,
initiator_name=admin.username or f'Admin #{admin.id}',
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
category=request.category,
)
# Start broadcast
@@ -625,6 +629,7 @@ async def create_combined_broadcast(
status='queued',
admin_id=admin.id,
admin_name=admin_name,
category=request.category,
channel=request.channel,
email_subject=request.email_subject.strip() if request.email_subject else None,
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
@@ -651,6 +656,8 @@ async def create_combined_broadcast(
selected_buttons=request.selected_buttons,
media=media_config,
initiator_name=admin_name,
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
category=request.category,
)
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
+3 -3
View File
@@ -42,9 +42,9 @@ router = APIRouter(prefix='/admin/menu-layout', tags=['Admin Menu Layout'])
# ---- Constants ---------------------------------------------------------------
MAX_ROWS = 20
MAX_BUTTONS_PER_ROW = 3
MAX_BUTTONS_PER_ROW = 8 # Telegram inline keyboard limit
MAX_LABEL_LENGTH = 100
URL_PATTERN = re.compile(r'^https?://')
URL_PATTERN = re.compile(r'^(https?://|tg://)')
# ---- Schemas -----------------------------------------------------------------
@@ -275,7 +275,7 @@ def _validate_update_payload(rows: list[RowConfig]) -> None:
if not btn.url or not URL_PATTERN.match(btn.url):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Custom button "{btn.id}" must have a URL starting with http:// or https://.',
detail=f'Custom button "{btn.id}" must have a URL starting with http://, https://, or tg://.',
)
if btn.open_in == 'webapp' and not btn.url.startswith('https://'):
raise HTTPException(
+343
View File
@@ -0,0 +1,343 @@
"""Admin routes for managing news articles in cabinet."""
from datetime import UTC, datetime
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.news import (
create_news_article,
delete_news_article,
get_all_news,
get_all_news_count,
get_news_article_by_id,
unfeature_all_news,
update_news_article,
)
from app.database.crud.news_categories import get_category_by_id
from app.database.crud.news_tags import get_tag_by_id
from app.database.models import NewsArticle, User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.news import (
NewsArticleListItem,
NewsArticleResponse,
NewsCreateRequest,
NewsListResponse,
NewsToggleResponse,
NewsUpdateRequest,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/news', tags=['Cabinet Admin News'])
def _article_to_detail(article: NewsArticle) -> dict[str, Any]:
"""Convert NewsArticle ORM instance to full detail dict.
Expects the ``author`` relationship to be eagerly loaded.
"""
author_name: str | None = None
if article.author:
author_name = article.author.first_name or article.author.username or f'#{article.author.id}'
return {
'id': article.id,
'title': article.title,
'slug': article.slug,
'content': article.content,
'excerpt': article.excerpt,
'category': article.category,
'category_color': article.category_color,
'tag': article.tag,
'category_id': article.category_id,
'tag_id': article.tag_id,
'featured_image_url': article.featured_image_url,
'is_published': article.is_published,
'is_featured': article.is_featured,
'published_at': article.published_at,
'read_time_minutes': article.read_time_minutes,
'views_count': article.views_count,
'author_name': author_name,
'created_at': article.created_at,
'updated_at': article.updated_at,
}
@router.get('', response_model=NewsListResponse)
async def list_all_news(
admin: User = Depends(require_permission('news:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> NewsListResponse:
"""Get all news articles (admin view, includes unpublished)."""
try:
articles = await get_all_news(db, limit=limit, offset=offset)
total = await get_all_news_count(db)
items = [NewsArticleListItem.model_validate(a) for a in articles]
return NewsListResponse(items=items, total=total)
except HTTPException:
raise
except Exception:
logger.exception('Failed to list all news')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load news articles',
)
@router.get('/{article_id}', response_model=NewsArticleResponse)
async def get_article_detail(
article_id: int,
admin: User = Depends(require_permission('news:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsArticleResponse:
"""Get a single news article by ID (admin view)."""
article = await get_news_article_by_id(db, article_id)
if not article:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Article not found',
)
return NewsArticleResponse(**_article_to_detail(article))
@router.post('', response_model=NewsArticleResponse, status_code=status.HTTP_201_CREATED)
async def create_article(
request: NewsCreateRequest,
admin: User = Depends(require_permission('news:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsArticleResponse:
"""Create a new news article."""
try:
# Resolve category from FK -- sync legacy string fields from the managed entity
category_name = request.category
category_color = request.category_color
if request.category_id is not None:
cat = await get_category_by_id(db, request.category_id)
if not cat:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f'Category with id={request.category_id} not found',
)
category_name = cat.name
category_color = cat.color
# Resolve tag from FK -- sync legacy string field from the managed entity
tag_name = request.tag
if request.tag_id is not None:
tag_obj = await get_tag_by_id(db, request.tag_id)
if not tag_obj:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f'Tag with id={request.tag_id} not found',
)
tag_name = tag_obj.name
if request.is_featured:
await unfeature_all_news(db)
article = await create_news_article(
db,
title=request.title,
slug=request.slug,
content=request.content,
excerpt=request.excerpt,
category=category_name,
category_color=category_color,
tag=tag_name,
category_id=request.category_id,
tag_id=request.tag_id,
featured_image_url=request.featured_image_url,
is_published=request.is_published,
is_featured=request.is_featured,
read_time_minutes=request.read_time_minutes,
created_by=admin.id,
)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='An article with this slug already exists',
)
except Exception:
logger.exception('Failed to create news article')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create article',
)
# Reload with author relationship
article = await get_news_article_by_id(db, article.id)
if not article:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to reload article after creation',
)
return NewsArticleResponse(**_article_to_detail(article))
@router.put('/{article_id}', response_model=NewsArticleResponse)
async def update_article(
article_id: int,
request: NewsUpdateRequest,
admin: User = Depends(require_permission('news:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsArticleResponse:
"""Update an existing news article."""
article = await get_news_article_by_id(db, article_id)
if not article:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Article not found',
)
try:
update_data = request.model_dump(exclude_unset=True)
# Resolve category from FK -- sync legacy string fields from the managed entity
if 'category_id' in update_data and update_data['category_id'] is not None:
cat = await get_category_by_id(db, update_data['category_id'])
if not cat:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f'Category with id={update_data["category_id"]} not found',
)
update_data['category'] = cat.name
update_data['category_color'] = cat.color
# Resolve tag from FK -- sync legacy string field from the managed entity
if 'tag_id' in update_data and update_data['tag_id'] is not None:
tag_obj = await get_tag_by_id(db, update_data['tag_id'])
if not tag_obj:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f'Tag with id={update_data["tag_id"]} not found',
)
update_data['tag'] = tag_obj.name
if update_data.get('is_featured'):
await unfeature_all_news(db)
article = await update_news_article(db, article, **update_data)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='An article with this slug already exists',
)
except Exception:
logger.exception('Failed to update news article', article_id=article_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update article',
)
# Reload with author relationship (update used bulk UPDATE, author not populated)
article = await get_news_article_by_id(db, article.id)
if not article:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to reload article after update',
)
return NewsArticleResponse(**_article_to_detail(article))
@router.delete('/{article_id}', status_code=status.HTTP_204_NO_CONTENT)
async def remove_article(
article_id: int,
admin: User = Depends(require_permission('news:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete a news article."""
article = await get_news_article_by_id(db, article_id)
if not article:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Article not found',
)
try:
await delete_news_article(db, article)
except Exception:
logger.exception('Failed to delete news article', article_id=article_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to delete article',
)
@router.post('/{article_id}/publish', response_model=NewsToggleResponse)
async def toggle_publish(
article_id: int,
admin: User = Depends(require_permission('news:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsToggleResponse:
"""Toggle the published status of a news article."""
article = await get_news_article_by_id(db, article_id)
if not article:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Article not found',
)
new_published = not article.is_published
update_kwargs: dict[str, Any] = {'is_published': new_published}
# Auto-set published_at on first publish
if new_published and article.published_at is None:
update_kwargs['published_at'] = datetime.now(UTC)
try:
article = await update_news_article(db, article, **update_kwargs)
return NewsToggleResponse(
id=article.id,
is_published=article.is_published,
is_featured=article.is_featured,
published_at=article.published_at,
)
except Exception:
logger.exception('Failed to toggle publish', article_id=article_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to toggle publish status',
)
@router.post('/{article_id}/feature', response_model=NewsToggleResponse)
async def toggle_featured(
article_id: int,
admin: User = Depends(require_permission('news:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsToggleResponse:
"""Toggle the featured status of a news article."""
article = await get_news_article_by_id(db, article_id)
if not article:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Article not found',
)
try:
new_featured = not article.is_featured
# Only one article can be featured at a time — unfeature all others first
if new_featured:
await unfeature_all_news(db)
article = await update_news_article(db, article, is_featured=new_featured)
return NewsToggleResponse(
id=article.id,
is_published=article.is_published,
is_featured=article.is_featured,
published_at=article.published_at,
)
except Exception:
logger.exception('Failed to toggle featured', article_id=article_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to toggle featured status',
)
@@ -0,0 +1,90 @@
"""Admin routes for managing news categories."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.news_categories import (
create_category,
delete_category,
get_all_categories,
get_category_by_id,
update_category,
)
from app.database.models import User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.news_categories import NewsCategoryCreate, NewsCategoryResponse, NewsCategoryUpdate
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/news/categories', tags=['Cabinet Admin News Categories'])
@router.get('', response_model=list[NewsCategoryResponse])
async def list_categories(
admin: User = Depends(require_permission('news:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[NewsCategoryResponse]:
"""Get all news categories."""
categories = await get_all_categories(db)
return [NewsCategoryResponse.model_validate(c) for c in categories]
@router.post('', response_model=NewsCategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_new_category(
request: NewsCategoryCreate,
admin: User = Depends(require_permission('news:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsCategoryResponse:
"""Create a new news category."""
try:
category = await create_category(db, name=request.name, color=request.color)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Category already exists',
)
return NewsCategoryResponse.model_validate(category)
@router.put('/{category_id}', response_model=NewsCategoryResponse)
async def update_existing_category(
category_id: int,
request: NewsCategoryUpdate,
admin: User = Depends(require_permission('news:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsCategoryResponse:
"""Update an existing news category."""
category = await get_category_by_id(db, category_id)
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Category not found',
)
try:
category = await update_category(db, category, **request.model_dump(exclude_unset=True))
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Category name already exists',
)
return NewsCategoryResponse.model_validate(category)
@router.delete('/{category_id}', status_code=status.HTTP_204_NO_CONTENT)
async def remove_category(
category_id: int,
admin: User = Depends(require_permission('news:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete a news category. Articles using it will have category_id set to NULL."""
category = await get_category_by_id(db, category_id)
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Category not found',
)
await delete_category(db, category)
+157
View File
@@ -0,0 +1,157 @@
"""Admin routes for managing news article media (images/videos)."""
import asyncio
import re
import structlog
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from PIL import Image as PILImage
from app.config import settings
from app.database.models import User
from app.services.news_media_service import (
SavedMedia,
delete_media_file,
detect_file_type,
ensure_upload_dirs,
save_image,
save_video,
)
from ..dependencies import require_permission
from ..schemas.news_media import NewsMediaUploadResponse
logger = structlog.get_logger(__name__)
_BYTES_PER_MB = 1024 * 1024
# Only allow UUID-hex filenames with expected extensions (path traversal defense-in-depth).
# thumb_ prefix is NOT allowed — thumbnails are cleaned up automatically when the main file is deleted.
_SAFE_FILENAME_RE = re.compile(r'^[0-9a-f]{32}\.(jpg|mp4|webm)$')
router = APIRouter(prefix='/admin/news/media', tags=['Cabinet Admin News Media'])
_ALLOWED_SCHEMES = frozenset({'http', 'https'})
def _build_media_url(request: Request, relative_path: str) -> str:
"""Build a full URL for a media file, respecting reverse proxy headers."""
proto = request.headers.get('X-Forwarded-Proto', request.url.scheme).split(',')[0].strip()
if proto not in _ALLOWED_SCHEMES:
proto = 'https'
host = request.headers.get('X-Forwarded-Host', request.headers.get('Host', request.url.netloc))
host = host.split(',')[0].strip()
return f'{proto}://{host}/uploads/{relative_path}'
def _build_response(request: Request, saved: SavedMedia) -> NewsMediaUploadResponse:
"""Convert SavedMedia to API response with full URLs."""
thumbnail_url = _build_media_url(request, saved.thumbnail_path) if saved.thumbnail_path else None
return NewsMediaUploadResponse(
url=_build_media_url(request, saved.relative_path),
thumbnail_url=thumbnail_url,
media_type=saved.media_type,
filename=saved.filename,
size_bytes=saved.size_bytes,
width=saved.width,
height=saved.height,
)
@router.post('/upload', response_model=NewsMediaUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_media(
request: Request,
file: UploadFile = File(...),
admin: User = Depends(require_permission('news:edit')),
) -> NewsMediaUploadResponse:
"""Upload an image or video for a news article."""
# Read with a hard budget to prevent memory exhaustion from huge uploads.
# Read slightly over the max allowed size so we can detect oversized files.
absolute_max_bytes = settings.MEDIA_MAX_VIDEO_SIZE_MB * _BYTES_PER_MB + 1
data = await file.read(absolute_max_bytes)
await file.close()
if not data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Empty file',
)
if len(data) >= absolute_max_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f'File too large. Absolute maximum: {settings.MEDIA_MAX_VIDEO_SIZE_MB} MB',
)
# Detect type from magic bytes
try:
media_type, _ext = detect_file_type(data)
except ValueError:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail='Unsupported file type. Allowed: JPEG, PNG, WebP, MP4, WebM',
) from None
# Enforce per-type size limits
max_size_mb = settings.MEDIA_MAX_IMAGE_SIZE_MB if media_type == 'image' else settings.MEDIA_MAX_VIDEO_SIZE_MB
if len(data) > max_size_mb * _BYTES_PER_MB:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f'File too large. Maximum size for {media_type}: {max_size_mb} MB',
)
upload_path = settings.get_media_upload_path()
await asyncio.to_thread(ensure_upload_dirs, upload_path)
try:
if media_type == 'image':
saved = await save_image(
data,
upload_path,
max_dim=settings.MEDIA_IMAGE_MAX_DIMENSION,
quality=settings.MEDIA_JPEG_QUALITY,
)
else:
saved = await save_video(data, upload_path)
except (ValueError, OSError, PILImage.DecompressionBombError) as exc:
logger.warning('Failed to save uploaded media', media_type=media_type, error=str(exc))
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail='Failed to process uploaded file',
) from None
logger.info(
'Media uploaded',
filename=saved.filename,
media_type=saved.media_type,
size_bytes=saved.size_bytes,
admin_id=admin.id,
)
return _build_response(request, saved)
@router.delete('/{filename}', status_code=status.HTTP_204_NO_CONTENT)
async def delete_media(
filename: str,
admin: User = Depends(require_permission('news:delete')),
) -> None:
"""Delete a previously uploaded media file."""
if not _SAFE_FILENAME_RE.match(filename):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid filename',
)
upload_path = settings.get_media_upload_path()
deleted = await asyncio.to_thread(delete_media_file, filename, upload_path)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='File not found',
)
logger.info('Media deleted', filename=filename, admin_id=admin.id)
+90
View File
@@ -0,0 +1,90 @@
"""Admin routes for managing news tags."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.news_tags import (
create_tag,
delete_tag,
get_all_tags,
get_tag_by_id,
update_tag,
)
from app.database.models import User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.news_tags import NewsTagCreate, NewsTagResponse, NewsTagUpdate
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/news/tags', tags=['Cabinet Admin News Tags'])
@router.get('', response_model=list[NewsTagResponse])
async def list_tags(
admin: User = Depends(require_permission('news:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[NewsTagResponse]:
"""Get all news tags."""
tags = await get_all_tags(db)
return [NewsTagResponse.model_validate(t) for t in tags]
@router.post('', response_model=NewsTagResponse, status_code=status.HTTP_201_CREATED)
async def create_new_tag(
request: NewsTagCreate,
admin: User = Depends(require_permission('news:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsTagResponse:
"""Create a new news tag."""
try:
tag = await create_tag(db, name=request.name, color=request.color)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Tag already exists',
)
return NewsTagResponse.model_validate(tag)
@router.put('/{tag_id}', response_model=NewsTagResponse)
async def update_existing_tag(
tag_id: int,
request: NewsTagUpdate,
admin: User = Depends(require_permission('news:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsTagResponse:
"""Update an existing news tag."""
tag = await get_tag_by_id(db, tag_id)
if not tag:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tag not found',
)
try:
tag = await update_tag(db, tag, **request.model_dump(exclude_unset=True))
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Tag name already exists',
)
return NewsTagResponse.model_validate(tag)
@router.delete('/{tag_id}', status_code=status.HTTP_204_NO_CONTENT)
async def remove_tag(
tag_id: int,
admin: User = Depends(require_permission('news:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete a news tag. Articles using it will have tag_id set to NULL."""
tag = await get_tag_by_id(db, tag_id)
if not tag:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tag not found',
)
await delete_tag(db, tag)
+16 -2
View File
@@ -4,14 +4,14 @@ from __future__ import annotations
import asyncio
from datetime import datetime
from typing import Any
from typing import Any, ClassVar
import structlog
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.bot_factory import create_bot
@@ -128,6 +128,20 @@ class PromoOfferBroadcastRequest(BaseModel):
message_text: str | None = Field(None, description='Custom message text (HTML)')
button_text: str | None = Field(None, description='Button text')
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
'no_sub': 'no',
'all_users': 'all',
'active_subscribers': 'active',
'trial_users': 'trial',
}
@validator('target')
def normalize_target(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().lower()
return cls._TARGET_ALIASES.get(normalized, normalized)
class PromoOfferBroadcastResponse(BaseModel):
created_offers: int
+24 -6
View File
@@ -28,6 +28,7 @@ from app.database.crud.promocode import (
get_promocodes_list,
update_promocode,
)
from app.database.crud.tariff import get_tariff_by_id
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, PromoGroup, User
from ..dependencies import get_cabinet_db, require_permission
@@ -55,6 +56,8 @@ class PromoCodeResponse(BaseModel):
valid_from: datetime
valid_until: datetime | None = None
promo_group_id: int | None = None
tariff_id: int | None = None
tariff_name: str | None = None
created_by: int | None = None
created_at: datetime
updated_at: datetime
@@ -93,6 +96,7 @@ class PromoCodeCreateRequest(BaseModel):
is_active: bool = True
first_purchase_only: bool = False
promo_group_id: int | None = None
tariff_id: int | None = None
class PromoCodeUpdateRequest(BaseModel):
@@ -106,6 +110,7 @@ class PromoCodeUpdateRequest(BaseModel):
is_active: bool | None = None
first_purchase_only: bool | None = None
promo_group_id: int | None = None
tariff_id: int | None = None
# ============== PromoGroup Schemas ==============
@@ -168,7 +173,12 @@ def _normalize_datetime(value: datetime | None) -> datetime | None:
return value
def _serialize_promocode(promocode: PromoCode) -> PromoCodeResponse:
async def _serialize_promocode(db: AsyncSession, promocode: PromoCode) -> PromoCodeResponse:
tariff_name = None
if promocode.tariff_id:
tariff = await get_tariff_by_id(db, promocode.tariff_id)
tariff_name = tariff.name if tariff else None
promo_type = PromoCodeType(promocode.type)
return PromoCodeResponse(
id=promocode.id,
@@ -186,6 +196,8 @@ def _serialize_promocode(promocode: PromoCode) -> PromoCodeResponse:
valid_from=promocode.valid_from,
valid_until=promocode.valid_until,
promo_group_id=promocode.promo_group_id,
tariff_id=promocode.tariff_id,
tariff_name=tariff_name,
created_by=promocode.created_by,
created_at=promocode.created_at,
updated_at=promocode.updated_at,
@@ -315,8 +327,9 @@ async def list_promocodes(
total = await get_promocodes_count(db, is_active=is_active) or 0
promocodes = await get_promocodes_list(db, offset=offset, limit=limit, is_active=is_active)
serialized = [await _serialize_promocode(db, p) for p in promocodes]
return PromoCodeListResponse(
items=[_serialize_promocode(promocode) for promocode in promocodes],
items=serialized,
total=int(total),
limit=limit,
offset=offset,
@@ -335,7 +348,7 @@ async def get_promocode(
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
stats = await get_promocode_statistics(db, promocode_id)
base = _serialize_promocode(promocode)
base = await _serialize_promocode(db, promocode)
recent_uses = [_serialize_recent_use(use) for use in stats.get('recent_uses', [])]
return PromoCodeDetailResponse(
@@ -388,11 +401,13 @@ async def create_promocode_endpoint(
update_fields['first_purchase_only'] = payload.first_purchase_only
if payload.promo_group_id is not None:
update_fields['promo_group_id'] = payload.promo_group_id
if payload.tariff_id is not None:
update_fields['tariff_id'] = payload.tariff_id
if update_fields:
promocode = await update_promocode(db, promocode, **update_fields)
return _serialize_promocode(promocode)
return await _serialize_promocode(db, promocode)
@router.patch('/{promocode_id}', response_model=PromoCodeResponse)
@@ -446,11 +461,14 @@ async def update_promocode_endpoint(
if payload.promo_group_id is not None:
updates['promo_group_id'] = payload.promo_group_id
if payload.tariff_id is not None:
updates['tariff_id'] = payload.tariff_id if payload.tariff_id != 0 else None
if not updates:
return _serialize_promocode(promocode)
return await _serialize_promocode(db, promocode)
promocode = await update_promocode(db, promocode, **updates)
return _serialize_promocode(promocode)
return await _serialize_promocode(db, promocode)
@router.delete(
+110 -20
View File
@@ -2,6 +2,7 @@
import re
from collections import defaultdict
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -16,6 +17,7 @@ from app.database.models import (
PartnerStatus,
ReferralEarning,
Subscription,
SubscriptionStatus,
Tariff,
Transaction,
TransactionType,
@@ -78,6 +80,7 @@ class NetworkUserNode(BaseModel):
personal_spent_kopeks: int
subscription_name: str | None
subscription_end: str | None
subscription_status: str | None
registered_at: str | None
@@ -114,6 +117,7 @@ class NetworkGraphResponse(BaseModel):
total_referrers: int
total_campaigns: int
total_earnings_kopeks: int
total_subscription_revenue_kopeks: int
class NetworkUserDetail(BaseModel):
@@ -134,6 +138,7 @@ class NetworkUserDetail(BaseModel):
personal_spent_kopeks: int
subscription_name: str | None
subscription_end: str | None
subscription_status: str | None
registered_at: str | None
@@ -215,6 +220,7 @@ def _build_user_node(
campaign_id: int | None,
subscription_name: str | None,
subscription_end_str: str | None,
subscription_status: str | None,
) -> NetworkUserNode:
return NetworkUserNode(
id=user.id,
@@ -232,6 +238,7 @@ def _build_user_node(
personal_spent_kopeks=personal_spent,
subscription_name=subscription_name,
subscription_end=subscription_end_str,
subscription_status=subscription_status,
registered_at=_format_datetime(user.created_at),
)
@@ -312,7 +319,7 @@ async def _fetch_branch_revenue(db: AsyncSession, user_ids: set[int]) -> dict[in
stmt = (
select(
referred_user.c.referred_by_id,
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0),
)
.join(referred_user, Transaction.user_id == referred_user.c.id)
.where(
@@ -333,7 +340,7 @@ async def _fetch_personal_spent(db: AsyncSession, user_ids: set[int]) -> dict[in
return {}
stmt = (
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),
@@ -376,18 +383,81 @@ async def _fetch_campaign_registrations(db: AsyncSession, user_ids: set[int] | N
return {row[0]: row[1] for row in result}
async def _fetch_subscription_info(db: AsyncSession, user_ids: set[int]) -> dict[int, tuple[str | None, str | None]]:
"""Return {user_id: (tariff_name, end_date_iso)} for given users."""
def _compute_subscription_status(
is_trial: bool | None,
db_status: str | None,
end_date: datetime | None,
now: datetime,
) -> str | None:
"""Map subscription fields to a frontend status label.
Returns one of: 'trial_active', 'trial_expired', 'paid_active', 'paid_expired', or None.
Statuses DISABLED, PENDING, EXPIRED, LIMITED are treated as inactive regardless of end_date.
ACTIVE and TRIAL fall through to a date-based check.
"""
if is_trial is None:
return None
if db_status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.PENDING.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
):
return 'trial_expired' if is_trial else 'paid_expired'
if is_trial:
return 'trial_active' if (end_date and end_date > now) else 'trial_expired'
return 'paid_active' if (end_date and end_date > now) else 'paid_expired'
async def _fetch_subscription_info(
db: AsyncSession,
user_ids: set[int],
) -> dict[int, tuple[str | None, str | None, str | None]]:
"""Return {user_id: (tariff_name, end_date_iso, subscription_status)} for given users."""
if not user_ids:
return {}
stmt = (
select(Subscription.user_id, Tariff.name, Subscription.end_date)
row_num = (
func.row_number()
.over(
partition_by=Subscription.user_id,
order_by=Subscription.end_date.desc().nullslast(),
)
.label('rn')
)
inner = (
select(
Subscription.user_id,
Tariff.name,
Subscription.end_date,
Subscription.is_trial,
Subscription.status,
row_num,
)
.outerjoin(Tariff, Subscription.tariff_id == Tariff.id)
.where(Subscription.user_id.in_(user_ids))
)
subq = inner.subquery()
stmt = select(
subq.c.user_id,
subq.c.name,
subq.c.end_date,
subq.c.is_trial,
subq.c.status,
).where(subq.c.rn == 1)
result = await db.execute(stmt)
return {row[0]: (row[1], _format_datetime(row[2]) if row[2] else None) for row in result}
now = datetime.now(UTC)
out: dict[int, tuple[str | None, str | None, str | None]] = {}
for row in result:
user_id, tariff_name, end_date, is_trial, db_status = row
end_date_iso = _format_datetime(end_date) if end_date else None
sub_status = _compute_subscription_status(is_trial, db_status, end_date, now)
out[user_id] = (tariff_name, end_date_iso, sub_status)
return out
async def _fetch_campaign_stats(
@@ -428,7 +498,7 @@ async def _fetch_campaign_stats(
user_spent: dict[int, int] = {}
if all_campaign_users:
spent_stmt = (
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_(all_campaign_users),
@@ -540,6 +610,7 @@ async def get_referral_network(
total_referrers=0,
total_campaigns=0,
total_earnings_kopeks=0,
total_subscription_revenue_kopeks=0,
)
# Cap to prevent excessive response sizes (deterministic: keep lowest IDs for stability)
@@ -567,7 +638,7 @@ async def get_referral_network(
# Build user nodes
user_nodes: list[NetworkUserNode] = []
for user in users:
sub = sub_info.get(user.id, (None, None))
sub = sub_info.get(user.id, (None, None, None))
user_nodes.append(
_build_user_node(
user,
@@ -578,6 +649,7 @@ async def get_referral_network(
campaign_id=campaign_regs.get(user.id),
subscription_name=sub[0],
subscription_end_str=sub[1],
subscription_status=sub[2],
)
)
@@ -629,6 +701,7 @@ async def get_referral_network(
total_referrers = len([u for u in user_nodes if u.direct_referrals > 0])
total_earnings = sum(personal_revenue.values())
total_subscription_revenue = sum(personal_spent.values())
return NetworkGraphResponse(
users=user_nodes,
@@ -638,6 +711,7 @@ async def get_referral_network(
total_referrers=total_referrers,
total_campaigns=len(campaign_nodes),
total_earnings_kopeks=total_earnings,
total_subscription_revenue_kopeks=total_subscription_revenue,
)
@@ -784,6 +858,7 @@ async def _build_scoped_graph(
total_referrers=0,
total_campaigns=len(campaign_nodes),
total_earnings_kopeks=0,
total_subscription_revenue_kopeks=0,
)
return NetworkGraphResponse(
users=[],
@@ -793,6 +868,7 @@ async def _build_scoped_graph(
total_referrers=0,
total_campaigns=0,
total_earnings_kopeks=0,
total_subscription_revenue_kopeks=0,
)
# Cap to prevent excessive response sizes
@@ -816,7 +892,7 @@ async def _build_scoped_graph(
user_nodes: list[NetworkUserNode] = []
for user in users:
sub = sub_info.get(user.id, (None, None))
sub = sub_info.get(user.id, (None, None, None))
user_nodes.append(
_build_user_node(
user,
@@ -827,6 +903,7 @@ async def _build_scoped_graph(
campaign_id=campaign_regs.get(user.id),
subscription_name=sub[0],
subscription_end_str=sub[1],
subscription_status=sub[2],
)
)
@@ -878,6 +955,7 @@ async def _build_scoped_graph(
total_referrers = len([u for u in user_nodes if u.direct_referrals > 0])
total_earnings = sum(personal_revenue.values())
total_subscription_revenue = sum(personal_spent.values())
return NetworkGraphResponse(
users=user_nodes,
@@ -887,6 +965,7 @@ async def _build_scoped_graph(
total_referrers=total_referrers,
total_campaigns=len(campaign_nodes),
total_earnings_kopeks=total_earnings,
total_subscription_revenue_kopeks=total_subscription_revenue,
)
@@ -1029,7 +1108,7 @@ async def get_network_user_detail(
# Fetch user with subscription eagerly loaded
stmt = (
select(User)
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
.where(User.id == user_id)
)
result = await db.execute(stmt)
@@ -1057,7 +1136,7 @@ async def get_network_user_detail(
branch_revenue = 0
# Personal spent
spent_stmt = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
spent_stmt = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.user_id == user_id,
Transaction.type.in_(SPENT_TRANSACTION_TYPES),
@@ -1102,7 +1181,7 @@ async def get_network_user_detail(
# Branch revenue: total spent by all users in the branch
branch_user_ids_stmt = select(branch_cte.c.id)
branch_rev_stmt = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
branch_rev_stmt = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.user_id.in_(branch_user_ids_stmt),
Transaction.type.in_(SPENT_TRANSACTION_TYPES),
@@ -1124,10 +1203,19 @@ async def get_network_user_detail(
# Subscription info
subscription_name: str | None = None
subscription_end: str | None = None
if user.subscription is not None:
if user.subscription.tariff is not None:
subscription_name = user.subscription.tariff.name
subscription_end = _format_datetime(user.subscription.end_date)
subscription_status: str | None = None
subs = getattr(user, 'subscriptions', None) or []
subscription = next((s for s in subs if s.is_active), subs[0] if subs else None)
if subscription is not None:
if subscription.tariff is not None:
subscription_name = subscription.tariff.name
subscription_end = _format_datetime(subscription.end_date)
subscription_status = _compute_subscription_status(
subscription.is_trial,
subscription.status,
subscription.end_date,
datetime.now(UTC),
)
return NetworkUserDetail(
id=user.id,
@@ -1147,6 +1235,7 @@ async def get_network_user_detail(
personal_spent_kopeks=personal_spent,
subscription_name=subscription_name,
subscription_end=subscription_end,
subscription_status=subscription_status,
registered_at=_format_datetime(user.created_at),
)
@@ -1215,7 +1304,7 @@ async def get_network_campaign_detail(
total_spent = 0
if campaign_user_ids:
spent_stmt = (
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_(campaign_user_ids),
@@ -1336,7 +1425,7 @@ async def search_referral_network(
sub_info = await _fetch_subscription_info(db, matched_ids)
for user in matched_users:
sub = sub_info.get(user.id, (None, None))
sub = sub_info.get(user.id, (None, None, None))
user_nodes.append(
_build_user_node(
user,
@@ -1347,6 +1436,7 @@ async def search_referral_network(
campaign_id=campaign_regs.get(user.id),
subscription_name=sub[0],
subscription_end_str=sub[1],
subscription_status=sub[2],
)
)
@@ -1400,7 +1490,7 @@ async def search_referral_network(
campaign_user_spent: dict[int, int] = {}
if all_campaign_user_ids:
spent_stmt = (
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_(all_campaign_user_ids),
+13 -8
View File
@@ -5,6 +5,7 @@ from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.server_squad import (
@@ -129,22 +130,22 @@ def _serialize_node(node_data: dict[str, Any]) -> NodeInfo:
is_disabled=bool(node_data.get('is_disabled')),
is_node_online=bool(node_data.get('is_node_online')),
is_xray_running=bool(node_data.get('is_xray_running')),
users_online=node_data.get('users_online'),
users_online=node_data.get('users_online', 0),
traffic_used_bytes=node_data.get('traffic_used_bytes'),
traffic_limit_bytes=node_data.get('traffic_limit_bytes'),
last_status_change=_parse_datetime(node_data.get('last_status_change')),
last_status_message=node_data.get('last_status_message'),
xray_uptime=node_data.get('xray_uptime'),
xray_uptime=node_data.get('xray_uptime', 0) or 0,
is_traffic_tracking_active=bool(node_data.get('is_traffic_tracking_active', False)),
traffic_reset_day=node_data.get('traffic_reset_day'),
notify_percent=node_data.get('notify_percent'),
consumption_multiplier=float(node_data.get('consumption_multiplier', 1.0)),
cpu_count=node_data.get('cpu_count'),
cpu_model=node_data.get('cpu_model'),
total_ram=node_data.get('total_ram'),
created_at=_parse_datetime(node_data.get('created_at')),
updated_at=_parse_datetime(node_data.get('updated_at')),
provider_uuid=node_data.get('provider_uuid'),
versions=node_data.get('versions'),
system=node_data.get('system'),
active_plugin_uuid=node_data.get('active_plugin_uuid'),
)
@@ -208,11 +209,9 @@ async def get_system_statistics(
users_by_status=stats.get('users_by_status', {}),
server_info=ServerInfo(
cpu_cores=server_data.get('cpu_cores', 0),
cpu_physical_cores=server_data.get('cpu_physical_cores', 0),
memory_total=server_data.get('memory_total', 0),
memory_used=server_data.get('memory_used', 0),
memory_free=server_data.get('memory_free', 0),
memory_available=server_data.get('memory_available', 0),
uptime_seconds=server_data.get('uptime_seconds', 0),
),
bandwidth=Bandwidth(
@@ -397,15 +396,21 @@ async def perform_node_action(
)
class RestartAllNodesPayload(BaseModel):
force_restart: bool = False
@router.post('/nodes/restart-all', response_model=NodeActionResponse)
async def restart_all_nodes(
payload: RestartAllNodesPayload | None = None,
admin: User = Depends(require_permission('remnawave:manage')),
) -> NodeActionResponse:
"""Restart all nodes."""
service = _get_service()
_ensure_configured(service)
success = await service.restart_all_nodes()
force = payload.force_restart if payload else False
success = await service.restart_all_nodes(force_restart=force)
if success:
logger.info('Admin restarted all nodes', telegram_id=admin.telegram_id)
+18 -44
View File
@@ -441,6 +441,14 @@ async def assign_role(
detail='Role not found',
)
# Superadmin role is managed exclusively via ADMIN_IDS/ADMIN_EMAILS env config
if role.level >= SUPERADMIN_LEVEL:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
'Add the user there and restart the bot.',
)
admin_level = await _get_admin_level(db, admin)
# Cannot assign a role with level >= own level
@@ -450,13 +458,6 @@ async def assign_role(
detail='Cannot assign a role with level >= your own role level',
)
# Superadmin assignments must be permanent — expiry would cause silent lockout
if role.level == SUPERADMIN_LEVEL and payload.expires_at is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Superadmin role assignments cannot be time-limited',
)
# Verify target user exists
from app.database.crud.user import get_user_by_id
@@ -505,9 +506,7 @@ async def revoke_role(
admin: User = Depends(require_permission('roles:assign')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Revoke a role assignment. Cannot remove the last superadmin."""
from app.config import settings
from app.database.crud.user import get_user_by_id
"""Revoke a role assignment. Superadmin roles are managed via env config."""
from app.database.models import UserRole
# Lock the assignment row (FOR UPDATE held until commit)
@@ -526,6 +525,14 @@ async def revoke_role(
detail='Associated role not found',
)
# Superadmin role is managed exclusively via env config
if role.level >= SUPERADMIN_LEVEL:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
'Remove the user from env and restart the bot.',
)
admin_level = await _get_admin_level(db, admin)
# Cannot revoke a role at or above own level
@@ -535,33 +542,6 @@ async def revoke_role(
detail='Cannot revoke a role at or above your own level',
)
# Block self-revocation of superadmin role
if role.level == SUPERADMIN_LEVEL and user_role.user_id == admin.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot revoke your own superadmin role',
)
# Protect last superadmin (level 999).
# Advisory lock serializes concurrent superadmin revocations so two requests
# cannot both read count=2 and then both proceed to revoke.
if role.level == SUPERADMIN_LEVEL:
if not settings.is_sqlite():
await db.execute(sa.text('SELECT pg_advisory_xact_lock(736453)'))
superadmin_count = await UserRoleCRUD.get_superadmin_count(db)
if superadmin_count <= 1:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot remove the last superadmin',
)
# Warn if target user is a legacy admin — RBAC revocation won't actually block access
target_user = await get_user_by_id(db, user_role.user_id)
is_target_legacy = target_user and settings.is_admin(
telegram_id=target_user.telegram_id,
email=target_user.email if target_user.email_verified else None,
)
# Revoke directly on the locked object (avoid CRUD re-fetch without FOR UPDATE)
user_role.is_active = False
await db.flush()
@@ -575,10 +555,4 @@ async def revoke_role(
role_name=role.name,
)
result_msg = {'message': 'Role revoked', 'assignment_id': assignment_id}
if is_target_legacy:
result_msg['warning'] = (
'This user is still listed in ADMIN_IDS/ADMIN_EMAILS env config. '
'They retain full access until removed from those settings and the bot is restarted.'
)
return result_msg
return {'message': 'Role revoked', 'assignment_id': assignment_id}
+7 -7
View File
@@ -128,7 +128,7 @@ async def get_sales_summary(
# Manual top-ups by admins
manual_topup_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.DEPOSIT.value,
Transaction.is_completed == True,
@@ -246,7 +246,7 @@ async def get_sales_summary(
# Add-on revenue
addon_revenue_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,
@@ -256,7 +256,7 @@ async def get_sales_summary(
)
)
)
addon_revenue = abs(addon_revenue_result.scalar() or 0)
addon_revenue = addon_revenue_result.scalar() or 0
return SalesSummary(
total_revenue_kopeks=total_revenue + manual_topup,
@@ -1101,11 +1101,11 @@ async def get_deposits_stats(
select(
Transaction.payment_method.label('method'),
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
)
.where(base_filter)
.group_by(Transaction.payment_method)
.order_by(func.sum(Transaction.amount_kopeks).desc())
.order_by(func.sum(func.abs(Transaction.amount_kopeks)).desc())
)
by_method = [
DepositByMethodItem(method=row.method or 'unknown', count=row.count, amount_kopeks=row.amount)
@@ -1116,7 +1116,7 @@ async def get_deposits_stats(
select(
func.date(Transaction.created_at).label('date'),
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
)
.where(base_filter)
.group_by(func.date(Transaction.created_at))
@@ -1137,7 +1137,7 @@ async def get_deposits_stats(
select(
func.date(Transaction.created_at).label('date'),
Transaction.payment_method.label('method'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
)
.where(base_filter)
.group_by(func.date(Transaction.created_at), Transaction.payment_method)
+7 -14
View File
@@ -3,6 +3,7 @@
import sys
import time
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
@@ -49,15 +50,11 @@ class NodeStatus(BaseModel):
is_disabled: bool
users_online: int
traffic_used_bytes: int | None = None
uptime: str | None = None
xray_version: str | None = None
node_version: str | None = None
last_status_message: str | None = None
xray_uptime: str | None = None
xray_uptime: int = 0
is_xray_running: bool | None = None
cpu_count: int | None = None
cpu_model: str | None = None
total_ram: str | None = None
versions: dict[str, str] | None = None
system: dict[str, Any] | None = None
country_code: str | None = None
@@ -469,15 +466,11 @@ async def _get_nodes_overview() -> NodesOverview:
is_disabled=n.get('is_disabled', False),
users_online=n.get('users_online', 0) or 0,
traffic_used_bytes=n.get('traffic_used_bytes'),
uptime=n.get('uptime'),
xray_version=n.get('xray_version'),
node_version=n.get('node_version'),
last_status_message=n.get('last_status_message'),
xray_uptime=n.get('xray_uptime'),
xray_uptime=n.get('xray_uptime', 0) or 0,
is_xray_running=n.get('is_xray_running'),
cpu_count=n.get('cpu_count'),
cpu_model=n.get('cpu_model'),
total_ram=n.get('total_ram'),
versions=n.get('versions'),
system=n.get('system'),
country_code=n.get('country_code'),
)
for n in nodes
+11 -2
View File
@@ -8,6 +8,7 @@ from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.config import settings
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import (
create_tariff,
@@ -656,7 +657,11 @@ async def _background_sync_squads(tariff_id: int, admin_id: int) -> None:
async def _sync_one(sub: Subscription) -> None:
nonlocal updated, failed
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
remnawave_uuid = (
getattr(sub, 'remnawave_uuid', None)
if settings.is_multi_tariff_enabled()
else (sub.user.remnawave_uuid if sub.user else None)
)
if not remnawave_uuid:
return
async with semaphore:
@@ -767,7 +772,11 @@ async def sync_tariff_squads(
skipped_count += 1
return 'skipped'
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
remnawave_uuid = (
getattr(sub, 'remnawave_uuid', None)
if settings.is_multi_tariff_enabled()
else (sub.user.remnawave_uuid if sub.user else None)
)
if not remnawave_uuid:
skipped_count += 1
return 'skipped'
+89 -22
View File
@@ -21,6 +21,8 @@ from ..dependencies import get_cabinet_db, require_permission
from ..schemas.traffic import (
ExportCsvRequest,
ExportCsvResponse,
SubscriptionEnrichmentInfo,
SubscriptionTrafficInfo,
TrafficEnrichmentResponse,
TrafficNodeInfo,
TrafficUsageResponse,
@@ -156,15 +158,42 @@ def _compute_date_range(period_days: int) -> tuple[str, str]:
async def _load_user_map(db: AsyncSession) -> dict[str, User]:
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff."""
stmt = (
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff.
In multi-tariff mode UUIDs live on Subscription rows, not on User.
Both sources are merged so the caller gets a complete uuid User map.
"""
from app.config import settings
# Build user map from both user-level and subscription-level UUIDs
user_map: dict[str, User] = {}
# Legacy: user-level UUIDs
stmt_users = (
select(User)
.where(User.remnawave_uuid.isnot(None))
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
)
result = await db.execute(stmt)
users = result.scalars().all()
return {u.remnawave_uuid: u for u in users if u.remnawave_uuid}
result_users = await db.execute(stmt_users)
users = result_users.scalars().all()
for u in users:
if u.remnawave_uuid:
user_map[u.remnawave_uuid] = u
# Multi-tariff: subscription-level UUIDs
if settings.is_multi_tariff_enabled():
stmt_subs = (
select(Subscription)
.where(Subscription.remnawave_uuid.isnot(None))
.options(selectinload(Subscription.user).selectinload(User.subscriptions).selectinload(Subscription.tariff))
)
result_subs = await db.execute(stmt_subs)
subs = result_subs.scalars().all()
for sub in subs:
if sub.remnawave_uuid and sub.user and sub.remnawave_uuid not in user_map:
user_map[sub.remnawave_uuid] = sub.user
return user_map
def _build_traffic_items(
@@ -202,19 +231,23 @@ def _build_traffic_items(
):
continue
sub = user.subscription
subs = getattr(user, 'subscriptions', None) or []
# Primary subscription for backward-compat top-level fields
primary_sub = next((s for s in subs if s.is_active), subs[0] if subs else None)
tariff_name = None
subscription_status = None
traffic_limit_gb = 0.0
device_limit = 1
if sub:
subscription_status = _get_status(sub)
traffic_limit_gb = float(sub.traffic_limit_gb or 0)
device_limit = sub.device_limit or 1
if sub.tariff:
tariff_name = sub.tariff.name
if primary_sub:
subscription_status = _get_status(primary_sub)
traffic_limit_gb = float(primary_sub.traffic_limit_gb or 0)
device_limit = primary_sub.device_limit or 1
if primary_sub.tariff:
tariff_name = primary_sub.tariff.name
# Filtering uses primary sub values (keeps existing filter semantics)
if tariff_filter is not None:
if (tariff_name or '') not in tariff_filter:
continue
@@ -229,6 +262,18 @@ def _build_traffic_items(
total_bytes = sum(traffic.values())
# Build per-subscription detail list for multi-subscription display
subscriptions_traffic = [
SubscriptionTrafficInfo(
subscription_id=sub.id,
tariff_name=sub.tariff.name if sub.tariff else None,
status=_get_status(sub),
traffic_limit_gb=float(sub.traffic_limit_gb or 0),
device_limit=sub.device_limit or 1,
)
for sub in subs
]
items.append(
UserTrafficItem(
user_id=user.id,
@@ -242,6 +287,7 @@ def _build_traffic_items(
device_limit=device_limit,
node_traffic=traffic,
total_bytes=total_bytes,
subscriptions=subscriptions_traffic,
)
)
@@ -305,15 +351,21 @@ async def get_traffic_usage(
# Collect all available tariff names (before filtering)
available_tariffs = sorted(
{
u.subscription.tariff.name
sub.tariff.name
for u in user_map.values()
if u.subscription and u.subscription.tariff and u.subscription.tariff.name
for sub in (getattr(u, 'subscriptions', None) or [])
if sub.tariff and sub.tariff.name
}
)
# Collect all available statuses (before filtering)
available_statuses = sorted(
{_get_status(sub) for u in user_map.values() if (sub := u.subscription) and _get_status(sub)}
{
_get_status(sub)
for u in user_map.values()
for sub in (getattr(u, 'subscriptions', None) or [])
if _get_status(sub)
}
)
# Parse tariff filter
@@ -466,27 +518,42 @@ async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict
enrichment: dict[int, UserTrafficEnrichment] = {}
for uuid, user in user_map.items():
uid = user.id
sub = user.subscription
subs_list = getattr(user, 'subscriptions', None) or []
# Primary subscription for backward-compat top-level date fields
primary_sub = next((s for s in subs_list if s.is_active), subs_list[0] if subs_list else None)
start_date = None
end_date = None
if sub:
if sub.start_date:
start_date = sub.start_date.isoformat()
if sub.end_date:
end_date = sub.end_date.isoformat()
if primary_sub:
if primary_sub.start_date:
start_date = primary_sub.start_date.isoformat()
if primary_sub.end_date:
end_date = primary_sub.end_date.isoformat()
last_node_name = None
last_uuid = last_node_uuid_by_user.get(uid)
if last_uuid:
last_node_name = node_uuid_to_name.get(last_uuid)
# Build per-subscription enrichment list for multi-subscription display
subscriptions_enrichment = [
SubscriptionEnrichmentInfo(
subscription_id=sub.id,
tariff_name=sub.tariff.name if sub.tariff else None,
start_date=sub.start_date.isoformat() if sub.start_date else None,
end_date=sub.end_date.isoformat() if sub.end_date else None,
)
for sub in subs_list
]
enrichment[uid] = UserTrafficEnrichment(
devices_connected=devices_by_user.get(uid, 0),
total_spent_kopeks=spending_map.get(uid, 0),
subscription_start_date=start_date,
subscription_end_date=end_date,
last_node_name=last_node_name,
subscriptions=subscriptions_enrichment,
)
return enrichment
File diff suppressed because it is too large Load Diff
+316 -117
View File
@@ -61,6 +61,7 @@ from ..auth.email_verification import (
is_token_expired,
)
from ..auth.jwt_handler import get_refresh_token_expires_at
from ..auth.merge_service import create_merge_token
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..ip_utils import get_client_ip
from ..schemas.auth import (
@@ -143,22 +144,28 @@ async def _store_refresh_token(
refresh_token: str,
device_info: str | None = None,
) -> None:
"""Store refresh token hash in database."""
"""Store refresh token hash in database using upsert to avoid duplicate key errors."""
from sqlalchemy.dialects.postgresql import insert as pg_insert
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
expires_at = get_refresh_token_expires_at()
token_record = CabinetRefreshToken(
stmt = pg_insert(CabinetRefreshToken).values(
user_id=user_id,
token_hash=token_hash,
device_info=device_info,
expires_at=expires_at,
)
db.add(token_record)
try:
await db.commit()
except IntegrityError:
await db.rollback()
logger.debug('Refresh token already exists (duplicate)', user_id=user_id)
stmt = stmt.on_conflict_do_update(
index_elements=['token_hash'],
set_={
'expires_at': expires_at,
'device_info': device_info,
'revoked_at': None,
},
)
await db.execute(stmt)
await db.commit()
async def _process_campaign_bonus(
@@ -239,11 +246,39 @@ async def _process_referral_code(
db: AsyncSession,
user: User,
referral_code: str | None,
*,
is_new_user: bool = False,
) -> None:
"""Set referred_by_id for user if referral_code is valid. Never raises."""
if not referral_code or user.referred_by_id:
"""Process referral for a newly created user. Never raises.
Only applies to new users (is_new_user=True). Existing users cannot be
assigned a referrer same logic as the bot /start handler.
Handles two cases:
- referred_by_id already set by create_user() fire registration event
- referred_by_id not set (resolution failed earlier) resolve, set, fire
"""
if not referral_code or not is_new_user:
return
try:
from app.bot_factory import create_bot
# Lock user row to prevent concurrent referral application (TOCTOU race)
await db.execute(select(User).where(User.id == user.id).with_for_update())
await db.refresh(user)
# Case 1: referred_by_id already set by create_user() — just fire the event
if user.referred_by_id:
async with create_bot() as bot:
await process_referral_registration(db, user.id, user.referred_by_id, bot=bot)
logger.info(
'Referral registration processed for pre-set referrer',
user_id=user.id,
referrer_id=user.referred_by_id,
)
return
# Case 2: referred_by_id not set — resolve referral code and set it
referrer = await get_user_by_referral_code(db, referral_code)
if not referrer:
return
@@ -254,8 +289,6 @@ async def _process_referral_code(
user.referred_by_id = referrer.id
await db.flush()
from app.bot_factory import create_bot
async with create_bot() as bot:
await process_referral_registration(db, user.id, referrer.id, bot=bot)
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
@@ -288,94 +321,123 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
logger.debug('No subscription found in panel for email', email=user.email)
return
# Take first user if multiple found
panel_user = panel_users[0]
logger.info('Found subscription in panel for email', email=user.email, uuid=panel_user.uuid)
# Check if another user already owns this remnawave_uuid
from app.database.crud.user import get_user_by_remnawave_uuid
existing_owner = await get_user_by_remnawave_uuid(db, panel_user.uuid)
if existing_owner and existing_owner.id != user.id:
logger.warning(
'Panel UUID already belongs to another user, skipping sync',
email=user.email,
panel_uuid=panel_user.uuid,
existing_owner_id=existing_owner.id,
)
return
# Link user to panel
user.remnawave_uuid = panel_user.uuid
# Create or update subscription
from app.database.crud.subscription import get_subscription_by_user_id
# In multi-tariff mode, sync ALL panel users (each = one subscription)
# In single-tariff mode, process only the first
from app.database.crud.subscription import get_active_subscriptions_by_user_id, get_subscription_by_user_id
from app.database.models import Subscription, SubscriptionStatus
existing_sub = await get_subscription_by_user_id(db, user.id)
panel_users_to_sync = panel_users if settings.is_multi_tariff_enabled() else panel_users[:1]
# Parse panel data — panel returns local time with misleading +00:00 offset
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
for panel_user in panel_users_to_sync:
logger.info('Syncing panel subscription for email', email=user.email, uuid=panel_user.uuid)
# Extract squad UUIDs from active_internal_squads
connected_squads = [s.get('uuid', '') for s in (panel_user.active_internal_squads or []) if s.get('uuid')]
# Check if another user already owns this remnawave_uuid
if settings.is_multi_tariff_enabled():
from sqlalchemy import select as _select
# Device limit from panel
device_limit = panel_user.hwid_device_limit or 0
from app.database.models import Subscription as _Subscription
# Determine status — expire_at is now naive UTC
current_time = datetime.now(UTC)
_sub_result = await db.execute(
_select(_Subscription).where(_Subscription.remnawave_uuid == panel_user.uuid)
)
_existing_sub = _sub_result.scalar_one_or_none()
if _existing_sub and _existing_sub.user_id != user.id:
logger.warning(
'Panel UUID already owned by another user subscription, skipping',
email=user.email,
panel_uuid=panel_user.uuid,
existing_owner_id=_existing_sub.user_id,
)
continue
else:
from app.database.crud.user import get_user_by_remnawave_uuid
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
sub_status = SubscriptionStatus.ACTIVE
elif expire_at <= current_time:
sub_status = SubscriptionStatus.EXPIRED
else:
sub_status = SubscriptionStatus.DISABLED
existing_owner = await get_user_by_remnawave_uuid(db, panel_user.uuid)
if existing_owner and existing_owner.id != user.id:
logger.warning(
'Panel UUID already belongs to another user, skipping',
email=user.email,
panel_uuid=panel_user.uuid,
existing_owner_id=existing_owner.id,
)
continue
if existing_sub:
# Update existing subscription (expire_at already naive UTC)
existing_sub.end_date = expire_at
existing_sub.traffic_limit_gb = traffic_limit_gb
existing_sub.traffic_used_gb = traffic_used_gb
existing_sub.status = sub_status.value
existing_sub.remnawave_short_uuid = panel_user.short_uuid
existing_sub.subscription_url = panel_user.subscription_url
existing_sub.subscription_crypto_link = panel_user.happ_crypto_link
existing_sub.connected_squads = connected_squads
existing_sub.device_limit = device_limit
existing_sub.is_trial = False # Panel subscription is not trial
logger.info(
'Updated subscription for email user squads: devices',
email=user.email,
connected_squads=connected_squads,
device_limit=device_limit,
)
else:
# Create new subscription (expire_at and current_time already naive UTC)
new_sub = Subscription(
user_id=user.id,
start_date=current_time,
end_date=expire_at,
traffic_limit_gb=traffic_limit_gb,
traffic_used_gb=traffic_used_gb,
status=sub_status.value,
is_trial=False,
remnawave_short_uuid=panel_user.short_uuid,
subscription_url=panel_user.subscription_url,
subscription_crypto_link=panel_user.happ_crypto_link,
connected_squads=connected_squads,
device_limit=device_limit,
)
db.add(new_sub)
logger.info(
'Created subscription for email user squads: devices',
email=user.email,
connected_squads=connected_squads,
device_limit=device_limit,
# Link user to panel (only in single-tariff mode)
if not settings.is_multi_tariff_enabled():
user.remnawave_uuid = panel_user.uuid
# Find existing subscription
if settings.is_multi_tariff_enabled():
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
existing_sub = next(
(s for s in active_subs if s.remnawave_uuid == panel_user.uuid),
None,
)
else:
existing_sub = await get_subscription_by_user_id(db, user.id)
# Parse panel data
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
connected_squads = [
s.get('uuid', '') for s in (panel_user.active_internal_squads or []) if s.get('uuid')
]
device_limit = panel_user.hwid_device_limit or 0
# Determine status
current_time = datetime.now(UTC)
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
sub_status = SubscriptionStatus.ACTIVE
elif expire_at <= current_time:
sub_status = SubscriptionStatus.EXPIRED
else:
sub_status = SubscriptionStatus.DISABLED
if existing_sub:
existing_sub.end_date = expire_at
existing_sub.traffic_limit_gb = traffic_limit_gb
existing_sub.traffic_used_gb = traffic_used_gb
existing_sub.status = sub_status.value
existing_sub.remnawave_short_uuid = panel_user.short_uuid
existing_sub.subscription_url = panel_user.subscription_url
existing_sub.subscription_crypto_link = panel_user.happ_crypto_link
existing_sub.connected_squads = connected_squads
existing_sub.device_limit = device_limit
existing_sub.is_trial = False
logger.info(
'Updated subscription for email user',
email=user.email,
uuid=panel_user.uuid,
)
else:
from app.database.crud.subscription import generate_unique_short_id
_short_id = await generate_unique_short_id(db)
new_sub = Subscription(
user_id=user.id,
start_date=current_time,
end_date=expire_at,
traffic_limit_gb=traffic_limit_gb,
traffic_used_gb=traffic_used_gb,
status=sub_status.value,
is_trial=False,
remnawave_uuid=panel_user.uuid if settings.is_multi_tariff_enabled() else None,
remnawave_short_id=_short_id,
remnawave_short_uuid=panel_user.short_uuid,
subscription_url=panel_user.subscription_url,
subscription_crypto_link=panel_user.happ_crypto_link,
connected_squads=connected_squads,
device_limit=device_limit,
)
db.add(new_sub)
logger.info(
'Created subscription for email user',
email=user.email,
uuid=panel_user.uuid,
)
await db.commit()
@@ -405,7 +467,11 @@ async def auth_telegram(
detail='Too many requests',
headers={'Retry-After': '60'},
)
user_data = validate_telegram_init_data(request.init_data)
# Telegram Desktop/iOS cache initData with stale auth_date (known Telegram bug:
# https://github.com/telegramdesktop/tdesktop/issues/28303).
# Use generous max_age: HMAC signature proves authenticity,
# JWT tokens handle actual session expiration after login.
user_data = validate_telegram_init_data(request.init_data, max_age_seconds=86400 * 30)
if not user_data:
raise HTTPException(
@@ -434,10 +500,35 @@ async def auth_telegram(
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
if referrer.telegram_id and referrer.telegram_id == telegram_id:
logger.warning(
'Self-referral attempt blocked via telegram_id',
telegram_id=telegram_id,
referral_code=request.referral_code,
)
else:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
# Fallback: check Redis for pending referral from /start (user opened cabinet before completing bot registration)
if not referrer_id and not user and telegram_id:
try:
from app.services.referral_service import get_pending_referral
pending = await get_pending_referral(telegram_id)
if pending and pending.get('referrer_id'):
referrer_id = pending['referrer_id']
logger.info(
'Resolved referral from Redis pending_referral (cabinet)',
telegram_id=telegram_id,
referrer_id=referrer_id,
)
except Exception as e:
logger.warning('Failed to check pending referral', error=e)
is_new_user = not user
if not user:
# Create new user from Telegram initData
logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
@@ -481,8 +572,17 @@ 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 referral code (only for new users — existing users cannot be assigned a referrer)
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
# Clear Redis pending referral after successful user creation with referral
if referrer_id:
try:
from app.services.referral_service import clear_pending_referral
await clear_pending_referral(telegram_id)
except Exception:
pass
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
@@ -515,7 +615,8 @@ async def auth_telegram_widget(
widget_data = request.model_dump(exclude={'campaign_slug', 'referral_code'})
if not validate_telegram_login_widget(widget_data):
# Generous max_age: Telegram caches auth data with stale auth_date
if not validate_telegram_login_widget(widget_data, max_age_seconds=86400 * 30):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired Telegram authentication data',
@@ -529,10 +630,19 @@ async def auth_telegram_widget(
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
if referrer.telegram_id and referrer.telegram_id == request.id:
logger.warning(
'Self-referral attempt blocked via telegram_id',
telegram_id=request.id,
referral_code=request.referral_code,
)
else:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
is_new_user = not user
if not user:
# Create new user from Telegram data
logger.info(
@@ -569,8 +679,17 @@ async def auth_telegram_widget(
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process referral code (before campaign bonus, which may also set referrer)
await _process_referral_code(db, user, request.referral_code)
# Process referral code (only for new users — existing users cannot be assigned a referrer)
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
# Clear Redis pending referral after successful registration
if referrer_id and request.id:
try:
from app.services.referral_service import clear_pending_referral
await clear_pending_referral(request.id)
except Exception:
pass
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
@@ -661,10 +780,19 @@ async def auth_telegram_oidc(
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
except (ValueError, LookupError) as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=str(e))
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
if referrer.telegram_id and referrer.telegram_id == telegram_id:
logger.warning(
'Self-referral attempt blocked via telegram_id',
telegram_id=telegram_id,
referral_code=request.referral_code,
)
else:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
is_new_user = not user
if not user:
logger.info('Creating new user from cabinet OIDC', telegram_id=telegram_id, username=username)
user = await create_user(
@@ -698,7 +826,17 @@ async def auth_telegram_oidc(
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
await _process_referral_code(db, user, request.referral_code)
# Process referral code (only for new users — existing users cannot be assigned a referrer)
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
# Clear Redis pending referral after successful registration
if referrer_id and telegram_id:
try:
from app.services.referral_service import clear_pending_referral
await clear_pending_referral(telegram_id)
except Exception:
pass
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
@@ -710,6 +848,7 @@ async def auth_telegram_oidc(
@router.post('/email/register')
async def register_email(
request: EmailRegisterRequest,
raw_request: Request,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
@@ -718,7 +857,24 @@ async def register_email(
Requires valid JWT token from Telegram authentication.
Sends verification email to the provided address.
If the email belongs to another active user, offers account merge.
"""
# Rate limit
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'email_register', limit=5, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
# Check if user already has a verified email — block before doing anything else
if user.email and user.email_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='You already have a verified email',
)
# Check for disposable email
if disposable_email_service.is_disposable(request.email):
raise HTTPException(
@@ -726,21 +882,38 @@ async def register_email(
detail='Disposable email addresses are not allowed',
)
# Check if email already exists (case-insensitive)
# Check if email already exists (case-insensitive, exclude deleted users)
email_lower = (request.email or '').strip().lower()
existing_user = await db.execute(select(User).where(func.lower(User.email) == email_lower))
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This email is already registered',
existing_result = await db.execute(
select(User).where(
func.lower(User.email) == email_lower,
User.status != UserStatus.DELETED.value,
)
# Check if user already has email
if user.email and user.email_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='You already have a verified email',
)
existing_email_user = existing_result.scalar_one_or_none()
if existing_email_user:
if existing_email_user.id == user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This email is already linked to your account',
)
# Offer account merge instead of blocking
logger.info(
'Email register conflict: email already linked to another user, offering merge',
current_user_id=user.id,
existing_user_id=existing_email_user.id,
)
merge_token = await create_merge_token(
primary_user_id=user.id,
secondary_user_id=existing_email_user.id,
provider='email',
provider_id=email_lower,
)
return {
'message': 'Account merge required',
'merge_required': True,
'merge_token': merge_token,
}
# Update user
user.email = request.email
@@ -885,12 +1058,26 @@ async def register_email_standalone(
referred_by_id=referrer.id if referrer else None,
)
# Сохранить campaign_slug для обработки при верификации email
if request.campaign_slug:
user.pending_campaign_slug = request.campaign_slug
# Для тестового email или отключённой верификации - автоматически верифицировать
if is_test_email or not settings.is_cabinet_email_verification_enabled():
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
await db.commit()
logger.info('Email auto-verified (test or verification disabled)', email=request.email, user_id=user.id)
# Sync existing panel subscription (same as manual verification flow)
try:
await _sync_subscription_from_panel_by_email(db, user)
except Exception:
logger.warning('Failed to sync panel subscription after auto-verify', user_id=user.id, exc_info=True)
# Process campaign bonus immediately for auto-verified users
if request.campaign_slug:
await _process_campaign_bonus(db, user, request.campaign_slug)
user.pending_campaign_slug = None
await db.commit()
else:
# Сгенерировать токен верификации
verification_token = generate_verification_token()
@@ -1001,8 +1188,12 @@ async def verify_email(
response = await _create_auth_response(user, db)
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)
# Process campaign bonus (prefer request param, fallback to saved slug from registration)
effective_campaign_slug = request.campaign_slug or user.pending_campaign_slug
response.campaign_bonus = await _process_campaign_bonus(db, user, effective_campaign_slug)
if user.pending_campaign_slug:
user.pending_campaign_slug = None
await db.commit()
if response.campaign_bonus:
response.user = _user_to_response(user)
@@ -1793,6 +1984,14 @@ async def poll_deep_link_token(
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token, device_info='deep_link')
# Deep link auth is always for existing users — referral code not applicable
# (kept for campaign bonus processing only)
# 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)
logger.info('Deep link auth successful', user_id=user.id, telegram_id=user.telegram_id)
return response
+58 -12
View File
@@ -360,7 +360,7 @@ async def create_topup(
option = (request.payment_option or '').strip().lower()
# Use description with telegram_id for tax receipts
description = settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
)
if option == 'sbp':
result = await payment_service.create_yookassa_sbp_payment(
@@ -423,7 +423,7 @@ async def create_topup(
amount_usd=amount_usd,
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
payload=f'cabinet_topup_{user.id}_{request.amount_kopeks}',
)
@@ -484,7 +484,7 @@ async def create_topup(
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_code=method_code,
@@ -513,7 +513,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_return_url,
success_url=cabinet_success_url,
@@ -540,7 +542,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
@@ -570,8 +574,11 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method=option,
)
if result:
@@ -610,7 +617,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_success_url,
failed_url=cabinet_failed_url,
@@ -637,7 +646,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
telegram_id=user.telegram_id,
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_success_url,
@@ -665,7 +676,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
@@ -686,7 +699,7 @@ async def create_topup(
)
# Use payment_option to select sbp or card
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36}
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36, 'sberpay': 43}
option = (request.payment_option or '').strip().lower()
ps_id = KASSA_AI_OPTION_MAP.get(option) # None = use env default
@@ -695,7 +708,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_system_id=ps_id,
@@ -722,7 +737,9 @@ async def create_topup(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
success_url=cabinet_success_url,
fail_url=cabinet_failed_url,
@@ -748,6 +765,35 @@ async def create_topup(
payment_url = f'{settings.TRIBUTE_DONATE_LINK}&user_id={user_identifier}'
payment_id = f'tribute_{user_identifier}_{request.amount_kopeks}'
elif request.payment_method == 'severpay':
if not settings.is_severpay_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='SeverPay payment method is unavailable',
)
payment_service = PaymentService()
result = await payment_service.create_severpay_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create SeverPay payment',
)
else:
# For other payment methods, redirect to bot
raise HTTPException(
+20 -6
View File
@@ -9,14 +9,28 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.contest import (
create_attempt,
get_active_rounds,
get_attempt,
increment_winner_count,
)
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.subscription import get_active_subscriptions_by_user_id, get_subscription_by_user_id
from app.database.models import SubscriptionStatus, User
async def _resolve_subscription_for_prize(db, user_id: int):
"""Resolve best subscription for applying contest prize (days/traffic)."""
if settings.is_multi_tariff_enabled():
active_subs = await get_active_subscriptions_by_user_id(db, user_id)
# Prefer non-daily with most days left
non_daily = [s for s in active_subs if not (s.tariff and getattr(s.tariff, 'is_daily', False))]
eligible = non_daily or active_subs
return max(eligible, key=lambda s: s.days_left) if eligible else None
return await get_subscription_by_user_id(db, user_id)
from app.services.contest_rotation_service import (
GAME_ANAGRAM,
GAME_BLITZ,
@@ -98,7 +112,7 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
except ValueError:
return 'Error: invalid prize value'
subscription = await get_subscription_by_user_id(db, user_id)
subscription = await _resolve_subscription_for_prize(db, user_id)
if not subscription:
return 'Error: subscription not found'
@@ -151,7 +165,7 @@ async def get_contests_count(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get count of contests available for the user."""
subscription = await get_subscription_by_user_id(db, user.id)
subscription = await _resolve_subscription_for_prize(db, user.id)
if not _user_allowed(subscription):
return ContestsCountResponse(count=0)
@@ -183,7 +197,7 @@ async def get_contests(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available contests/games."""
subscription = await get_subscription_by_user_id(db, user.id)
subscription = await _resolve_subscription_for_prize(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
@@ -230,7 +244,7 @@ async def get_contest_game(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get game data for a specific contest round."""
subscription = await get_subscription_by_user_id(db, user.id)
subscription = await _resolve_subscription_for_prize(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
@@ -350,7 +364,7 @@ async def submit_contest_answer(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Submit answer for a contest round."""
subscription = await get_subscription_by_user_id(db, user.id)
subscription = await _resolve_subscription_for_prize(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
+3 -3
View File
@@ -425,8 +425,8 @@ async def create_gift_purchase(
warning=recipient_warning,
)
# Balance mode
if user.balance_kopeks < price_kopeks:
# Balance mode (skip for 100% discount)
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Insufficient balance',
@@ -724,7 +724,7 @@ async def activate_gift_by_code(
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
code = body.code.strip()
if code.upper().startswith('GIFT-'):
if code.upper().startswith('GIFT-') or code.upper().startswith('GIFT_'):
code = code[5:]
if len(code) < 8:
+177
View File
@@ -0,0 +1,177 @@
"""Public news routes for cabinet - user-facing news/blog section."""
import time
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.news import (
get_news_article_by_slug,
get_news_categories,
get_published_news,
get_published_news_count,
increment_views,
)
from app.database.models import NewsArticle, User
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.news import (
NewsArticleListItem,
NewsArticleResponse,
NewsListResponse,
)
logger = structlog.get_logger(__name__)
# Slug constraint: alphanumeric, hyphens, underscores, max 500 chars
_SLUG_MAX_LENGTH: int = 500
_SLUG_PATTERN: str = r'^[a-zA-Z0-9_-]+$'
# --- View counter deduplication ---
# In-memory TTL cache to prevent a single user from inflating view counts.
# Key: (user_id, article_id), Value: timestamp of last counted view.
# Views from the same user on the same article within _VIEW_DEDUP_SECONDS are ignored.
_VIEW_DEDUP_SECONDS: int = 300 # 5 minutes
_VIEW_DEDUP_MAX_SIZE: int = 10_000 # max entries before eviction
_view_dedup_cache: dict[tuple[int, int], float] = {}
def _should_count_view(user_id: int, article_id: int) -> bool:
"""Return True if this view should be counted (not a duplicate within TTL)."""
now = time.monotonic()
key = (user_id, article_id)
last_seen = _view_dedup_cache.get(key)
if last_seen is not None and (now - last_seen) < _VIEW_DEDUP_SECONDS:
return False
# Evict stale entries if cache grows too large
if len(_view_dedup_cache) >= _VIEW_DEDUP_MAX_SIZE:
cutoff = now - _VIEW_DEDUP_SECONDS
stale_keys = [k for k, v in _view_dedup_cache.items() if v < cutoff]
for k in stale_keys:
del _view_dedup_cache[k]
_view_dedup_cache[key] = now
return True
router = APIRouter(prefix='/news', tags=['Cabinet News'])
def _article_to_response(article: NewsArticle, *, include_content: bool = True) -> dict[str, Any]:
"""Convert NewsArticle ORM instance to response dict.
``author_name`` is only resolved when ``include_content=True`` (single-article
detail view) because the author relationship is not eagerly loaded for list
queries -- accessing it there would trigger a lazy-load or raise
``MissingGreenlet`` in async context.
"""
data: dict[str, Any] = {
'id': article.id,
'title': article.title,
'slug': article.slug,
'excerpt': article.excerpt,
'category': article.category,
'category_color': article.category_color,
'tag': article.tag,
'featured_image_url': article.featured_image_url,
'is_published': article.is_published,
'is_featured': article.is_featured,
'published_at': article.published_at,
'read_time_minutes': article.read_time_minutes,
'views_count': article.views_count,
}
if include_content:
author_name: str | None = None
if article.author:
author_name = article.author.first_name or article.author.username or f'#{article.author.id}'
data['content'] = article.content
data['author_name'] = author_name
data['created_at'] = article.created_at
data['updated_at'] = article.updated_at
return data
# NOTE: /categories MUST be declared before /{slug} to avoid route conflict
@router.get('/categories', response_model=list[str])
async def list_categories(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[str]:
"""Get list of distinct news categories."""
try:
return await get_news_categories(db)
except Exception:
logger.exception('Failed to get news categories')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load categories',
)
@router.get('', response_model=NewsListResponse)
async def list_published_news(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
category: str | None = Query(None, max_length=100),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> NewsListResponse:
"""Get paginated list of published news articles.
SQLAlchemy AsyncSession does not support concurrent operations, so
queries run sequentially.
"""
try:
articles = await get_published_news(db, category=category, limit=limit, offset=offset)
total = await get_published_news_count(db, category=category)
categories = await get_news_categories(db)
items = [NewsArticleListItem(**_article_to_response(a, include_content=False)) for a in articles]
return NewsListResponse(items=items, total=total, categories=categories)
except HTTPException:
raise
except Exception:
logger.exception('Failed to list published news')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load news',
)
@router.get('/{slug}', response_model=NewsArticleResponse)
async def get_article_by_slug(
slug: str = Path(..., max_length=_SLUG_MAX_LENGTH, pattern=_SLUG_PATTERN),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> NewsArticleResponse:
"""Get a single published news article by slug. Increments view count."""
article = await get_news_article_by_slug(db, slug)
if not article or not article.is_published:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Article not found',
)
# Build response dict while session attributes are still loaded.
# increment_views() calls db.commit() which expires all ORM attributes;
# accessing them afterwards triggers lazy-load → MissingGreenlet in async.
response_data = _article_to_response(article, include_content=True)
# Increment views with per-user deduplication (5-min TTL).
if _should_count_view(user.id, article.id):
try:
new_count = await increment_views(db, article.id)
response_data['views_count'] = new_count
except Exception:
logger.warning('Failed to increment views', article_id=article.id)
return NewsArticleResponse(**response_data)
+20 -3
View File
@@ -40,6 +40,8 @@ async def _finalize_oauth_login(
provider: str,
campaign_slug: str | None = None,
referral_code: str | None = None,
*,
is_new_user: bool = False,
) -> AuthResponse:
"""Update last login, create tokens, store refresh token."""
user.cabinet_last_login = datetime.now(UTC)
@@ -47,10 +49,10 @@ async def _finalize_oauth_login(
auth_response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
# Process referral code (before campaign bonus, which may also set referrer)
# Process referral code (only for new users — existing users cannot be assigned a referrer)
from .auth import _process_referral_code, _user_to_response
await _process_referral_code(db, user, referral_code)
await _process_referral_code(db, user, referral_code, is_new_user=is_new_user)
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
if auth_response.campaign_bonus:
@@ -232,4 +234,19 @@ async def oauth_callback(
referred_by_id=referrer_id,
)
logger.info('New OAuth user created', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
# Commit user before panel sync (sync does its own commit/rollback)
await db.commit()
# Sync existing panel subscriptions by email (if verified)
if user_info.email and user_info.email_verified:
try:
from app.cabinet.routes.auth import _sync_subscription_from_panel_by_email
await _sync_subscription_from_panel_by_email(db, user)
except Exception:
logger.warning('Failed to sync panel subscription for new OAuth user', user_id=user.id, exc_info=True)
return await _finalize_oauth_login(
db, user, provider, request.campaign_slug, request.referral_code, is_new_user=True
)
+1 -1
View File
@@ -309,7 +309,7 @@ async def claim_promo_offer(
# Handle test access offers
if effect_type == 'test_access':
await db.refresh(user, ['subscription'])
await db.refresh(user, ['subscriptions'])
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
db,
user,
+16 -2
View File
@@ -20,6 +20,7 @@ class PromocodeActivateRequest(BaseModel):
"""Request to activate a promo code."""
code: str = Field(..., min_length=1, max_length=50, description='Promo code to activate')
subscription_id: int | None = Field(None, description='Subscription ID for multi-tariff promo codes')
class PromocodeActivateResponse(BaseModel):
@@ -41,7 +42,7 @@ class PromocodeDeactivateResponse(BaseModel):
discount_percent: int = 0
@router.post('/activate', response_model=PromocodeActivateResponse)
@router.post('/activate')
async def activate_promocode(
request: PromocodeActivateRequest,
user: User = Depends(get_current_cabinet_user),
@@ -50,7 +51,17 @@ async def activate_promocode(
"""Activate a promo code for the current user."""
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db=db, user_id=user.id, code=request.code.strip())
result = await promocode_service.activate_promocode(
db=db, user_id=user.id, code=request.code.strip(), subscription_id=request.subscription_id
)
if result.get('error') == 'select_subscription':
return {
'success': False,
'error': 'select_subscription',
'eligible_subscriptions': result.get('eligible_subscriptions', []),
'code': result.get('code', request.code.strip()),
}
if result['success']:
balance_before_rubles = result.get('balance_before_kopeks', 0) / 100
@@ -68,10 +79,13 @@ async def activate_promocode(
error_messages = {
'not_found': 'Promo code not found',
'expired': 'Promo code has expired',
'inactive': 'Promo code is deactivated',
'not_yet_valid': 'Promo code is not yet active',
'used': 'Promo code has been fully used',
'already_used_by_user': 'You have already used this promo code',
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
'no_subscription_for_days': 'This promo code requires an active or expired subscription',
'subscription_not_found': 'Subscription not found',
'not_first_purchase': 'This promo code is only available for first purchase',
'daily_limit': 'Too many promo code activations today',
'user_not_found': 'User not found',
+6 -2
View File
@@ -119,7 +119,11 @@ async def get_referral_list(
):
"""Get list of invited users."""
# Base query with eager loading of subscription relationship
query = select(User).options(selectinload(User.subscription)).where(User.referred_by_id == user.id)
query = (
select(User)
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
.where(User.referred_by_id == user.id)
)
# Get total count
count_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id)
@@ -139,7 +143,7 @@ async def get_referral_list(
username=r.username,
first_name=r.first_name,
created_at=r.created_at,
has_subscription=r.subscription is not None,
has_subscription=bool(getattr(r, 'subscriptions', None)),
has_paid=r.has_had_paid_subscription,
)
for r in referrals
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
"""Subscription sub-modules for cabinet API.
Each module contains a subset of endpoints from the original monolithic subscription.py.
The main subscription.py includes all sub-routers for backward compatibility.
"""
from .autopay import router as autopay_router
from .daily import router as daily_router
from .devices import router as devices_router
from .multi_tariff import router as multi_tariff_router
from .purchase import router as purchase_router
from .renewal import router as renewal_router
from .servers import router as servers_router
from .status import router as status_router
from .tariff_switch import router as tariff_switch_router
from .traffic import router as traffic_router
__all__ = [
'autopay_router',
'daily_router',
'devices_router',
'multi_tariff_router',
'purchase_router',
'renewal_router',
'servers_router',
'status_router',
'tariff_switch_router',
'traffic_router',
]
@@ -0,0 +1,78 @@
"""Autopay settings endpoint.
PATCH /subscription/autopay
"""
from __future__ import annotations
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from ...dependencies import get_cabinet_db, get_current_cabinet_user
from ...schemas.subscription import AutopayUpdateRequest
logger = structlog.get_logger(__name__)
router = APIRouter()
@router.patch('/autopay')
async def update_autopay(
request: AutopayUpdateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
):
"""Update autopay settings."""
from .helpers import resolve_subscription
subscription = await resolve_subscription(db, user, subscription_id)
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
if request.enabled:
# Classic subscriptions cannot use autopay when tariff mode is enabled
from app.config import settings
if settings.is_tariffs_mode() and not subscription.tariff_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Autopay is not available for classic subscriptions. Please purchase a tariff.',
)
# Триальные подписки — пробник, автопродление не имеет смысла
if subscription.is_trial:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Autopay is not available for trial subscriptions',
)
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
# глобальный autopay для них запрещён
await db.refresh(subscription, ['tariff'])
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Autopay is not available for daily subscriptions',
)
subscription.autopay_enabled = request.enabled
if request.days_before is not None:
subscription.autopay_days_before = request.days_before
await db.commit()
return {
'message': 'Autopay settings updated',
'autopay_enabled': subscription.autopay_enabled,
'autopay_days_before': subscription.autopay_days_before,
}
@@ -0,0 +1,190 @@
"""Daily subscription management endpoints.
POST /subscription/pause
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.tariff import get_tariff_by_id
from app.database.models import User
from app.services.subscription_service import SubscriptionService
from ...dependencies import get_cabinet_db, get_current_cabinet_user
from .helpers import resolve_subscription
logger = structlog.get_logger(__name__)
router = APIRouter()
@router.post('/pause')
async def toggle_subscription_pause(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
) -> dict[str, Any]:
"""Toggle pause/resume for daily subscription."""
subscription = await resolve_subscription(db, user, subscription_id)
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
tariff_id = getattr(subscription, 'tariff_id', None)
if not tariff_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Subscription has no tariff',
)
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not getattr(tariff, 'is_daily', False):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Pause is only available for daily tariffs',
)
# Determine current state
from app.database.models import SubscriptionStatus
is_currently_paused = getattr(subscription, 'is_daily_paused', False)
was_disabled = subscription.status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.LIMITED.value,
)
# System-DISABLED subs (insufficient balance) should always be treated as needing resume,
# even if is_daily_paused is False (it's set by the system, not the user)
if was_disabled and not is_currently_paused:
new_paused_state = False # Force resume path
else:
new_paused_state = not is_currently_paused
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
# IMPORTANT: must happen BEFORE modifying subscription — lock_user_for_pricing
# reloads subscriptions via selectinload which resets in-memory changes
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Re-fetch subscription after lock (selectinload may have replaced the ORM object)
subscription = await resolve_subscription(db, user, subscription_id)
if not subscription:
raise HTTPException(status_code=404, detail='Subscription not found after lock')
subscription.is_daily_paused = new_paused_state
# Apply group discount to daily price (consistent with DailySubscriptionService and miniapp resume)
from app.services.pricing_engine import PricingEngine
promo_group = PricingEngine.resolve_promo_group(user)
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
daily_price = (
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
)
# If resuming, check balance and charge
if not new_paused_state:
if daily_price > 0 and user.balance_kopeks < daily_price:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_balance',
'message': 'Insufficient balance to resume daily subscription',
'required': daily_price,
'balance': user.balance_kopeks,
},
)
# Charge daily fee FIRST, then restore ACTIVE status
if was_disabled:
if daily_price > 0:
from app.database.crud.user import subtract_user_balance
deducted = await subtract_user_balance(
db,
user,
daily_price,
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
mark_as_paid_subscription=True,
)
if not deducted:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_balance',
'message': 'Balance deduction failed',
'required': daily_price,
'balance': user.balance_kopeks,
},
)
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
try:
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
)
except Exception as exc:
logger.warning('Failed to create resume transaction', error=exc)
# Balance deducted successfully — now activate
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
await db.commit()
await db.refresh(subscription)
await db.refresh(user)
# Sync with RemnaWave only when resuming from DISABLED state
if not new_paused_state and was_disabled:
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
except Exception as e:
logger.error('Error syncing RemnaWave user on resume', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create',
)
if new_paused_state:
message = 'Daily subscription paused'
else:
message = 'Daily subscription resumed'
return {
'success': True,
'message': message,
'is_paused': new_paused_state,
'balance_kopeks': user.balance_kopeks,
'balance_label': settings.format_price(user.balance_kopeks),
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,234 @@
"""Shared helper functions for subscription modules."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
import structlog
from app.config import settings
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Subscription, User
from ...schemas.subscription import (
ServerInfo,
SubscriptionResponse,
)
logger = structlog.get_logger(__name__)
async def resolve_subscription(
db: AsyncSession,
user: User,
subscription_id: int | None,
) -> Subscription | None:
"""Resolve target subscription: by ID in multi-tariff mode, or legacy fallback.
Args:
db: Database session.
user: Current user.
subscription_id: Optional subscription ID (from query param).
Returns:
Target Subscription or None if not found.
Raises:
HTTPException: If subscription_id provided but not found for this user.
"""
from fastapi import HTTPException
from app.database.crud.subscription import get_subscription_by_id_for_user
if subscription_id and settings.is_multi_tariff_enabled():
subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id)
if not subscription:
raise HTTPException(status_code=404, detail='Subscription not found')
return subscription
if settings.is_multi_tariff_enabled() and not subscription_id:
from app.database.crud.subscription import get_active_subscriptions_by_user_id
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
if active_subs:
non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)]
pool = non_daily or active_subs
return max(pool, key=lambda s: s.days_left)
return None
await db.refresh(user, ['subscriptions'])
return user.subscription
def _get_addon_discount_percent(
user: User | None,
category: str,
period_days_hint: int | None = None,
) -> int:
"""Get addon discount percent for user — delegates to PricingEngine."""
from app.services.pricing_engine import PricingEngine
return PricingEngine.get_addon_discount_percent(user, category, period_days_hint)
def _apply_addon_discount(
user: User,
category: str,
amount: int,
period_days: int | None = None,
) -> dict[str, int]:
"""Apply addon discount to amount.
Returns dict with keys: discounted, discount, percent
"""
from app.utils.pricing_utils import apply_percentage_discount
percent = _get_addon_discount_percent(user, category, period_days)
if percent <= 0 or amount <= 0:
return {'discounted': amount, 'discount': 0, 'percent': 0}
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
return {
'discounted': discounted_amount,
'discount': discount_value,
'percent': percent,
}
def _subscription_to_response(
subscription: Subscription,
servers: list[ServerInfo] | None = None,
tariff_name: str | None = None,
traffic_purchases: list[dict[str, Any]] | None = None,
user: User | None = None,
) -> SubscriptionResponse:
"""Convert Subscription model to response."""
now = datetime.now(UTC)
# Use actual_status property for correct status (same as bot uses)
actual_status = subscription.actual_status
is_expired = actual_status == 'expired'
is_active = actual_status in ('active', 'trial')
is_limited = actual_status == 'limited'
# Calculate time remaining
days_left = 0
hours_left = 0
minutes_left = 0
time_left_display = ''
if subscription.end_date and not is_expired:
time_delta = subscription.end_date - now
total_seconds = max(0, int(time_delta.total_seconds()))
days_left = total_seconds // 86400 # 86400 seconds in a day
remaining_seconds = total_seconds % 86400
hours_left = remaining_seconds // 3600
minutes_left = (remaining_seconds % 3600) // 60
# Create human-readable display
if days_left > 0:
time_left_display = f'{days_left}d {hours_left}h'
elif hours_left > 0:
time_left_display = f'{hours_left}h {minutes_left}m'
elif minutes_left > 0:
time_left_display = f'{minutes_left}m'
else:
time_left_display = '0m'
else:
time_left_display = '0m'
traffic_limit_gb = subscription.traffic_limit_gb or 0
traffic_used_gb = subscription.traffic_used_gb or 0.0
if traffic_limit_gb > 0:
traffic_used_percent = min(100, (traffic_used_gb / traffic_limit_gb) * 100)
else:
traffic_used_percent = 0
# Check if this is a daily tariff
is_daily_paused = getattr(subscription, 'is_daily_paused', False) or False
tariff_id = getattr(subscription, 'tariff_id', None)
# Use subscription's is_daily_tariff property if available
is_daily = False
daily_price_kopeks = None
if hasattr(subscription, 'is_daily_tariff'):
is_daily = subscription.is_daily_tariff
elif tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
is_daily = getattr(subscription.tariff, 'is_daily', False)
# 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)
# Применяем скидку промогруппы + promo-offer для отображения
if daily_price_kopeks and daily_price_kopeks > 0 and user:
from app.services.pricing_engine import PricingEngine
from app.utils.promo_offer import get_user_active_promo_discount_percent
_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
_group_pct = _promo_group.get_discount_percent('period', 1) if _promo_group else 0
_offer_pct = get_user_active_promo_discount_percent(user)
if _group_pct > 0 or _offer_pct > 0:
daily_price_kopeks, _, _ = PricingEngine.apply_stacked_discounts(
daily_price_kopeks, _group_pct, _offer_pct
)
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
if is_daily and not is_daily_paused:
last_charge = getattr(subscription, 'last_daily_charge_at', None)
if last_charge:
next_charge = last_charge + timedelta(days=1)
# Если время списания уже прошло — не показываем (DailySubscriptionService обработает)
if next_charge > datetime.now(UTC):
next_daily_charge_at = next_charge
# Проверяем настройку скрытия ссылки (скрывается только текст, кнопки работают)
hide_link = settings.should_hide_subscription_link()
return SubscriptionResponse(
id=subscription.id,
status=actual_status, # Use actual_status instead of raw status
is_trial=subscription.is_trial or actual_status == 'trial',
start_date=subscription.start_date,
end_date=subscription.end_date,
days_left=days_left,
hours_left=hours_left,
minutes_left=minutes_left,
time_left_display=time_left_display,
traffic_limit_gb=traffic_limit_gb,
traffic_used_gb=round(traffic_used_gb, 2),
traffic_used_percent=round(traffic_used_percent, 1),
device_limit=subscription.device_limit or 0,
connected_squads=subscription.connected_squads or [],
servers=servers or [],
autopay_enabled=subscription.autopay_enabled or False,
autopay_days_before=subscription.autopay_days_before or 3,
subscription_url=subscription.subscription_url,
hide_subscription_link=hide_link,
is_active=is_active,
is_expired=is_expired,
is_limited=is_limited,
traffic_purchases=traffic_purchases or [],
is_daily=is_daily,
is_daily_paused=is_daily_paused,
daily_price_kopeks=daily_price_kopeks,
next_daily_charge_at=next_daily_charge_at,
tariff_id=tariff_id,
tariff_name=tariff_name,
traffic_reset_mode=traffic_reset_mode,
)
@@ -0,0 +1,156 @@
"""Multi-tariff subscription endpoints for cabinet API.
GET /subscriptions list all user subscriptions (multi-tariff)
GET /subscriptions/{id} get specific subscription details
"""
from __future__ import annotations
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.subscription import (
decrement_subscription_server_counts,
get_all_subscriptions_by_user_id,
get_subscription_by_id_for_user,
)
from app.database.models import SubscriptionStatus, User
from ...dependencies import get_cabinet_db, get_current_cabinet_user
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/subscriptions', tags=['Cabinet Multi-Tariff'], redirect_slashes=False)
class SubscriptionListItem(BaseModel):
id: int
status: str
tariff_id: int | None = None
tariff_name: str | None = None
traffic_limit_gb: int = 0
traffic_used_gb: float = 0.0
device_limit: int = 1
end_date: str | None = None
subscription_url: str | None = None
subscription_crypto_link: str | None = None
is_trial: bool = False
is_daily: bool = False
is_daily_paused: bool = False
autopay_enabled: bool = False
connected_squads: list[str] | None = None
class SubscriptionsListResponse(BaseModel):
subscriptions: list[SubscriptionListItem]
multi_tariff_enabled: bool
def _subscription_to_list_item(sub) -> SubscriptionListItem:
tariff_name = None
if sub.tariff:
tariff_name = sub.tariff.name
return SubscriptionListItem(
id=sub.id,
status=sub.actual_status,
tariff_id=sub.tariff_id,
tariff_name=tariff_name,
traffic_limit_gb=sub.traffic_limit_gb or 0,
traffic_used_gb=sub.traffic_used_gb or 0.0,
device_limit=sub.device_limit or 1,
end_date=sub.end_date.isoformat() if sub.end_date else None,
subscription_url=sub.subscription_url,
subscription_crypto_link=sub.subscription_crypto_link,
is_trial=sub.is_trial or False,
is_daily=bool(sub.tariff and getattr(sub.tariff, 'is_daily', False)),
is_daily_paused=bool(getattr(sub, 'is_daily_paused', False)),
autopay_enabled=sub.autopay_enabled or False,
connected_squads=sub.connected_squads,
)
@router.get('', response_model=SubscriptionsListResponse)
async def list_subscriptions(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SubscriptionsListResponse:
"""List all user subscriptions. Returns all subscriptions regardless of multi-tariff mode."""
subscriptions = await get_all_subscriptions_by_user_id(db, user.id)
items = [_subscription_to_list_item(sub) for sub in subscriptions]
return SubscriptionsListResponse(
subscriptions=items,
multi_tariff_enabled=settings.is_multi_tariff_enabled(),
)
@router.get('/{subscription_id}', response_model=SubscriptionListItem)
async def get_subscription_detail(
subscription_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SubscriptionListItem:
"""Get specific subscription details with ownership check."""
subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id)
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Subscription not found',
)
return _subscription_to_list_item(subscription)
@router.delete('/{subscription_id}')
async def delete_subscription(
subscription_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict:
"""Delete an expired/disabled subscription. Active subscriptions cannot be deleted."""
subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id)
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Subscription not found',
)
# Only expired/disabled subscriptions can be deleted
deletable_statuses = {
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
}
if getattr(subscription, 'actual_status', subscription.status) not in deletable_statuses:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Only expired or disabled subscriptions can be deleted',
)
# Delete from RemnaWave panel (stops webhooks / phantom notifications)
if subscription.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
service = SubscriptionService()
await service.delete_remnawave_user(subscription.remnawave_uuid)
except Exception as e:
logger.warning('Failed to delete RemnaWave user on subscription delete', error=e)
# Decrement server counts
await decrement_subscription_server_counts(db, subscription)
# Delete the subscription
await db.delete(subscription)
await db.commit()
logger.info(
'Subscription deleted by user',
subscription_id=subscription_id,
user_id=user.id,
tariff_id=subscription.tariff_id,
)
return {'message': 'Subscription deleted'}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,269 @@
"""Subscription renewal endpoints.
GET /subscription/renewal-options
POST /subscription/renew
"""
from __future__ import annotations
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.tariff import get_tariff_by_id
from app.database.models import PaymentMethod, SubscriptionStatus, User
from app.services.pricing_engine import pricing_engine
from app.services.subscription_renewal_service import (
SubscriptionRenewalChargeError,
SubscriptionRenewalService,
)
from app.services.user_cart_service import user_cart_service
from ...dependencies import get_cabinet_db, get_current_cabinet_user
from ...schemas.subscription import (
RenewalOptionResponse,
RenewalRequest,
)
logger = structlog.get_logger(__name__)
router = APIRouter()
@router.get('/renewal-options', response_model=list[RenewalOptionResponse])
async def get_renewal_options(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
):
"""Get available subscription renewal options with prices."""
from .helpers import resolve_subscription
subscription = await resolve_subscription(db, user, subscription_id)
if not subscription:
return []
# Classic subscriptions cannot be renewed when tariff mode is enabled
if settings.is_tariffs_mode() and not subscription.tariff_id:
return []
_non_renewable = {SubscriptionStatus.DISABLED.value, SubscriptionStatus.PENDING.value}
_actual_status = getattr(subscription, 'actual_status', subscription.status)
if _actual_status in _non_renewable:
return []
# Determine available periods
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
periods = sorted(int(k) for k in subscription.tariff.period_prices.keys())
else:
periods = settings.get_available_renewal_periods()
options = []
for period in periods:
pricing = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
if pricing.final_total <= 0 and pricing.original_total <= 0:
continue
original_price = pricing.original_total
combined_discount = 0
if original_price > 0 and original_price != pricing.final_total:
combined_discount = int((original_price - pricing.final_total) * 100 / original_price)
options.append(
RenewalOptionResponse(
period_days=period,
price_kopeks=pricing.final_total,
price_rubles=pricing.final_total / 100,
discount_percent=combined_discount,
original_price_kopeks=original_price if combined_discount > 0 else None,
)
)
return options
@router.post('/renew')
async def renew_subscription(
request: RenewalRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
):
"""Renew subscription (pay from balance)."""
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Subscription renewal is restricted for this account',
)
# Support subscription_id from both query param and body (backward compat)
from .helpers import resolve_subscription
_sub_id = subscription_id or request.subscription_id
subscription = await resolve_subscription(db, user, _sub_id)
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
# Classic subscriptions cannot be renewed when tariff mode is enabled
if settings.is_tariffs_mode() and not subscription.tariff_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Classic subscriptions cannot be renewed. Please purchase a tariff.',
)
_non_renewable = {SubscriptionStatus.DISABLED.value, SubscriptionStatus.PENDING.value}
_actual_status = getattr(subscription, 'actual_status', subscription.status)
if _actual_status in _non_renewable:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Cannot renew subscription with status: {_actual_status}',
)
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
available_periods = [int(p) for p in subscription.tariff.period_prices.keys()]
else:
available_periods = settings.get_available_renewal_periods()
if request.period_days not in available_periods:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Selected renewal period is not available',
)
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Unified pricing via PricingEngine
pricing = await pricing_engine.calculate_renewal_price(
db,
subscription,
request.period_days,
user=user,
)
price_kopeks = pricing.final_total
promo_offer_discount_value = pricing.promo_offer_discount
promo_offer_discount_percent = pricing.breakdown.get('offer_discount_pct', 0)
if price_kopeks <= 0 and pricing.original_total <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid renewal period',
)
original_price_kopeks = pricing.original_total
discount_percent = 0
if original_price_kopeks > 0 and original_price_kopeks != price_kopeks:
discount_percent = int((original_price_kopeks - price_kopeks) * 100 / original_price_kopeks)
tariff = subscription.tariff if subscription.tariff_id else None
# Check balance (skip for 100% discount)
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
# Get tariff info for cart
tariff_id = subscription.tariff_id
tariff_name = None
tariff_traffic_limit_gb = None
tariff_allowed_squads = None
if tariff_id:
tariff = await get_tariff_by_id(db, tariff_id)
if tariff:
tariff_name = tariff.name
tariff_traffic_limit_gb = tariff.traffic_limit_gb
tariff_allowed_squads = tariff.allowed_squads or []
# Save cart for auto-purchase after balance top-up
cart_data: dict[str, Any] = {
'cart_mode': 'extend',
'subscription_id': subscription.id,
'tariff_id': tariff_id,
'period_days': request.period_days,
'total_price': price_kopeks,
'user_id': user.id,
'saved_cart': True,
'missing_amount': missing,
'return_to_cart': True,
'description': f'Продление подписки на {request.period_days} дней'
+ (f' ({tariff_name})' if tariff_name else ''),
'discount_percent': discount_percent,
'consume_promo_offer': promo_offer_discount_value > 0,
'source': 'cabinet',
}
# Add subscription parameters for auto-purchase
if tariff_id:
cart_data['traffic_limit_gb'] = tariff_traffic_limit_gb
# Сохраняем актуальный device_limit подписки (включая докупленные устройства)
cart_data['device_limit'] = subscription.device_limit
cart_data['allowed_squads'] = tariff_allowed_squads
else:
# Classic mode: сохраняем текущие параметры подписки для корректной автопокупки
cart_data['device_limit'] = subscription.device_limit
cart_data['traffic_limit_gb'] = subscription.traffic_limit_gb
try:
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info('Cart saved for auto-renewal (cabinet) user', user_id=user.id)
except Exception as e:
logger.error('Error saving cart for auto-renewal (cabinet)', error=e)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_funds',
'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}',
'missing_amount': missing,
'cart_saved': True,
'cart_mode': 'extend',
},
)
# Centralized renewal: balance deduction, extension, RemnaWave sync, admin notification,
# server price recording, and compensating refund on failure.
renewal_description = f'Продление подписки на {request.period_days} дней' + (f' ({tariff.name})' if tariff else '')
renewal_service = SubscriptionRenewalService()
try:
result = await renewal_service.finalize(
db,
user,
subscription,
pricing,
description=renewal_description,
payment_method=PaymentMethod.BALANCE,
)
except SubscriptionRenewalChargeError:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_funds',
'message': 'Недостаточно средств (concurrent check)',
},
)
response: dict[str, Any] = {
'message': 'Subscription renewed successfully',
'new_end_date': result.subscription.end_date.isoformat(),
'amount_paid_kopeks': price_kopeks,
}
# Add discount info to response
if promo_offer_discount_value > 0:
response['promo_discount_percent'] = promo_offer_discount_percent
response['promo_discount_amount_kopeks'] = promo_offer_discount_value
response['original_price_kopeks'] = original_price_kopeks
return response
@@ -0,0 +1,268 @@
"""Server/country management endpoints.
GET /subscription/countries
POST /subscription/countries
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.subscription_service import SubscriptionService
from ...dependencies import get_cabinet_db, get_current_cabinet_user
from .helpers import resolve_subscription
logger = structlog.get_logger(__name__)
router = APIRouter()
@router.get('/countries')
async def get_available_countries(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
) -> dict[str, Any]:
"""Get available countries/servers for the user."""
from app.database.crud.server_squad import get_available_server_squads
from app.utils.pricing_utils import apply_percentage_discount, calculate_prorated_price
subscription = await resolve_subscription(db, user, subscription_id)
promo_group_id = user.promo_group_id
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
connected_squads = []
days_left = 0
if subscription:
connected_squads = subscription.connected_squads or []
if subscription.end_date:
delta = subscription.end_date - datetime.now(UTC)
days_left = max(0, delta.days)
# Get discount from promo group via PricingEngine (respects apply_discounts_to_addons flag)
from app.services.pricing_engine import PricingEngine
servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
countries = []
for server in available_servers:
base_price = server.price_kopeks
# Apply discount
if servers_discount_percent > 0:
discounted_price, _ = apply_percentage_discount(base_price, servers_discount_percent)
else:
discounted_price = base_price
# Calculate prorated price if subscription exists
prorated_price = discounted_price
if subscription and subscription.end_date:
prorated_price, _ = calculate_prorated_price(
discounted_price,
subscription.end_date,
)
countries.append(
{
'uuid': server.squad_uuid,
'name': server.display_name,
'country_code': server.country_code,
'base_price_kopeks': base_price,
'price_kopeks': prorated_price, # Prorated price with discount
'price_per_month_kopeks': discounted_price, # Monthly price with discount
'price_rubles': prorated_price / 100,
'is_available': server.is_available and not server.is_full,
'is_connected': server.squad_uuid in connected_squads,
'has_discount': servers_discount_percent > 0,
'discount_percent': servers_discount_percent,
}
)
return {
'countries': countries,
'connected_count': len(connected_squads),
'has_subscription': subscription is not None,
'days_left': days_left,
'discount_percent': servers_discount_percent,
}
@router.post('/countries')
async def update_countries(
request: dict[str, Any],
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
) -> dict[str, Any]:
"""Update subscription countries/servers."""
from app.database.crud.server_squad import add_user_to_servers, get_available_server_squads, get_server_ids_by_uuids
from app.database.crud.subscription import add_subscription_servers
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import TransactionType
from app.utils.pricing_utils import apply_percentage_discount, calculate_prorated_price
subscription = await resolve_subscription(db, user, subscription_id)
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
if subscription.is_trial:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Country management is not available for trial subscriptions',
)
selected_countries = request.get('countries', [])
if not selected_countries:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='At least one country must be selected',
)
current_countries = subscription.connected_squads or []
promo_group_id = user.promo_group_id
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
allowed_country_ids = {server.squad_uuid for server in available_servers}
# Validate selected countries
for country_uuid in selected_countries:
if country_uuid not in allowed_country_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Country {country_uuid} is not available',
)
added = [c for c in selected_countries if c not in current_countries]
removed = [c for c in current_countries if c not in selected_countries]
if not added and not removed:
return {
'message': 'No changes detected',
'connected_squads': current_countries,
}
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Calculate cost for added servers
total_cost = 0
added_names = []
removed_names = []
from app.services.pricing_engine import PricingEngine
servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
added_server_prices = []
for server in available_servers:
if server.squad_uuid in added:
server_price_per_month = server.price_kopeks
if servers_discount_percent > 0:
discounted_per_month, _ = apply_percentage_discount(
server_price_per_month,
servers_discount_percent,
)
else:
discounted_per_month = server_price_per_month
charged_price, charged_days = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
total_cost += charged_price
added_names.append(server.display_name)
added_server_prices.append(charged_price)
if server.squad_uuid in removed:
removed_names.append(server.display_name)
# Check balance
if total_cost > 0 and user.balance_kopeks < total_cost:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f'Insufficient balance. Need {total_cost / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB',
)
# Deduct balance and update subscription
if added and total_cost > 0:
success = await subtract_user_balance(db, user, total_cost, f'Adding countries: {", ".join(added_names)}')
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to charge balance',
)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=total_cost,
description=f'Adding countries to subscription: {", ".join(added_names)}',
)
# Add servers to subscription
if added:
added_server_ids = await get_server_ids_by_uuids(db, added)
if added_server_ids:
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
try:
await add_user_to_servers(db, added_server_ids)
except Exception as e:
logger.error('Ошибка обновления счётчика серверов', error=e)
# Update connected squads
subscription.connected_squads = selected_countries
subscription.updated_at = datetime.now(UTC)
await db.commit()
# Sync with RemnaWave
try:
from app.config import settings
subscription_service = SubscriptionService()
_has_panel = (
getattr(subscription, 'remnawave_uuid', None)
if settings.is_multi_tariff_enabled()
else getattr(user, 'remnawave_uuid', None)
)
if _has_panel:
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
else:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync countries with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='update' if _has_panel else 'create',
)
await db.refresh(subscription)
return {
'message': 'Countries updated successfully',
'added': added_names,
'removed': removed_names,
'amount_paid_kopeks': total_cost,
'connected_squads': subscription.connected_squads,
}
@@ -0,0 +1,526 @@
"""Subscription status endpoints.
GET /subscription subscription info
GET /subscription/connection-link
GET /subscription/happ-downloads
GET /subscription/app-config
"""
from __future__ import annotations
import base64
import re
from datetime import UTC, datetime
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.tariff import get_tariff_by_id
from app.database.models import ServerSquad, User
from app.services.remnawave_service import RemnaWaveService
from app.services.system_settings_service import bot_configuration_service
from ...dependencies import get_cabinet_db, get_current_cabinet_user
from ...schemas.subscription import (
ServerInfo,
SubscriptionStatusResponse,
)
from .helpers import _subscription_to_response, resolve_subscription
logger = structlog.get_logger(__name__)
router = APIRouter()
@router.get('/info', response_model=SubscriptionStatusResponse)
async def get_subscription(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
):
"""Get current user's subscription details."""
# Reload user from current session to get fresh data
# (user object is from different session in get_current_cabinet_user)
from app.database.crud.user import get_user_by_id
fresh_user = await get_user_by_id(db, user.id)
if not fresh_user:
return SubscriptionStatusResponse(has_subscription=False, subscription=None)
subscription = await resolve_subscription(db, fresh_user, subscription_id)
if not subscription:
# Return 200 with has_subscription: false instead of 404
return SubscriptionStatusResponse(has_subscription=False, subscription=None)
# Load tariff for daily subscription check and tariff name
tariff_name = None
if subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
subscription.tariff = tariff
tariff_name = tariff.name
# Fetch server names for connected squads
servers: list[ServerInfo] = []
connected_squads = subscription.connected_squads or []
if connected_squads:
result = await db.execute(select(ServerSquad).where(ServerSquad.squad_uuid.in_(connected_squads)))
server_squads = result.scalars().all()
servers = [
ServerInfo(uuid=sq.squad_uuid, name=sq.display_name, country_code=sq.country_code) for sq in server_squads
]
# Fetch traffic purchases (monthly packages)
traffic_purchases_data = []
from app.database.models import TrafficPurchase
now = datetime.now(UTC)
purchases_query = (
select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == subscription.id)
.where(TrafficPurchase.expires_at > now)
.order_by(TrafficPurchase.expires_at.asc())
)
purchases_result = await db.execute(purchases_query)
purchases = purchases_result.scalars().all()
for purchase in purchases:
time_remaining = purchase.expires_at - now
days_remaining = max(0, int(time_remaining.total_seconds() / 86400))
total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds()
elapsed_seconds = (now - purchase.created_at).total_seconds()
progress_percent = min(
100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)
)
traffic_purchases_data.append(
{
'id': purchase.id,
'traffic_gb': purchase.traffic_gb,
'expires_at': purchase.expires_at,
'created_at': purchase.created_at,
'days_remaining': days_remaining,
'progress_percent': round(progress_percent, 1),
}
)
subscription_data = _subscription_to_response(
subscription, servers, tariff_name, traffic_purchases_data, user=fresh_user
)
return SubscriptionStatusResponse(has_subscription=True, subscription=subscription_data)
# ============ Connection Link ============
@router.get('/connection-link')
async def get_connection_link(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
) -> dict[str, Any]:
"""Get subscription connection link and instructions."""
from app.utils.subscription_utils import (
convert_subscription_link_to_happ_scheme,
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
)
subscription = await resolve_subscription(db, user, subscription_id)
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
subscription_url = subscription.subscription_url
if not subscription_url:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Subscription link not yet generated',
)
display_link = get_display_subscription_link(subscription)
happ_redirect = get_happ_cryptolink_redirect_link(subscription_url) if settings.is_happ_cryptolink_mode() else None
happ_scheme_link = (
convert_subscription_link_to_happ_scheme(subscription_url) if settings.is_happ_cryptolink_mode() else None
)
connect_mode = settings.CONNECT_BUTTON_MODE
hide_subscription_link = settings.should_hide_subscription_link()
return {
'subscription_url': subscription_url if not hide_subscription_link else None,
'display_link': display_link if not hide_subscription_link else None,
'happ_redirect_link': happ_redirect,
'happ_scheme_link': happ_scheme_link,
'connect_mode': connect_mode,
'hide_link': hide_subscription_link,
'instructions': {
'steps': [
'Copy the subscription link',
'Open your VPN application',
"Find 'Add subscription' or 'Import' option",
'Paste the copied link',
]
},
}
# ============ hApp Downloads ============
@router.get('/happ-downloads')
async def get_happ_downloads(
user: User = Depends(get_current_cabinet_user),
) -> dict[str, Any]:
"""Get hApp download links for different platforms."""
platforms = {
'ios': {
'name': 'iOS (iPhone/iPad)',
'icon': '🍎',
'link': settings.get_happ_download_link('ios'),
},
'android': {
'name': 'Android',
'icon': '🤖',
'link': settings.get_happ_download_link('android'),
},
'macos': {
'name': 'macOS',
'icon': '🖥️',
'link': settings.get_happ_download_link('macos'),
},
'windows': {
'name': 'Windows',
'icon': '💻',
'link': settings.get_happ_download_link('windows'),
},
}
# Filter out platforms without links
available_platforms = {k: v for k, v in platforms.items() if v['link']}
return {
'platforms': available_platforms,
'happ_enabled': bool(available_platforms),
}
# ============ App Config for Connection ============
def _get_remnawave_config_uuid() -> str | None:
"""Get RemnaWave config UUID from system settings or env."""
try:
return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
except Exception:
return settings.CABINET_REMNA_SUB_CONFIG
def _extract_scheme_from_buttons(buttons: list[dict[str, Any]]) -> tuple[str, bool]:
"""Extract URL scheme from buttons list.
Returns:
Tuple of (scheme, uses_crypto_link).
uses_crypto_link=True when the template is {{HAPP_CRYPT4_LINK}},
meaning subscription_crypto_link should be used as payload.
"""
for btn in buttons:
if not isinstance(btn, dict):
continue
link = btn.get('link', '') or btn.get('url', '') or btn.get('buttonLink', '')
if not link:
continue
link_upper = link.upper()
# Check for {{HAPP_CRYPT4_LINK}} -- uses crypto link as payload
if '{{HAPP_CRYPT4_LINK}}' in link_upper or 'HAPP_CRYPT4_LINK' in link_upper:
scheme = re.sub(r'\{\{HAPP_CRYPT4_LINK\}\}', '', link, flags=re.IGNORECASE)
if scheme and '://' in scheme:
return scheme, True
# Check for {{SUBSCRIPTION_LINK}} -- uses plain subscription_url as payload
if '{{SUBSCRIPTION_LINK}}' in link_upper or 'SUBSCRIPTION_LINK' in link_upper:
scheme = re.sub(r'\{\{SUBSCRIPTION_LINK\}\}', '', link, flags=re.IGNORECASE)
if scheme and '://' in scheme:
return scheme, False
# Also check for type="subscriptionLink" buttons with custom schemes
btn_type = btn.get('type', '')
if btn_type == 'subscriptionLink' and '://' in link and not link.startswith('http'):
scheme = link.split('{{')[0] if '{{' in link else link
if scheme and '://' in scheme:
return scheme, False
return '', False
def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
"""Get URL scheme for app - from config, buttons, or fallback by name.
Returns:
Tuple of (scheme, uses_crypto_link).
uses_crypto_link=True means the app template uses {{HAPP_CRYPT4_LINK}},
so subscription_crypto_link should be used as the deep link payload.
"""
# 1. Check urlScheme field (cabinet format stores usesCryptoLink alongside)
scheme = str(app.get('urlScheme', '')).strip()
if scheme:
uses_crypto = bool(app.get('usesCryptoLink', False))
return scheme, uses_crypto
# 2. Extract from buttons in blocks (RemnaWave format)
blocks = app.get('blocks', [])
for block in blocks:
if not isinstance(block, dict):
continue
buttons = block.get('buttons', [])
scheme, uses_crypto = _extract_scheme_from_buttons(buttons)
if scheme:
return scheme, uses_crypto
# 3. Check buttons directly in app (alternative structure)
direct_buttons = app.get('buttons', [])
if direct_buttons:
scheme, uses_crypto = _extract_scheme_from_buttons(direct_buttons)
if scheme:
return scheme, uses_crypto
# No scheme found
logger.debug(
'_get_url_scheme_for_app: No scheme found for app has blocks: has buttons: has urlScheme',
get=app.get('name'),
get_2=bool(app.get('blocks')),
get_3=bool(app.get('buttons')),
get_4=bool(app.get('urlScheme')),
)
return '', False
async def _load_app_config_async() -> dict[str, Any] | None:
"""Load app config from RemnaWave API (if configured).
Returns None when no Remnawave config is set or API fails.
"""
remnawave_uuid = _get_remnawave_config_uuid()
if remnawave_uuid:
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
config = await api.get_subscription_page_config(remnawave_uuid)
if config and config.config:
logger.debug('Loaded app config from RemnaWave', remnawave_uuid=remnawave_uuid)
raw = dict(config.config)
raw['_isRemnawave'] = True
return raw
except Exception as e:
logger.warning('Failed to load RemnaWave config', error=e)
return None
def _create_deep_link(
app: dict[str, Any], subscription_url: str, subscription_crypto_link: str | None = None
) -> str | None:
"""Create deep link for app with subscription URL.
Uses urlScheme from RemnaWave config (e.g. "happ://add/", "v2rayng://install-config?url=")
combined with the appropriate payload URL.
Two Happ schemes exist in RemnaWave:
- happ://add/{{SUBSCRIPTION_LINK}} -> uses plain subscription_url
- happ://crypt4/{{HAPP_CRYPT4_LINK}} -> uses subscription_crypto_link
"""
if not isinstance(app, dict):
return None
if not subscription_url and not subscription_crypto_link:
return None
scheme, uses_crypto = _get_url_scheme_for_app(app)
if not scheme:
logger.debug('_create_deep_link: no urlScheme for app', get=app.get('name', 'unknown'))
return None
# Pick the correct payload based on which template the app uses
if uses_crypto:
if not subscription_crypto_link:
logger.debug(
'_create_deep_link: app requires crypto link but none available', get=app.get('name', 'unknown')
)
return None
payload = subscription_crypto_link
else:
if not subscription_url:
logger.debug(
'_create_deep_link: app requires subscription_url but none available', get=app.get('name', 'unknown')
)
return None
payload = subscription_url
if app.get('isNeedBase64Encoding'):
try:
payload = base64.b64encode(payload.encode('utf-8')).decode('utf-8')
except Exception as e:
logger.warning('Failed to encode payload to base64', error=e)
return f'{scheme}{payload}'
def _resolve_button_url(
url: str,
subscription_url: str | None,
subscription_crypto_link: str | None,
) -> str:
"""Resolve template variables in button URLs.
Matches remnawave/subscription-page frontend TemplateEngine:
- {{SUBSCRIPTION_LINK}} -> plain subscription URL
- {{HAPP_CRYPT3_LINK}} -> crypto link
- {{HAPP_CRYPT4_LINK}} -> crypto link
"""
if not url:
return url
result = url
if subscription_url:
result = result.replace('{{SUBSCRIPTION_LINK}}', subscription_url)
if subscription_crypto_link:
result = result.replace('{{HAPP_CRYPT3_LINK}}', subscription_crypto_link)
result = result.replace('{{HAPP_CRYPT4_LINK}}', subscription_crypto_link)
return result
@router.get('/app-config')
async def get_app_config(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
) -> dict[str, Any]:
"""Get app configuration for connection with deep links."""
subscription = await resolve_subscription(db, user, subscription_id)
subscription_url = None
subscription_crypto_link = None
if subscription:
subscription_url = subscription.subscription_url
subscription_crypto_link = subscription.subscription_crypto_link
# Generate crypto link on the fly if subscription_url exists but crypto link is missing.
# This covers synced users where enrich_happ_links was not called.
if subscription_url and not subscription_crypto_link:
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
encrypted = await api.encrypt_happ_crypto_link(subscription_url)
if encrypted:
subscription_crypto_link = encrypted
if subscription:
subscription.subscription_crypto_link = encrypted
await db.commit()
logger.info(
'Generated and saved crypto link for user',
user_id=user.id,
)
except Exception as e:
logger.debug('Could not generate crypto link', error=e)
config = await _load_app_config_async()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='App configuration not set up.',
)
config.pop('_isRemnawave', None)
hide_link = settings.should_hide_subscription_link()
# Build platformNames from displayName of each platform
platform_names: dict[str, Any] = {}
for pk, pd in config.get('platforms', {}).items():
if isinstance(pd, dict) and 'displayName' in pd:
platform_names[pk] = pd['displayName']
fallback_names = {
'ios': {'en': 'iPhone/iPad'},
'android': {'en': 'Android'},
'macos': {'en': 'macOS'},
'windows': {'en': 'Windows'},
'linux': {'en': 'Linux'},
'androidTV': {'en': 'Android TV'},
'appleTV': {'en': 'Apple TV'},
}
for k, v in fallback_names.items():
if k not in platform_names:
platform_names[k] = v
# Serve original blocks/svgLibrary enriched with deep links and resolved URLs.
platforms: dict[str, Any] = {}
for platform_key, platform_data in config.get('platforms', {}).items():
if not isinstance(platform_data, dict):
continue
apps = platform_data.get('apps', [])
if not isinstance(apps, list):
continue
enriched_apps = []
for app in apps:
if not isinstance(app, dict):
continue
# Generate deep link
deep_link = None
if subscription_url or subscription_crypto_link:
deep_link = _create_deep_link(app, subscription_url, subscription_crypto_link)
app['deepLink'] = deep_link
# Resolve templates only for subscriptionLink and copyButton (not external)
for block in app.get('blocks', []):
if not isinstance(block, dict):
continue
for btn in block.get('buttons', []):
if not isinstance(btn, dict):
continue
btn_type = btn.get('type', '')
if btn_type in ('subscriptionLink', 'copyButton'):
url = btn.get('url', '') or btn.get('link', '')
if url and '{{' in url:
resolved = _resolve_button_url(
url,
subscription_url,
subscription_crypto_link,
)
# Only set resolvedUrl if ALL templates were resolved;
# otherwise let the frontend fall through to deepLink/subscriptionUrl
if '{{' not in resolved:
btn['resolvedUrl'] = resolved
enriched_apps.append(app)
if enriched_apps:
platform_output = {k: v for k, v in platform_data.items() if k != 'apps'}
platform_output['apps'] = enriched_apps
platforms[platform_key] = platform_output
return {
'isRemnawave': True,
'platforms': platforms,
'svgLibrary': config.get('svgLibrary', {}),
'baseTranslations': config.get('baseTranslations'),
'baseSettings': config.get('baseSettings'),
'uiConfig': config.get('uiConfig', {}),
'platformNames': platform_names,
'hasSubscription': bool(subscription_url or subscription_crypto_link),
'subscriptionUrl': subscription_url,
'subscriptionCryptoLink': subscription_crypto_link,
'hideLink': hide_link,
'branding': config.get('brandingSettings', {}),
}
@@ -0,0 +1,507 @@
"""Tariff switching endpoints.
POST /subscription/tariff/switch/preview
POST /subscription/tariff/switch
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.tariff import get_tariff_by_id
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import PaymentMethod, Subscription, TransactionType, User
from app.services.pricing_engine import pricing_engine
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import SubscriptionService
from ...dependencies import get_cabinet_db, get_current_cabinet_user
from ...schemas.subscription import TariffPurchaseRequest
from .helpers import _subscription_to_response, resolve_subscription
logger = structlog.get_logger(__name__)
router = APIRouter()
@router.post('/tariff/switch/preview')
async def preview_tariff_switch(
request: TariffPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
) -> dict[str, Any]:
"""Preview tariff switch - shows cost calculation."""
if not settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Tariffs mode is not enabled',
)
subscription = await resolve_subscription(db, user, subscription_id)
if not subscription or not subscription.tariff_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No active subscription with tariff',
)
# Use actual_status for correct status check (handles time-based expiration)
actual_status = subscription.actual_status
if actual_status == 'expired':
# For expired subscriptions, user should purchase a new tariff, not switch
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
'code': 'subscription_expired',
'message': 'Subscription is expired. Please purchase a new tariff instead of switching.',
'use_purchase_flow': True,
},
)
if actual_status not in ('active', 'trial'):
# For disabled/pending subscriptions, block switching with generic error
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
'code': 'subscription_not_active',
'message': f'Subscription is not active (status: {actual_status}). Cannot switch tariff.',
},
)
current_tariff = await get_tariff_by_id(db, subscription.tariff_id)
new_tariff = await get_tariff_by_id(db, request.tariff_id)
if not new_tariff or not new_tariff.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found or inactive',
)
if subscription.tariff_id == request.tariff_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Already on this tariff',
)
# Check tariff availability for user's promo group
# Use get_primary_promo_group() for correct promo group resolution
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group is None:
promo_group = getattr(user, 'promo_group', None)
promo_group_id = promo_group.id if promo_group else None
if not new_tariff.is_available_for_promo_group(promo_group_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Tariff not available for your promo group',
)
# Calculate remaining days
remaining_days = 0
if subscription.end_date and subscription.end_date > datetime.now(UTC):
delta = subscription.end_date - datetime.now(UTC)
remaining_days = max(0, delta.days)
# Calculate switch cost (PricingEngine handles all cases: periodic<->periodic, daily->periodic, periodic->daily)
switch_result = pricing_engine.calculate_tariff_switch_cost(
current_tariff,
new_tariff,
remaining_days,
user=user,
)
upgrade_cost = switch_result.upgrade_cost
is_upgrade = switch_result.is_upgrade
base_upgrade_cost = switch_result.raw_cost
discount_value = switch_result.discount_value
period_discount_percent = switch_result.effective_discount_pct
balance = user.balance_kopeks or 0
has_enough = balance >= upgrade_cost
missing = max(0, upgrade_cost - balance) if not has_enough else 0
response: dict[str, Any] = {
'can_switch': has_enough,
'current_tariff_id': current_tariff.id if current_tariff else None,
'current_tariff_name': current_tariff.name if current_tariff else None,
'new_tariff_id': new_tariff.id,
'new_tariff_name': new_tariff.name,
'remaining_days': remaining_days,
'upgrade_cost_kopeks': upgrade_cost,
'upgrade_cost_label': settings.format_price(upgrade_cost) if upgrade_cost > 0 else 'Бесплатно',
'balance_kopeks': balance,
'balance_label': settings.format_price(balance),
'has_enough_balance': has_enough,
'missing_amount_kopeks': missing,
'missing_amount_label': settings.format_price(missing) if missing > 0 else '',
'is_upgrade': is_upgrade,
}
# Add discount info if applicable
if period_discount_percent > 0 and discount_value > 0:
response['discount_percent'] = period_discount_percent
response['discount_kopeks'] = discount_value
response['base_upgrade_cost_kopeks'] = base_upgrade_cost
return response
@router.post('/tariff/switch')
async def switch_tariff(
request: TariffPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
) -> dict[str, Any]:
"""Switch to a different tariff without changing end date."""
if not settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Tariffs mode is not enabled',
)
resolved = await resolve_subscription(db, user, subscription_id)
if not resolved or not resolved.tariff_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No active subscription with tariff',
)
# Guard: prevent switching to a tariff the user already owns (multi-tariff)
if settings.is_multi_tariff_enabled() and request.tariff_id:
from app.database.crud.subscription import get_subscription_by_user_and_tariff
existing_target = await get_subscription_by_user_and_tariff(db, user.id, request.tariff_id)
if existing_target and existing_target.id != resolved.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='You already have an active subscription for the target tariff',
)
# Lock subscription row to prevent concurrent tariff switches
locked_result = await db.execute(
select(Subscription)
.where(Subscription.id == resolved.id)
.with_for_update()
.execution_options(populate_existing=True)
)
subscription = locked_result.scalar_one()
# Use actual_status for correct status check (handles time-based expiration)
actual_status = subscription.actual_status
if actual_status == 'expired':
# For expired subscriptions, user should purchase a new tariff, not switch
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
'code': 'subscription_expired',
'message': 'Subscription is expired. Please purchase a new tariff instead of switching.',
'use_purchase_flow': True,
},
)
if actual_status not in ('active', 'trial'):
# For disabled/pending subscriptions, block switching with generic error
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
'code': 'subscription_not_active',
'message': f'Subscription is not active (status: {actual_status}). Cannot switch tariff.',
},
)
current_tariff = await get_tariff_by_id(db, subscription.tariff_id)
new_tariff = await get_tariff_by_id(db, request.tariff_id)
if not new_tariff or not new_tariff.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found or inactive',
)
if subscription.tariff_id == request.tariff_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Already on this tariff',
)
# Check tariff availability
# Use get_primary_promo_group() for correct promo group resolution
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group is None:
promo_group = getattr(user, 'promo_group', None)
promo_group_id = promo_group.id if promo_group else None
if not new_tariff.is_available_for_promo_group(promo_group_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Tariff not available',
)
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Calculate remaining days
remaining_days = 0
if subscription.end_date and subscription.end_date > datetime.now(UTC):
delta = subscription.end_date - datetime.now(UTC)
remaining_days = max(0, delta.days)
# Calculate cost (PricingEngine handles all cases: periodic<->periodic, daily->periodic, periodic->daily)
switch_result = pricing_engine.calculate_tariff_switch_cost(
current_tariff,
new_tariff,
remaining_days,
user=user,
)
upgrade_cost = switch_result.upgrade_cost
base_upgrade_cost = switch_result.raw_cost
discount_value = switch_result.discount_value
period_discount_percent = switch_result.effective_discount_pct
new_period_days = switch_result.new_period_days
# Validate daily price for switching TO daily
new_is_daily = getattr(new_tariff, 'is_daily', False)
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
switching_to_daily = not current_is_daily and new_is_daily
switching_from_daily = current_is_daily and not new_is_daily
if switching_to_daily and (getattr(new_tariff, 'daily_price_kopeks', 0) or 0) <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Daily tariff has invalid price',
)
# Charge if upgrade
switch_transaction = None
if upgrade_cost > 0:
if user.balance_kopeks < upgrade_cost:
missing = upgrade_cost - user.balance_kopeks
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_funds',
'message': f'Insufficient funds. Missing {settings.format_price(missing)}',
'missing_amount': missing,
},
)
if switching_to_daily:
description = f"Переход на суточный тариф '{new_tariff.name}'"
elif switching_from_daily:
description = f"Переход с суточного на тариф '{new_tariff.name}' ({new_period_days} дней)"
else:
description = f"Переход на тариф '{new_tariff.name}' (доплата за {remaining_days} дней)"
# Add discount info to description if applicable
if period_discount_percent > 0 and discount_value > 0:
description += f' (скидка {period_discount_percent}%)'
success = await subtract_user_balance(
db,
user,
upgrade_cost,
description,
consume_promo_offer=switch_result.offer_discount_pct > 0,
mark_as_paid_subscription=True,
commit=False,
)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to charge balance',
)
# Create transaction (commit=False to keep FOR UPDATE lock held)
switch_transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=upgrade_cost,
description=description,
payment_method=PaymentMethod.BALANCE,
commit=False,
)
else:
# Free switch (downgrade) — record in history
description = f"Переход на тариф '{new_tariff.name}'"
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=0,
description=description,
commit=False,
)
# Update subscription
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
# Reset device limit to new tariff base (extra purchased devices are not carried over)
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
# Re-load subscription to avoid MissingGreenlet from expired lazy relationship
# (subtract_user_balance re-selects User with populate_existing=True which expires relationships)
await db.refresh(subscription)
subscription.tariff_id = new_tariff.id
subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
subscription.device_limit = calc_device_limit_on_tariff_switch(
current_device_limit=subscription.device_limit,
old_tariff_device_limit=current_tariff.device_limit if current_tariff else None,
new_tariff_device_limit=new_tariff.device_limit,
max_device_limit=new_tariff.max_device_limit,
)
subscription.connected_squads = new_tariff.allowed_squads or []
# Reset purchased traffic and delete TrafficPurchase records on tariff switch
from sqlalchemy import delete as sql_delete
from app.database.models import TrafficPurchase
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
subscription.traffic_used_gb = 0.0
if switching_to_daily:
# Switching TO daily - reset end_date to 1 day, set last_daily_charge_at
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.is_daily_paused = False
elif switching_from_daily:
subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days)
subscription.is_daily_paused = False
subscription.updated_at = datetime.now(UTC)
await db.commit()
# Emit deferred side-effects after atomic commit
if upgrade_cost > 0 and switch_transaction:
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
switch_transaction,
amount_kopeks=upgrade_cost,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
payment_method=PaymentMethod.BALANCE,
)
# Sync with RemnaWave (optionally reset traffic based on admin setting)
should_reset_traffic = settings.RESET_TRAFFIC_ON_TARIFF_SWITCH
# Refresh subscription after commit (all objects are expired)
await db.refresh(subscription)
try:
subscription_service = SubscriptionService()
_has_panel = (
getattr(subscription, 'remnawave_uuid', None)
if settings.is_multi_tariff_enabled()
else getattr(user, 'remnawave_uuid', None)
)
if _has_panel:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа',
sync_squads=True,
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа',
)
except Exception as e:
logger.error('Failed to sync tariff switch with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='update' if _has_panel else 'create',
)
# Reset all devices on tariff switch
devices_reset = False
_switch_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else user.remnawave_uuid
)
if _switch_uuid:
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
await api.reset_user_devices(_switch_uuid)
devices_reset = True
logger.info('Reset all devices for user on tariff switch', user_id=user.id)
except Exception as e:
logger.error('Failed to reset devices on tariff switch', error=e)
await db.refresh(user)
await db.refresh(subscription)
# Отправляем уведомление админам о смене тарифа
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_subscription_purchase_notification(
db=db,
user=user,
subscription=subscription,
transaction=switch_transaction if upgrade_cost > 0 else None,
period_days=remaining_days if remaining_days > 0 else new_period_days,
was_trial_conversion=False,
amount_kopeks=upgrade_cost,
purchase_type='tariff_switch',
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for tariff switch', error=e)
# Refresh expired objects after db.commit() in _record_subscription_event
await db.refresh(subscription)
await db.refresh(user)
response: dict[str, Any] = {
'success': True,
'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'"
+ (' (devices reset)' if devices_reset else ''),
'subscription': _subscription_to_response(subscription, user=user),
'old_tariff_name': old_tariff_name,
'new_tariff_id': new_tariff.id,
'new_tariff_name': new_tariff.name,
'charged_kopeks': upgrade_cost,
'balance_kopeks': user.balance_kopeks,
'balance_label': settings.format_price(user.balance_kopeks),
}
# Add discount info if applicable
if period_discount_percent > 0 and discount_value > 0:
response['discount_percent'] = period_discount_percent
response['discount_kopeks'] = discount_value
response['base_charged_kopeks'] = base_upgrade_cost
return response
@@ -0,0 +1,785 @@
"""Traffic management endpoints.
GET /subscription/traffic-packages
POST /subscription/traffic
PUT /subscription/traffic
POST /subscription/refresh-traffic
POST /subscription/traffic/save-cart
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.tariff import get_tariff_by_id
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import TransactionType, User
from app.services.pricing_engine import pricing_engine
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service
from app.utils.cache import RateLimitCache, cache, cache_key
from ...dependencies import get_cabinet_db, get_current_cabinet_user
from ...schemas.subscription import (
TrafficPackageResponse,
TrafficPurchaseRequest,
)
from .helpers import _apply_addon_discount, resolve_subscription
logger = structlog.get_logger(__name__)
router = APIRouter()
@router.get('/traffic-packages', response_model=list[TrafficPackageResponse])
async def get_traffic_packages(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
):
"""Get available traffic packages."""
from app.database.crud.tariff import get_tariff_by_id
subscription = await resolve_subscription(db, user, subscription_id)
if not subscription:
return []
# Режим тарифов - берём пакеты из тарифа
if settings.is_tariffs_mode() and subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not tariff:
return []
# Проверяем, разрешена ли докупка для этого тарифа
if not getattr(tariff, 'traffic_topup_enabled', False):
return []
# Проверяем безлимит
if tariff.traffic_limit_gb == 0:
return []
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
result = []
for gb, price in packages.items():
if price <= 0:
continue
result.append(
TrafficPackageResponse(
gb=gb,
price_kopeks=price,
price_rubles=price / 100,
is_unlimited=False,
)
)
return sorted(result, key=lambda x: x.gb)
# Classic режим - глобальные настройки
if not settings.is_traffic_topup_enabled():
return []
# Проверяем настройку тарифа пользователя (allow_traffic_topup)
if subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and not tariff.allow_traffic_topup:
return []
packages = settings.get_traffic_topup_packages()
result = []
for pkg in packages:
if not pkg.get('enabled', True):
continue
if pkg['price'] <= 0:
continue
result.append(
TrafficPackageResponse(
gb=pkg['gb'],
price_kopeks=pkg['price'],
price_rubles=pkg['price'] / 100,
is_unlimited=pkg['gb'] == 0,
)
)
return result
@router.post('/traffic')
async def purchase_traffic(
request: TrafficPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
):
"""Purchase additional traffic."""
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Subscription purchases are restricted for this account',
)
from app.database.crud.subscription import add_subscription_traffic
from app.database.crud.tariff import get_tariff_by_id
from app.utils.pricing_utils import calculate_prorated_price
subscription = await resolve_subscription(db, user, subscription_id)
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
tariff = None
base_price_kopeks = 0
is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id
# Режим тарифов
if is_tariff_mode:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
# Проверяем, разрешена ли докупка
if not getattr(tariff, 'traffic_topup_enabled', False):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Traffic top-up is disabled for this tariff',
)
# Проверяем безлимит
if tariff.traffic_limit_gb == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot add traffic to unlimited subscription',
)
# Проверяем лимит докупки
max_topup_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
if max_topup_limit > 0:
current_traffic = subscription.traffic_limit_gb or 0
new_traffic = current_traffic + request.gb
if new_traffic > max_topup_limit:
available_gb = max(0, max_topup_limit - current_traffic)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Traffic limit exceeded. Max: {max_topup_limit} GB, available: {available_gb} GB',
)
# Получаем цену из тарифа
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
if request.gb not in packages:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Traffic package {request.gb}GB is not available',
)
base_price_kopeks = packages[request.gb]
if base_price_kopeks <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Traffic package {request.gb}GB has no price configured',
)
else:
# Classic режим
if not settings.is_traffic_topup_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Traffic top-up feature is disabled',
)
# Проверяем настройку тарифа (allow_traffic_topup)
if subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and not tariff.allow_traffic_topup:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Traffic top-up is not available for your tariff',
)
# Получаем цену из глобальных настроек
packages = settings.get_traffic_topup_packages()
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
if not matching_pkg:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid traffic package',
)
base_price_kopeks = matching_pkg['price']
if base_price_kopeks <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Traffic package has no price configured',
)
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
# Пропорциональный расчёт применяем только в классическом режиме.
if is_tariff_mode:
prorated_price = base_price_kopeks
days_charged = 30
else:
prorated_price, days_charged = calculate_prorated_price(
base_price_kopeks,
subscription.end_date,
)
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Apply discount from promo group using proper method
period_hint_days = days_charged if days_charged > 0 else 30
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, period_hint_days)
final_price = discount_result['discounted']
traffic_discount_percent = discount_result['percent']
discount_value = discount_result['discount']
# Ensure minimum price after discount (except for 100% discount)
if traffic_discount_percent < 100 and final_price > 0:
final_price = max(100, final_price)
# Проверяем баланс
if final_price > 0 and user.balance_kopeks < final_price:
missing = final_price - user.balance_kopeks
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'add_traffic',
'subscription_id': subscription.id,
'traffic_gb': request.gb,
'price_kopeks': final_price,
'base_price_kopeks': prorated_price,
'discount_percent': traffic_discount_percent,
'source': 'cabinet',
'description': f'Докупка {request.gb} ГБ трафика',
}
try:
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(
'Cart saved for traffic purchase (cabinet) user + discount',
user_id=user.id,
gb=request.gb,
traffic_discount_percent=traffic_discount_percent,
)
except Exception as e:
logger.error('Error saving cart for traffic purchase (cabinet)', error=e)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_funds',
'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}',
'missing_amount': missing,
'cart_saved': True,
'cart_mode': 'add_traffic',
},
)
# Формируем описание
if traffic_discount_percent > 0:
traffic_description = f'Докупка {request.gb} ГБ трафика (скидка {traffic_discount_percent}%)'
else:
traffic_description = f'Докупка {request.gb} ГБ трафика'
# Списываем баланс
success = await subtract_user_balance(db, user, final_price, traffic_description)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to charge balance',
)
# Добавляем трафик (add_subscription_traffic обновляет purchased_traffic_gb, traffic_reset_at и коммитит)
await add_subscription_traffic(db, subscription, request.gb)
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
from app.database.crud.subscription import reactivate_subscription
await reactivate_subscription(db, subscription)
# Синхронизируем с RemnaWave
try:
subscription_service = SubscriptionService()
_panel_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else getattr(user, 'remnawave_uuid', None)
)
if _panel_uuid:
await subscription_service.update_remnawave_user(db, subscription)
if subscription.status == 'active':
await subscription_service.enable_remnawave_user(_panel_uuid)
else:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync traffic with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='update' if _panel_uuid else 'create',
)
# Создаём транзакцию
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=final_price,
description=traffic_description,
)
await db.refresh(user)
await db.refresh(subscription)
# Отправляем уведомление админам
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)
old_traffic = subscription.traffic_limit_gb - request.gb
await notification_service.send_subscription_update_notification(
db=db,
user=user,
subscription=subscription,
update_type='traffic',
old_value=old_traffic,
new_value=subscription.traffic_limit_gb,
price_paid=final_price,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for traffic purchase', error=e)
response: dict[str, Any] = {
'success': True,
'message': 'Traffic purchased successfully',
'gb_added': request.gb,
'new_traffic_limit_gb': subscription.traffic_limit_gb,
'amount_paid_kopeks': final_price,
'new_balance_kopeks': user.balance_kopeks,
}
if traffic_discount_percent > 0:
response['discount_percent'] = traffic_discount_percent
response['discount_kopeks'] = discount_value
response['base_price_kopeks'] = prorated_price
return response
@router.post('/traffic/save-cart')
async def save_traffic_cart(
request: TrafficPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
) -> dict[str, bool]:
"""Save cart for traffic purchase (for insufficient balance flow)."""
subscription = await resolve_subscription(db, user, subscription_id)
if not subscription:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='У вас нет активной подписки',
)
if subscription.status not in ['active', 'trial']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Ваша подписка неактивна',
)
if subscription.is_trial:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Докупка трафика недоступна на пробном периоде',
)
if subscription.traffic_limit_gb == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='У вас уже безлимитный трафик',
)
# Get traffic price from tariff or settings
tariff = None
base_price_kopeks = 0
is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id
if is_tariff_mode:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Тариф не найден',
)
if not getattr(tariff, 'traffic_topup_enabled', False):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Докупка трафика недоступна на вашем тарифе',
)
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
if request.gb not in packages:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Пакет трафика {request.gb} ГБ недоступен',
)
base_price_kopeks = packages[request.gb]
else:
if not settings.is_traffic_topup_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Докупка трафика отключена',
)
packages = settings.get_traffic_topup_packages()
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
if not matching_pkg:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Недоступный пакет трафика',
)
base_price_kopeks = matching_pkg['price']
# Calculate prorated price (days-based), then apply discount
from app.utils.pricing_utils import calculate_prorated_price as _calc_prorated
now = datetime.now(UTC)
days_left = max(1, (subscription.end_date - now).days)
prorated_price, _ = _calc_prorated(
base_price_kopeks,
subscription.end_date,
)
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, days_left)
final_price = discount_result['discounted']
traffic_discount_percent = discount_result['percent']
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'add_traffic',
'subscription_id': subscription.id,
'traffic_gb': request.gb,
'price_kopeks': final_price,
'base_price_kopeks': base_price_kopeks,
'discount_percent': traffic_discount_percent,
'source': 'cabinet',
'description': f'Докупка {request.gb} ГБ трафика',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info('Cart saved for traffic purchase (cabinet save-cart) user +', user_id=user.id, gb=request.gb)
return {'success': True, 'cart_saved': True}
# ============ Traffic Switch (Change Traffic Package) ============
@router.put('/traffic')
async def switch_traffic_package(
request: TrafficPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
) -> dict[str, Any]:
"""Switch to a different traffic package (change limit)."""
from app.utils.pricing_utils import calculate_prorated_price
subscription = await resolve_subscription(db, user, subscription_id)
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
)
if subscription.is_trial:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Traffic management is only available for paid subscriptions',
)
current_traffic = subscription.traffic_limit_gb or 0
new_traffic = request.gb
if current_traffic == new_traffic:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Already on this traffic package',
)
# Get available packages
packages = settings.get_traffic_packages()
current_pkg = next((p for p in packages if p['gb'] == current_traffic and p.get('enabled', True)), None)
new_pkg = next((p for p in packages if p['gb'] == new_traffic and p.get('enabled', True)), None)
if not new_pkg:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid traffic package',
)
# Calculate price difference (only charge for upgrade)
current_price = current_pkg['price'] if current_pkg else 0
new_price = new_pkg['price']
if new_price > current_price:
# Upgrade - charge difference
price_diff = new_price - current_price
# Lock user row to prevent TOCTOU on promo-offer state
from app.database.crud.user import lock_user_for_pricing
user = await lock_user_for_pricing(db, user.id)
# Apply promo discount via PricingEngine
price_diff, _discount_val, traffic_discount_percent = pricing_engine.calculate_traffic_discount(
price_diff,
user,
)
# Prorated calculation
final_price, days_charged = calculate_prorated_price(price_diff, subscription.end_date)
if final_price > 0 and user.balance_kopeks < final_price:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f'Insufficient balance. Need {final_price / 100:.2f} RUB',
)
# Charge balance
description = f'Traffic upgrade from {current_traffic}GB to {new_traffic}GB'
success = await subtract_user_balance(db, user, final_price, description)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to charge balance',
)
# Create transaction
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=final_price,
description=description,
)
charged = final_price
else:
# Downgrade - no charge, no refund
charged = 0
# Update subscription — delete TrafficPurchase records before resetting purchased_traffic_gb
from sqlalchemy import delete as sql_delete
from app.database.models import TrafficPurchase
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
subscription.traffic_limit_gb = new_traffic
subscription.purchased_traffic_gb = 0 # Reset purchased traffic on switch
subscription.traffic_reset_at = None # Reset traffic reset date
subscription.updated_at = datetime.now(UTC)
await db.commit()
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
_panel_uuid2 = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else getattr(user, 'remnawave_uuid', None)
)
if _panel_uuid2:
await subscription_service.update_remnawave_user(db, subscription)
else:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync traffic switch with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update' if _panel_uuid2 else 'create',
)
await db.refresh(user)
await db.refresh(subscription)
return {
'success': True,
'message': f'Traffic changed from {current_traffic}GB to {new_traffic}GB',
'old_traffic_gb': current_traffic,
'new_traffic_gb': new_traffic,
'charged_kopeks': charged,
'balance_kopeks': user.balance_kopeks,
'balance_label': settings.format_price(user.balance_kopeks),
}
# ============ Traffic Refresh ============
# Rate limit: 1 request per 60 seconds per user
TRAFFIC_REFRESH_RATE_LIMIT = 1
TRAFFIC_REFRESH_RATE_WINDOW = 60 # seconds
TRAFFIC_CACHE_TTL = 60 # Cache traffic data for 60 seconds
@router.post('/refresh-traffic')
async def refresh_traffic(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
):
"""
Refresh traffic usage from RemnaWave panel.
Rate limited to 1 request per 60 seconds.
"""
subscription = await resolve_subscription(db, user, subscription_id)
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No active subscription',
)
# Use per-subscription key when subscription_id is available so that refreshing
# Sub B is not blocked by a previous refresh of Sub A (multi-tariff mode).
cache_suffix = f'{user.id}_{subscription_id}' if subscription_id is not None else str(user.id)
# Check rate limit
is_limited = await RateLimitCache.is_rate_limited(
cache_suffix,
'traffic_refresh',
TRAFFIC_REFRESH_RATE_LIMIT,
TRAFFIC_REFRESH_RATE_WINDOW,
)
if is_limited:
# Check if we have cached data
traffic_cache_key = cache_key('traffic', cache_suffix)
cached_data = await cache.get(traffic_cache_key)
if cached_data:
return {
'success': True,
'cached': True,
'rate_limited': True,
'retry_after_seconds': TRAFFIC_REFRESH_RATE_WINDOW,
**cached_data,
}
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f'Rate limited. Try again in {TRAFFIC_REFRESH_RATE_WINDOW} seconds.',
headers={'Retry-After': str(TRAFFIC_REFRESH_RATE_WINDOW)},
)
# Fetch traffic from RemnaWave
try:
remnawave_service = RemnaWaveService()
# Resolve panel UUID for traffic lookup
_traffic_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else user.remnawave_uuid
)
if user.telegram_id and not settings.is_multi_tariff_enabled():
traffic_stats = await remnawave_service.get_user_traffic_stats(user.telegram_id)
elif _traffic_uuid:
traffic_stats = await remnawave_service.get_user_traffic_stats_by_uuid(_traffic_uuid)
else:
traffic_stats = None
if not traffic_stats:
# Return current database values if RemnaWave unavailable
traffic_data = {
'traffic_used_bytes': int((subscription.traffic_used_gb or 0) * (1024**3)),
'traffic_used_gb': round(subscription.traffic_used_gb or 0, 2),
'traffic_limit_bytes': int((subscription.traffic_limit_gb or 0) * (1024**3)),
'traffic_limit_gb': subscription.traffic_limit_gb or 0,
'traffic_used_percent': round(
((subscription.traffic_used_gb or 0) / (subscription.traffic_limit_gb or 1)) * 100
if subscription.traffic_limit_gb
else 0,
1,
),
'is_unlimited': (subscription.traffic_limit_gb or 0) == 0,
}
return {
'success': True,
'cached': False,
'source': 'database',
**traffic_data,
}
# Update subscription with fresh data
used_gb = traffic_stats.get('used_traffic_gb', 0)
if abs((subscription.traffic_used_gb or 0) - used_gb) > 0.01:
subscription.traffic_used_gb = used_gb
subscription.updated_at = datetime.now(UTC)
await db.commit()
# Calculate percentage
limit_gb = subscription.traffic_limit_gb or 0
if limit_gb > 0:
percent = min(100, (used_gb / limit_gb) * 100)
else:
percent = 0
traffic_data = {
'traffic_used_bytes': traffic_stats.get('used_traffic_bytes', 0),
'traffic_used_gb': round(used_gb, 2),
'traffic_limit_bytes': traffic_stats.get('traffic_limit_bytes', 0),
'traffic_limit_gb': limit_gb,
'traffic_used_percent': round(percent, 1),
'is_unlimited': limit_gb == 0,
'lifetime_used_bytes': traffic_stats.get('lifetime_used_traffic_bytes', 0),
'lifetime_used_gb': round(traffic_stats.get('lifetime_used_traffic_gb', 0), 2),
}
# Cache the result
traffic_cache_key = cache_key('traffic', cache_suffix)
await cache.set(traffic_cache_key, traffic_data, TRAFFIC_CACHE_TTL)
return {
'success': True,
'cached': False,
'source': 'remnawave',
**traffic_data,
}
except Exception as e:
logger.error('Error refreshing traffic for user', user_id=user.id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to refresh traffic data',
)
+6 -2
View File
@@ -317,7 +317,8 @@ async def notify_user_balance_change(
async def notify_user_subscription_activated(
user_id: int,
expires_at: str,
subscription_id: int | None = None,
expires_at: str = '',
tariff_name: str = '',
) -> None:
"""Уведомить пользователя об активации подписки."""
@@ -325,6 +326,7 @@ async def notify_user_subscription_activated(
user_id,
{
'type': 'subscription.activated',
'subscription_id': subscription_id,
'expires_at': expires_at,
'tariff_name': tariff_name,
},
@@ -359,7 +361,8 @@ async def notify_user_subscription_expired(user_id: int) -> None:
async def notify_user_subscription_renewed(
user_id: int,
new_expires_at: str,
subscription_id: int | None = None,
new_expires_at: str = '',
amount_kopeks: int = 0,
) -> None:
"""Уведомить пользователя о продлении подписки."""
@@ -367,6 +370,7 @@ async def notify_user_subscription_renewed(
user_id,
{
'type': 'subscription.renewed',
'subscription_id': subscription_id,
'new_expires_at': new_expires_at,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
+41 -7
View File
@@ -20,6 +20,7 @@ from app.cabinet.schemas.wheel import (
WheelConfigResponse,
WheelPrizeDisplay,
)
from app.config import settings
from app.database.crud.wheel import (
get_or_create_wheel_config,
get_user_spin_history,
@@ -48,10 +49,22 @@ async def get_wheel_config(
# Проверяем доступность
availability = await wheel_service.check_availability(db, user)
# Проверяем наличие подписки
from app.database.crud.subscription import get_subscription_by_user_id
# Проверяем наличие подписки (multi-tariff aware)
if settings.is_multi_tariff_enabled():
from app.database.crud.subscription import get_active_subscriptions_by_user_id
subscription = await get_subscription_by_user_id(db, user.id)
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
# Check if user has any active subscription for wheel access
if active_subs:
_non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)]
_pool = _non_daily or active_subs
subscription = max(_pool, key=lambda s: s.days_left)
else:
subscription = None
else:
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user.id)
has_subscription = subscription is not None and subscription.is_active
prizes_display = [
@@ -65,6 +78,14 @@ async def get_wheel_config(
for p in prizes
]
# Build eligible subscriptions for frontend picker
eligible_subs_display = None
if availability.eligible_subscriptions:
eligible_subs_display = [
{'id': s.id, 'tariff_name': s.tariff_name, 'days_left': s.days_left}
for s in availability.eligible_subscriptions
]
return WheelConfigResponse(
is_enabled=config.is_enabled,
name=config.name,
@@ -82,6 +103,7 @@ async def get_wheel_config(
user_balance_kopeks=availability.user_balance_kopeks,
required_balance_kopeks=availability.required_balance_kopeks,
has_subscription=has_subscription,
eligible_subscriptions=eligible_subs_display,
)
@@ -113,7 +135,7 @@ async def spin_wheel(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Крутить колесо удачи."""
result = await wheel_service.spin(db, user, request.payment_type.value)
result = await wheel_service.spin(db, user, request.payment_type.value, subscription_id=request.subscription_id)
if not result.success:
# Возвращаем ошибку в теле ответа, а не HTTP exception
@@ -218,10 +240,22 @@ async def create_stars_invoice(
detail='Оплата Stars не включена',
)
# Проверяем наличие активной подписки
from app.database.crud.subscription import get_subscription_by_user_id
# Проверяем наличие активной подписки (multi-tariff aware)
if settings.is_multi_tariff_enabled():
from app.database.crud.subscription import get_active_subscriptions_by_user_id
subscription = await get_subscription_by_user_id(db, user.id)
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
# Check if user has any active subscription for Stars invoice
if active_subs:
_non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)]
_pool = _non_daily or active_subs
subscription = max(_pool, key=lambda s: s.days_left)
else:
subscription = None
else:
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription or not subscription.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
+15 -1
View File
@@ -138,6 +138,9 @@ class EmailRegisterStandaloneRequest(BaseModel):
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
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 CampaignBonusInfo(BaseModel):
@@ -198,6 +201,17 @@ class DeepLinkTokenResponse(BaseModel):
class DeepLinkPollRequest(BaseModel):
"""Request to poll deep link auth status."""
"""Request to poll deep link auth status.
Deep link auth is always for existing bot users referral codes are not applicable here.
Only campaign_slug is supported (campaign bonus can apply to existing users).
"""
token: str = Field(..., min_length=16, max_length=128, description='Deep link auth token')
campaign_slug: str | None = Field(
None,
min_length=1,
max_length=64,
pattern=r'^[a-zA-Z0-9_-]+$',
description='Campaign slug captured from cabinet URL',
)
+31 -1
View File
@@ -3,7 +3,7 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
# ============ Channel Types ============
@@ -75,6 +75,27 @@ class BroadcastButtonsResponse(BaseModel):
buttons: list[BroadcastButton]
class CustomBroadcastButton(BaseModel):
"""Custom button for broadcast message."""
label: str = Field(..., min_length=1, max_length=64)
action_type: Literal['callback', 'url'] = 'callback'
action_value: str = Field(..., min_length=1, max_length=256)
@field_validator('action_value')
@classmethod
def validate_action_value(cls, v: str, info) -> str:
action_type = info.data.get('action_type', 'callback')
if action_type == 'url':
if not v.startswith(('https://', 'tg://')):
raise ValueError('URL must start with https:// or tg://')
elif action_type == 'callback':
# Telegram API limits callback_data to 64 bytes
if len(v.encode('utf-8')) > 64:
raise ValueError('Callback data must be at most 64 bytes')
return v
# ============ Media ============
@@ -95,7 +116,9 @@ class BroadcastCreateRequest(BaseModel):
target: str
message_text: str = Field(..., min_length=1, max_length=4000)
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
media: BroadcastMediaRequest | None = None
category: str = Field(default='system', pattern='^(system|news|promo)$')
# ============ Response ============
@@ -122,6 +145,9 @@ class BroadcastResponse(BaseModel):
completed_at: datetime | None = None
progress_percent: float = 0.0
# Category for user notification preference filtering
category: str = 'system' # system|news|promo
# Email/channel fields
channel: str = 'telegram' # telegram|email|both
email_subject: str | None = None
@@ -187,8 +213,12 @@ class CombinedBroadcastCreateRequest(BaseModel):
# Telegram-specific fields
message_text: str | None = Field(default=None, max_length=4000)
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
media: BroadcastMediaRequest | None = None
# Broadcast category for user notification preference filtering
category: str = Field(default='system', pattern='^(system|news|promo)$')
# Email-specific fields
email_subject: str | None = Field(default=None, max_length=255)
email_html_content: str | None = Field(default=None, max_length=100000)
+332
View File
@@ -0,0 +1,332 @@
"""Schemas for news articles in cabinet.
Security notes:
- featured_image_url is validated to only accept http/https schemes.
- category_color is validated as a strict hex color (#RGB, #RRGGBB, etc.).
- Slug is sanitized to only allow [a-zA-Z0-9_-].
- Content is server-side sanitized to strip <script>, event handlers, and
dangerous URI schemes as a defense-in-depth measure. The frontend also
sanitizes via DOMPurify, but server-side sanitization protects against
alternative consumers (mobile apps, RSS, email digests) and compromised
frontends.
"""
import re
from datetime import datetime
from urllib.parse import urlparse
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# Pre-compiled regex for hex color validation (reused across validators)
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
# Pre-compiled regex for collapsing repeated hyphens in slugs
_MULTI_HYPHEN_RE: re.Pattern[str] = re.compile(r'-+')
# Maximum slug length (matches DB column constraint)
_MAX_SLUG_LENGTH: int = 500
# Allowed URL schemes for user-supplied URLs (featured_image_url)
_SAFE_URL_SCHEMES: frozenset[str] = frozenset({'http', 'https'})
# Cyrillic-to-Latin transliteration map for slug generation
_TRANSLIT_MAP: dict[str, str] = {
'а': 'a',
'б': 'b',
'в': 'v',
'г': 'g',
'д': 'd',
'е': 'e',
'ё': 'yo',
'ж': 'zh',
'з': 'z',
'и': 'i',
'й': 'y',
'к': 'k',
'л': 'l',
'м': 'm',
'н': 'n',
'о': 'o',
'п': 'p',
'р': 'r',
'с': 's',
'т': 't',
'у': 'u',
'ф': 'f',
'х': 'kh',
'ц': 'ts',
'ч': 'ch',
'ш': 'sh',
'щ': 'shch',
'ъ': '',
'ы': 'y',
'ь': '',
'э': 'e',
'ю': 'yu',
'я': 'ya',
}
def _slugify(title: str) -> str:
"""Generate a URL-safe slug from a title, transliterating Cyrillic."""
slug = title.lower()
result: list[str] = []
for ch in slug:
if ch in _TRANSLIT_MAP:
result.append(_TRANSLIT_MAP[ch])
elif ch.isascii() and (ch.isalnum() or ch in '-_'):
result.append(ch)
elif ch == ' ':
result.append('-')
slug = ''.join(result)
slug = _MULTI_HYPHEN_RE.sub('-', slug).strip('-')
return slug[:_MAX_SLUG_LENGTH] or 'untitled'
def _validate_hex_color(v: str) -> str:
"""Validate a hex color string. Raises ValueError on invalid input."""
if not _HEX_COLOR_RE.match(v):
msg = 'category_color must be a valid hex color (e.g. #00e5a0)'
raise ValueError(msg)
return v
def _validate_safe_url(v: str) -> str:
"""Validate that a URL uses http or https scheme only.
Prevents javascript:, data:, vbscript:, and other dangerous URI schemes
from being stored in the database and later rendered in <img> or <a> tags.
"""
try:
parsed = urlparse(v)
except Exception:
msg = 'Invalid URL format'
raise ValueError(msg)
if parsed.scheme not in _SAFE_URL_SCHEMES:
msg = f'URL scheme must be http or https, got: {parsed.scheme!r}'
raise ValueError(msg)
if not parsed.netloc:
msg = 'URL must have a valid host'
raise ValueError(msg)
return v
# --- Server-side HTML content sanitization ---
# Pre-compiled patterns for stripping the most dangerous HTML constructs.
# This is a defense-in-depth measure: the frontend also sanitizes via DOMPurify.
# Uses regex rather than a full HTML parser to avoid adding a new dependency.
# Strips: <script>, <style>, <object>, <embed>, <applet>, <base>, <form>,
# <link>, <meta> tags and all on* event handler attributes.
_DANGEROUS_TAGS_RE: re.Pattern[str] = re.compile(
r'<\s*/?\s*(script|style|object|embed|applet|base|form|link(?:\s)|meta)\b[^>]*>',
re.IGNORECASE | re.DOTALL,
)
# Match on* event handler attributes, e.g. onclick="...", onerror='...'
_EVENT_HANDLER_RE: re.Pattern[str] = re.compile(
r'\s+on[a-z]+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)',
re.IGNORECASE,
)
# Match javascript:, vbscript:, data: in href/src attributes
_DANGEROUS_URI_RE: re.Pattern[str] = re.compile(
r'((?:href|src)\s*=\s*["\'])\s*(javascript|vbscript|data)\s*:',
re.IGNORECASE,
)
def _sanitize_html_content(html: str) -> str:
"""Strip dangerous HTML constructs from article content.
This is NOT a replacement for DOMPurify on the frontend. It is a
defense-in-depth layer that removes the most obvious XSS vectors
at the storage boundary. A full HTML sanitizer (nh3, bleach) would
be stronger, but this avoids adding a new dependency.
"""
if not html:
return html
# 1. Remove dangerous tags and their content
result = _DANGEROUS_TAGS_RE.sub('', html)
# Also strip <script>...</script> content (tag + body)
result = re.sub(r'<script\b[^>]*>[\s\S]*?</script>', '', result, flags=re.IGNORECASE)
result = re.sub(r'<style\b[^>]*>[\s\S]*?</style>', '', result, flags=re.IGNORECASE)
# 2. Remove event handler attributes
result = _EVENT_HANDLER_RE.sub('', result)
# 3. Neutralize dangerous URI schemes in href/src
result = _DANGEROUS_URI_RE.sub(r'\1about:', result)
return result
class NewsArticleResponse(BaseModel):
"""Full news article response (detail view)."""
id: int
title: str
slug: str
content: str
excerpt: str | None
category: str
category_color: str
tag: str | None
category_id: int | None = None
tag_id: int | None = None
featured_image_url: str | None
is_published: bool
is_featured: bool
published_at: datetime | None
read_time_minutes: int
views_count: int
author_name: str | None = None
created_at: datetime
updated_at: datetime | None
model_config = ConfigDict(from_attributes=True)
class NewsArticleListItem(BaseModel):
"""Compact news article for list views."""
id: int
title: str
slug: str
excerpt: str | None
category: str
category_color: str
tag: str | None
category_id: int | None = None
tag_id: int | None = None
featured_image_url: str | None
is_published: bool
is_featured: bool
published_at: datetime | None
read_time_minutes: int
views_count: int
model_config = ConfigDict(from_attributes=True)
class NewsListResponse(BaseModel):
"""Paginated list of news articles."""
items: list[NewsArticleListItem]
total: int
categories: list[str] = Field(default_factory=list)
class NewsCreateRequest(BaseModel):
"""Request to create a news article."""
title: str = Field(..., min_length=1, max_length=500)
slug: str | None = Field(None, min_length=1, max_length=500)
content: str = Field(default='', max_length=500_000)
excerpt: str | None = Field(None, max_length=1000)
category: str = Field(..., min_length=1, max_length=100)
category_color: str = Field(default='#00e5a0', max_length=20)
tag: str | None = Field(None, max_length=50)
category_id: int | None = None
tag_id: int | None = None
featured_image_url: str | None = Field(None, max_length=2000)
is_published: bool = False
is_featured: bool = False
read_time_minutes: int = Field(default=1, ge=1, le=60)
@field_validator('content')
@classmethod
def sanitize_content(cls, v: str) -> str:
"""Strip dangerous HTML from article content (defense-in-depth)."""
return _sanitize_html_content(v)
@field_validator('category_color')
@classmethod
def validate_hex_color(cls, v: str) -> str:
return _validate_hex_color(v)
@field_validator('featured_image_url')
@classmethod
def validate_featured_image_url(cls, v: str | None) -> str | None:
"""Reject javascript:, data:, and other dangerous URL schemes."""
if v is not None:
return _validate_safe_url(v)
return v
@model_validator(mode='before')
@classmethod
def auto_generate_slug(cls, data: dict) -> dict: # type: ignore[type-arg]
"""Generate slug from title when not explicitly provided."""
if isinstance(data, dict) and not data.get('slug'):
title = data.get('title', '')
data['slug'] = _slugify(title) if isinstance(title, str) else 'untitled'
return data
@field_validator('slug')
@classmethod
def sanitize_slug(cls, v: str | None) -> str | None:
"""Ensure slug contains only URL-safe characters, transliterating Cyrillic."""
if v is not None:
return _slugify(v)
return v
class NewsUpdateRequest(BaseModel):
"""Request to update a news article."""
title: str | None = Field(None, min_length=1, max_length=500)
slug: str | None = Field(None, min_length=1, max_length=500)
content: str | None = Field(None, max_length=500_000)
excerpt: str | None = None
category: str | None = Field(None, min_length=1, max_length=100)
category_color: str | None = Field(None, max_length=20)
tag: str | None = None
category_id: int | None = None
tag_id: int | None = None
featured_image_url: str | None = Field(None, max_length=2000)
is_published: bool | None = None
is_featured: bool | None = None
read_time_minutes: int | None = Field(None, ge=1, le=60)
@field_validator('content')
@classmethod
def sanitize_content(cls, v: str | None) -> str | None:
"""Strip dangerous HTML from article content (defense-in-depth)."""
if v is not None:
return _sanitize_html_content(v)
return v
@field_validator('category_color')
@classmethod
def validate_hex_color(cls, v: str | None) -> str | None:
if v is not None:
return _validate_hex_color(v)
return v
@field_validator('featured_image_url')
@classmethod
def validate_featured_image_url(cls, v: str | None) -> str | None:
"""Reject javascript:, data:, and other dangerous URL schemes."""
if v is not None:
return _validate_safe_url(v)
return v
@field_validator('slug')
@classmethod
def sanitize_slug(cls, v: str | None) -> str | None:
"""Ensure slug contains only URL-safe characters, transliterating Cyrillic."""
if v is not None:
return _slugify(v)
return v
class NewsToggleResponse(BaseModel):
"""Response after toggling publish/featured status."""
id: int
is_published: bool
is_featured: bool
published_at: datetime | None
+48
View File
@@ -0,0 +1,48 @@
"""Schemas for news categories."""
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
class NewsCategoryCreate(BaseModel):
"""Request to create a news category."""
name: str = Field(..., min_length=1, max_length=100)
color: str = Field(default='#00e5a0', max_length=20)
@field_validator('color')
@classmethod
def validate_color(cls, v: str) -> str:
if not _HEX_COLOR_RE.match(v):
msg = 'Invalid hex color'
raise ValueError(msg)
return v
class NewsCategoryUpdate(BaseModel):
"""Request to update a news category."""
name: str | None = Field(None, min_length=1, max_length=100)
color: str | None = Field(None, max_length=20)
@field_validator('color')
@classmethod
def validate_color(cls, v: str | None) -> str | None:
if v is not None and not _HEX_COLOR_RE.match(v):
msg = 'Invalid hex color'
raise ValueError(msg)
return v
class NewsCategoryResponse(BaseModel):
"""News category response."""
id: int
name: str
color: str
model_config = ConfigDict(from_attributes=True)
+17
View File
@@ -0,0 +1,17 @@
"""Schemas for news media upload responses."""
from typing import Literal
from pydantic import BaseModel
class NewsMediaUploadResponse(BaseModel):
"""Response returned after a successful media upload."""
url: str
thumbnail_url: str | None = None
media_type: Literal['image', 'video']
filename: str
size_bytes: int
width: int | None = None
height: int | None = None
+48
View File
@@ -0,0 +1,48 @@
"""Schemas for news tags."""
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
class NewsTagCreate(BaseModel):
"""Request to create a news tag."""
name: str = Field(..., min_length=1, max_length=50)
color: str = Field(default='#94a3b8', max_length=20)
@field_validator('color')
@classmethod
def validate_color(cls, v: str) -> str:
if not _HEX_COLOR_RE.match(v):
msg = 'Invalid hex color'
raise ValueError(msg)
return v
class NewsTagUpdate(BaseModel):
"""Request to update a news tag."""
name: str | None = Field(None, min_length=1, max_length=50)
color: str | None = Field(None, max_length=20)
@field_validator('color')
@classmethod
def validate_color(cls, v: str | None) -> str | None:
if v is not None and not _HEX_COLOR_RE.match(v):
msg = 'Invalid hex color'
raise ValueError(msg)
return v
class NewsTagResponse(BaseModel):
"""News tag response."""
id: int
name: str
color: str
model_config = ConfigDict(from_attributes=True)
+5 -7
View File
@@ -47,11 +47,9 @@ class ServerInfo(BaseModel):
"""Server hardware info."""
cpu_cores: int
cpu_physical_cores: int
memory_total: int
memory_used: int
memory_free: int
memory_available: int
uptime_seconds: int
@@ -108,22 +106,22 @@ class NodeInfo(BaseModel):
is_disabled: bool
is_node_online: bool
is_xray_running: bool
users_online: int | None = None
users_online: int = 0
traffic_used_bytes: int | None = None
traffic_limit_bytes: int | None = None
last_status_change: datetime | None = None
last_status_message: str | None = None
xray_uptime: str | None = None
xray_uptime: int = 0
is_traffic_tracking_active: bool = False
traffic_reset_day: int | None = None
notify_percent: int | None = None
consumption_multiplier: float = 1.0
cpu_count: int | None = None
cpu_model: str | None = None
total_ram: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
provider_uuid: str | None = None
versions: dict[str, str] | None = None
system: dict[str, Any] | None = None
active_plugin_uuid: str | None = None
class NodesListResponse(BaseModel):
+4
View File
@@ -88,6 +88,10 @@ class RenewalRequest(BaseModel):
"""Request to renew subscription."""
period_days: int = Field(..., ge=1, le=3650, description='Renewal period in days')
subscription_id: int | None = Field(
default=None,
description='ID of subscription to renew (required in multi-tariff mode)',
)
class TrafficPackageResponse(BaseModel):
+3 -3
View File
@@ -112,7 +112,7 @@ class TariffDetailResponse(BaseModel):
is_daily: bool = False
daily_price_kopeks: int = 0
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, MONTH_ROLLING, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = None
# Показывать в подарках
@@ -170,7 +170,7 @@ class TariffCreateRequest(BaseModel):
is_daily: bool = False
daily_price_kopeks: int = Field(0, ge=0)
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, MONTH_ROLLING, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
# Показывать в подарках
@@ -211,7 +211,7 @@ class TariffUpdateRequest(BaseModel):
is_daily: bool | None = None
daily_price_kopeks: int | None = Field(None, ge=0)
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, MONTH_ROLLING, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
# Показывать в подарках
+25
View File
@@ -9,18 +9,31 @@ class TrafficNodeInfo(BaseModel):
country_code: str
class SubscriptionTrafficInfo(BaseModel):
"""Per-subscription traffic metadata for multi-subscription display."""
subscription_id: int
tariff_name: str | None
status: str | None
traffic_limit_gb: float
device_limit: int
class UserTrafficItem(BaseModel):
user_id: int
telegram_id: int | None
username: str | None
email: str | None
full_name: str
# Primary subscription fields (backward compat — reflect the active/first sub)
tariff_name: str | None
subscription_status: str | None
traffic_limit_gb: float
device_limit: int
node_traffic: dict[str, int] # {node_uuid: total_bytes}
total_bytes: int
# All subscriptions for multi-subscription display
subscriptions: list[SubscriptionTrafficInfo] = Field(default_factory=list)
class TrafficUsageResponse(BaseModel):
@@ -34,12 +47,24 @@ class TrafficUsageResponse(BaseModel):
available_statuses: list[str]
class SubscriptionEnrichmentInfo(BaseModel):
"""Per-subscription enrichment (dates) for multi-subscription display."""
subscription_id: int
tariff_name: str | None
start_date: str | None
end_date: str | None
class UserTrafficEnrichment(BaseModel):
devices_connected: int = 0
total_spent_kopeks: int = 0
# Primary subscription dates (backward compat — reflect the active/first sub)
subscription_start_date: str | None = None
subscription_end_date: str | None = None
last_node_name: str | None = None
# All subscriptions for multi-subscription display
subscriptions: list[SubscriptionEnrichmentInfo] = Field(default_factory=list)
class TrafficEnrichmentResponse(BaseModel):
+42 -1
View File
@@ -177,9 +177,12 @@ class UserDetailResponse(BaseModel):
last_activity: datetime | None = None
cabinet_last_login: datetime | None = None
# Subscription
# Subscription (legacy single, kept for backward compat)
subscription: UserSubscriptionInfo | None = None
# All subscriptions (multi-tariff)
subscriptions: list[UserSubscriptionInfo] = []
# Promo group
promo_group: UserPromoGroupInfo | None = None
@@ -285,6 +288,9 @@ class UpdateSubscriptionRequest(BaseModel):
..., description='Action: extend, shorten, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
)
# Target subscription (required in multi-tariff mode for non-create actions)
subscription_id: int | None = Field(None, description='Subscription ID to target (multi-tariff)')
# For extend action
days: int | None = Field(None, ge=1, le=3650, description='Days to extend')
@@ -387,6 +393,37 @@ class UpdateReferralCommissionResponse(BaseModel):
message: str
class AssignReferrerRequest(BaseModel):
"""Request to manually assign a referrer to a user."""
referrer_id: int = Field(..., gt=0, description='ID of the referrer user')
class AssignReferrerResponse(BaseModel):
"""Response after referrer assignment."""
success: bool
old_referrer_id: int | None = None
new_referrer_id: int | None = None
message: str
class RemoveReferrerResponse(BaseModel):
"""Response after removing a user's referrer."""
success: bool
old_referrer_id: int | None = None
message: str
class RemoveReferralResponse(BaseModel):
"""Response after removing a specific referral from a user."""
success: bool
removed_user_id: int
message: str
class DeviceInfo(BaseModel):
"""Individual device info."""
@@ -608,6 +645,10 @@ class PanelSyncStatusResponse(BaseModel):
remnawave_uuid: str | None = None
last_sync: datetime | None = None
# Multi-tariff context
subscription_id: int | None = None
subscription_tariff_name: str | None = None
# Bot data
bot_subscription_status: str | None = None
bot_subscription_end_date: datetime | None = None
+2
View File
@@ -61,6 +61,7 @@ class WheelConfigResponse(BaseModel):
user_balance_kopeks: int = 0
required_balance_kopeks: int = 0
has_subscription: bool = False
eligible_subscriptions: list[dict] | None = None
class SpinAvailabilityResponse(BaseModel):
@@ -81,6 +82,7 @@ class SpinRequest(BaseModel):
"""Запрос на спин."""
payment_type: WheelPaymentType
subscription_id: int | None = None
class SpinResultResponse(BaseModel):
+49 -12
View File
@@ -351,18 +351,26 @@ class EmailNotificationTemplates:
"""Template for subscription expiring notification."""
days_left = context.get('days_left', 0)
expires_at = context.get('expires_at', '')
tariff_name = html.escape(context.get('tariff_name', ''))
tariff_suffix_ru = f' «{tariff_name}»' if tariff_name else ''
tariff_suffix_en = f' "{tariff_name}"' if tariff_name else ''
tariff_line_ru = f'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_en = f'<p>Plan: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_zh = f'<p>套餐: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_ua = f'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
subjects = {
'ru': f'Подписка истекает через {days_left} дн.',
'en': f'Subscription expires in {days_left} day(s)',
'ru': f'Подписка{tariff_suffix_ru} истекает через {days_left} дн.',
'en': f'Subscription{tariff_suffix_en} expires in {days_left} day(s)',
'zh': f'订阅将在 {days_left} 天后到期',
'ua': f'Підписка закінчується через {days_left} дн.',
'ua': f'Підписка{tariff_suffix_ru} закінчується через {days_left} дн.',
}
bodies = {
'ru': f"""
<h2>Подписка скоро истекает</h2>
<div class="highlight warning">
{tariff_line_ru}
<p>Ваша подписка истекает через <strong>{days_left}</strong> дн.</p>
<p>Дата истечения: <strong>{expires_at}</strong></p>
</div>
@@ -372,6 +380,7 @@ class EmailNotificationTemplates:
'en': f"""
<h2>Subscription Expiring Soon</h2>
<div class="highlight warning">
{tariff_line_en}
<p>Your subscription expires in <strong>{days_left}</strong> day(s).</p>
<p>Expiration date: <strong>{expires_at}</strong></p>
</div>
@@ -381,6 +390,7 @@ class EmailNotificationTemplates:
'zh': f"""
<h2>订阅即将到期</h2>
<div class="highlight warning">
{tariff_line_zh}
<p>您的订阅将在 <strong>{days_left}</strong> 天后到期</p>
<p>到期日期: <strong>{expires_at}</strong></p>
</div>
@@ -390,6 +400,7 @@ class EmailNotificationTemplates:
'ua': f"""
<h2>Підписка скоро закінчується</h2>
<div class="highlight warning">
{tariff_line_ua}
<p>Ваша підписка закінчується через <strong>{days_left}</strong> дн.</p>
<p>Дата закінчення: <strong>{expires_at}</strong></p>
</div>
@@ -405,17 +416,26 @@ class EmailNotificationTemplates:
def _subscription_expired_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for subscription expired notification."""
tariff_name = html.escape(context.get('tariff_name', ''))
tariff_suffix_ru = f' «{tariff_name}»' if tariff_name else ''
tariff_suffix_en = f' "{tariff_name}"' if tariff_name else ''
tariff_line_ru = f'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_en = f'<p>Plan: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_zh = f'<p>套餐: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_ua = f'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
subjects = {
'ru': 'Подписка истекла',
'en': 'Subscription Expired',
'ru': f'Подписка{tariff_suffix_ru} истекла',
'en': f'Subscription{tariff_suffix_en} Expired',
'zh': '订阅已到期',
'ua': 'Підписка закінчилась',
'ua': f'Підписка{tariff_suffix_ru} закінчилась',
}
bodies = {
'ru': f"""
<h2>Подписка истекла</h2>
<div class="highlight danger">
{tariff_line_ru}
<p>Ваша подписка истекла. Доступ к VPN отключён.</p>
</div>
<p>Оформите новую подписку, чтобы продолжить использование сервиса.</p>
@@ -424,6 +444,7 @@ class EmailNotificationTemplates:
'en': f"""
<h2>Subscription Expired</h2>
<div class="highlight danger">
{tariff_line_en}
<p>Your subscription has expired. VPN access has been disabled.</p>
</div>
<p>Purchase a new subscription to continue using our service.</p>
@@ -432,6 +453,7 @@ class EmailNotificationTemplates:
'zh': f"""
<h2>订阅已到期</h2>
<div class="highlight danger">
{tariff_line_zh}
<p>您的订阅已到期VPN访问已被禁用</p>
</div>
<p>请购买新订阅以继续使用我们的服务</p>
@@ -440,6 +462,7 @@ class EmailNotificationTemplates:
'ua': f"""
<h2>Підписка закінчилась</h2>
<div class="highlight danger">
{tariff_line_ua}
<p>Ваша підписка закінчилась. Доступ до VPN вимкнено.</p>
</div>
<p>Оформіть нову підписку, щоб продовжити використання сервісу.</p>
@@ -455,18 +478,24 @@ class EmailNotificationTemplates:
def _subscription_renewed_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for subscription renewed notification."""
new_expires_at = context.get('new_expires_at', '')
tariff_name = html.escape(context.get('tariff_name', ''))
tariff_suffix_ru = f' «{tariff_name}»' if tariff_name else ''
tariff_suffix_en = f' "{tariff_name}"' if tariff_name else ''
tariff_line_ru = f'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_en = f'<p>Plan: <strong>{tariff_name}</strong></p>' if tariff_name else ''
subjects = {
'ru': 'Подписка продлена',
'en': 'Subscription Renewed',
'ru': f'Подписка{tariff_suffix_ru} продлена',
'en': f'Subscription{tariff_suffix_en} Renewed',
'zh': '订阅已续订',
'ua': 'Підписку продовжено',
'ua': f'Підписку{tariff_suffix_ru} продовжено',
}
bodies = {
'ru': f"""
<h2>Подписка успешно продлена!</h2>
<div class="highlight success">
{tariff_line_ru}
<p>Ваша подписка была успешно продлена.</p>
<p>Новая дата истечения: <strong>{new_expires_at}</strong></p>
</div>
@@ -476,6 +505,7 @@ class EmailNotificationTemplates:
'en': f"""
<h2>Subscription Successfully Renewed!</h2>
<div class="highlight success">
{tariff_line_en}
<p>Your subscription has been successfully renewed.</p>
<p>New expiration date: <strong>{new_expires_at}</strong></p>
</div>
@@ -492,18 +522,24 @@ class EmailNotificationTemplates:
def _subscription_activated_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for subscription activated notification."""
expires_at = context.get('expires_at', '')
tariff_name = html.escape(context.get('tariff_name', ''))
tariff_suffix_ru = f' «{tariff_name}»' if tariff_name else ''
tariff_suffix_en = f' "{tariff_name}"' if tariff_name else ''
tariff_line_ru = f'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_en = f'<p>Plan: <strong>{tariff_name}</strong></p>' if tariff_name else ''
subjects = {
'ru': 'Подписка активирована',
'en': 'Subscription Activated',
'ru': f'Подписка{tariff_suffix_ru} активирована',
'en': f'Subscription{tariff_suffix_en} Activated',
'zh': '订阅已激活',
'ua': 'Підписку активовано',
'ua': f'Підписку{tariff_suffix_ru} активовано',
}
bodies = {
'ru': f"""
<h2>Подписка активирована!</h2>
<div class="highlight success">
{tariff_line_ru}
<p>Ваша VPN подписка успешно активирована.</p>
<p>Действует до: <strong>{expires_at}</strong></p>
</div>
@@ -513,6 +549,7 @@ class EmailNotificationTemplates:
'en': f"""
<h2>Subscription Activated!</h2>
<div class="highlight success">
{tariff_line_en}
<p>Your VPN subscription has been successfully activated.</p>
<p>Valid until: <strong>{expires_at}</strong></p>
</div>
+71 -19
View File
@@ -133,6 +133,7 @@ class Settings(BaseSettings):
WEBHOOK_NOTIFY_NOT_CONNECTED: bool = True
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD: bool = True
WEBHOOK_NOTIFY_DEVICES: bool = True
WEBHOOK_NOTIFY_TORRENT_DETECTED: bool = True
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
@@ -208,6 +209,11 @@ class Settings(BaseSettings):
# - tariffs: режим тарифов (готовые пакеты с фиксированными параметрами)
SALES_MODE: str = 'tariffs'
# Multi-tariff mode: allows users to purchase multiple tariffs simultaneously
# Only works when SALES_MODE='tariffs'
MULTI_TARIFF_ENABLED: bool = False
MAX_ACTIVE_SUBSCRIPTIONS: int = 10
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
# Длительность триала всё равно берётся из TRIAL_DURATION_DAYS
@@ -367,6 +373,7 @@ class Settings(BaseSettings):
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_RECURRENT_ENABLED: bool = False
YOOKASSA_RECURRENT_REQUIRED: bool = False
YOOKASSA_TEST_MODE: bool = False
SUPPORT_TOPUP_ENABLED: bool = True
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
@@ -376,6 +383,7 @@ class Settings(BaseSettings):
NALOGO_PASSWORD: str | None = None
NALOGO_DEVICE_ID: str | None = None
NALOGO_STORAGE_PATH: str = './nalogo_tokens.json'
NALOGO_PROXY_URL: str | None = None # SOCKS proxy for nalog.ru; falls back to PROXY_URL if not set
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
@@ -460,7 +468,8 @@ class Settings(BaseSettings):
PLATEGA_RETURN_URL: str | None = None
PLATEGA_FAILED_URL: str | None = None
PLATEGA_CURRENCY: str = 'RUB'
PLATEGA_ACTIVE_METHODS: str = '2,10,11,12,13'
PLATEGA_ACTIVE_METHODS: str = '2,11,12,13'
PLATEGA_INLINE_METHODS: bool = True
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
PLATEGA_WEBHOOK_PATH: str = '/platega-webhook'
@@ -550,6 +559,8 @@ class Settings(BaseSettings):
KASSA_AI_SBP_DISPLAY_NAME: str = 'СБП (KassaAI)'
KASSA_AI_CARD_ENABLED: bool = False # Карты РФ — payment_system_id=36
KASSA_AI_CARD_DISPLAY_NAME: str = 'Карта (KassaAI)'
KASSA_AI_SBERPAY_ENABLED: bool = False # SberPay — payment_system_id=43
KASSA_AI_SBERPAY_DISPLAY_NAME: str = 'SberPay (KassaAI)'
# RioPay (api.riopay.online) v2.0.1
RIOPAY_ENABLED: bool = False
@@ -581,6 +592,13 @@ class Settings(BaseSettings):
CONNECT_BUTTON_MODE: str = 'miniapp_subscription'
MINIAPP_CUSTOM_URL: str = ''
MINIAPP_STATIC_PATH: str = 'miniapp'
# Media upload settings (news article images/videos)
MEDIA_UPLOAD_DIR: str = './uploads'
MEDIA_MAX_IMAGE_SIZE_MB: int = 10
MEDIA_MAX_VIDEO_SIZE_MB: int = 50
MEDIA_IMAGE_MAX_DIMENSION: int = 2048
MEDIA_JPEG_QUALITY: int = 85
MINIAPP_PURCHASE_URL: str = ''
MINIAPP_SERVICE_NAME_EN: str = 'Bedolaga VPN'
MINIAPP_SERVICE_NAME_RU: str = 'Bedolaga VPN'
@@ -807,7 +825,7 @@ class Settings(BaseSettings):
# Format: socks5://user:password@host:port or socks5://host:port
PROXY_URL: str | None = None
@field_validator('PROXY_URL', mode='before')
@field_validator('PROXY_URL', 'NALOGO_PROXY_URL', mode='before')
@classmethod
def validate_proxy_url(cls, value: str | None) -> str | None:
if not value:
@@ -815,13 +833,13 @@ class Settings(BaseSettings):
from urllib.parse import urlparse
parsed = urlparse(value)
if parsed.scheme not in ('socks5', 'socks4'):
if parsed.scheme not in ('socks5', 'socks5h', 'socks4'):
raise ValueError(
f'PROXY_URL must use socks5:// or socks4:// scheme, got: {parsed.scheme!r}. '
'HTTP proxies are not supported for security reasons (bot token would be exposed).'
f'Proxy URL must use socks5://, socks5h://, or socks4:// scheme, got: {parsed.scheme!r}. '
'HTTP proxies are not supported for security reasons.'
)
if not parsed.hostname:
raise ValueError('PROXY_URL must contain a hostname')
raise ValueError('Proxy URL must contain a hostname')
return value
@field_validator('MAIN_MENU_MODE', mode='before')
@@ -954,6 +972,13 @@ class Settings(BaseSettings):
"""Return SOCKS5 proxy URL or None."""
return self.PROXY_URL if self.PROXY_URL else None
def get_nalogo_proxy_url(self) -> str | None:
"""Return SOCKS proxy URL for nalogo or None.
Uses NALOGO_PROXY_URL if set, otherwise falls back to PROXY_URL.
"""
return self.NALOGO_PROXY_URL or self.PROXY_URL
def is_admin(self, telegram_id: int | None = None, email: str | None = None) -> bool:
"""
Check if user is admin by telegram_id or email.
@@ -1125,12 +1150,17 @@ class Settings(BaseSettings):
username_clean = (username or '').lstrip('@')
full_name_value = full_name or ''
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
def _sanitize(value: str) -> str:
result = re.sub(r'[^0-9A-Za-z_-]+', '_', value)
return re.sub(r'_+', '_', result).strip('_-')
# Для email-пользователей формируем уникальный identifier
if telegram_id:
identifier = str(telegram_id)
elif email:
email_prefix = email.split('@')[0][:10]
identifier = f'email_{email_prefix}_{user_id}' if user_id else f'email_{email_prefix}'
email_prefix = _sanitize(email.split('@')[0][:10])
identifier = _sanitize(f'email_{email_prefix}_{user_id}' if user_id else f'email_{email_prefix}')
elif user_id:
identifier = f'id_{user_id}'
else:
@@ -1144,20 +1174,18 @@ class Settings(BaseSettings):
'username_clean': username_clean,
'telegram_id': str(telegram_id) if telegram_id else identifier,
'identifier': identifier,
'email': email.split('@')[0] if email else '',
'email': _sanitize(email.split('@')[0]) if email else '',
'user_id': str(user_id) if user_id else '',
},
)
raw_username = template.format_map(values).strip()
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
sanitized_username = re.sub(r'[^0-9A-Za-z_-]+', '_', raw_username)
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('_-')
sanitized_username = _sanitize(raw_username)
if not sanitized_username:
sanitized_username = f'user_{identifier}'
sanitized_username = _sanitize(f'user_{identifier}')
return sanitized_username[:36]
return sanitized_username[:36].strip('_-') or 'user'
@staticmethod
def parse_daily_time_list(raw_value: str | None) -> list[time]:
@@ -1656,6 +1684,14 @@ class Settings(BaseSettings):
def get_disabled_mode_device_limit(self) -> int | None:
return self.get_devices_selection_disabled_amount()
def is_multi_tariff_enabled(self) -> bool:
"""Проверяет, включен ли мультитарифный режим."""
return self.MULTI_TARIFF_ENABLED and self.SALES_MODE == 'tariffs'
def get_max_active_subscriptions(self) -> int:
"""Максимальное число одновременных подписок (>1 только в multi-tariff)."""
return self.MAX_ACTIVE_SUBSCRIPTIONS if self.is_multi_tariff_enabled() else 1
def is_tariffs_mode(self) -> bool:
"""Проверяет, включен ли режим продаж 'Тарифы'."""
return self.SALES_MODE == 'tariffs'
@@ -1806,7 +1842,7 @@ class Settings(BaseSettings):
except ValueError:
logger.warning('Некорректный код метода Platega', part=part)
continue
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
if method_code in {2, 11, 12, 13} and method_code not in seen:
methods.append(method_code)
seen.add(method_code)
@@ -1819,8 +1855,7 @@ class Settings(BaseSettings):
def get_platega_method_definitions() -> dict[int, dict[str, str]]:
return {
2: {'name': 'СБП (QR)', 'title': '🏦 СБП (QR)'},
10: {'name': 'Банковские карты (RUB)', 'title': '💳 Карты (RUB)'},
11: {'name': 'Банковские карты', 'title': '💳 Банковские карты'},
11: {'name': 'Карты (RUB)', 'title': '💳 Карты (RUB)'},
12: {'name': 'Международные карты', 'title': '🌍 Международные карты'},
13: {'name': 'Криптовалюта', 'title': '🪙 Криптовалюта'},
}
@@ -1948,6 +1983,16 @@ class Settings(BaseSettings):
def get_kassa_ai_card_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_card_display_name())
def is_kassa_ai_sberpay_enabled(self) -> bool:
return self.KASSA_AI_SBERPAY_ENABLED and self.is_kassa_ai_enabled()
def get_kassa_ai_sberpay_display_name(self) -> str:
name = (self.KASSA_AI_SBERPAY_DISPLAY_NAME or '').strip()
return name if name else 'SberPay (KassaAI)'
def get_kassa_ai_sberpay_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_sberpay_display_name())
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
@@ -2196,13 +2241,17 @@ class Settings(BaseSettings):
except (ValueError, AttributeError):
return [30, 60, 90, 180, 360]
def get_balance_payment_description(self, amount_kopeks: int, telegram_user_id: int | None = None) -> str:
def get_balance_payment_description(
self, amount_kopeks: int, telegram_user_id: int | None = None, user_db_id: int | None = None
) -> str:
# Базовое описание
description = f'{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}'
# Если передан user_id, добавляем его
# Добавляем идентификатор пользователя (TG ID приоритет, fallback на DB ID)
if telegram_user_id is not None:
description += f' (ID {telegram_user_id})'
elif user_db_id is not None:
description += f' (U{user_db_id})'
# Формируем финальную строку по шаблону
return self.PAYMENT_BALANCE_TEMPLATE.format(service_name=self.PAYMENT_SERVICE_NAME, description=description)
@@ -2615,6 +2664,9 @@ class Settings(BaseSettings):
raw_path = 'miniapp'
return Path(raw_path)
def get_media_upload_path(self) -> Path:
return Path(self.MEDIA_UPLOAD_DIR)
# Cabinet methods
def is_cabinet_enabled(self) -> bool:
return bool(self.CABINET_ENABLED)
+288
View File
@@ -0,0 +1,288 @@
"""CRUD operations for news articles."""
from datetime import UTC, datetime
from typing import Any
import structlog
from sqlalchemy import delete, func, nullslast, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import NewsArticle
logger = structlog.get_logger(__name__)
# Fields that can be set via update_news_article
_ALLOWED_UPDATE_FIELDS: frozenset[str] = frozenset(
{
'title',
'slug',
'content',
'excerpt',
'category',
'category_color',
'tag',
'category_id',
'tag_id',
'featured_image_url',
'is_published',
'is_featured',
'published_at',
'read_time_minutes',
}
)
# Fields that can be explicitly set to None
_NULLABLE_UPDATE_FIELDS: frozenset[str] = frozenset(
{
'excerpt',
'tag',
'category_id',
'tag_id',
'featured_image_url',
'published_at',
}
)
async def create_news_article(
db: AsyncSession,
*,
title: str,
slug: str,
content: str = '',
excerpt: str | None = None,
category: str = '',
category_color: str = '#00e5a0',
tag: str | None = None,
category_id: int | None = None,
tag_id: int | None = None,
featured_image_url: str | None = None,
is_published: bool = False,
is_featured: bool = False,
published_at: datetime | None = None,
read_time_minutes: int = 1,
created_by: int | None = None,
) -> NewsArticle:
"""Create a new news article.
Raises:
IntegrityError: if slug is not unique (caller must handle).
"""
# Auto-set published_at when publishing without explicit date
if is_published and published_at is None:
published_at = datetime.now(UTC)
article = NewsArticle(
title=title,
slug=slug,
content=content,
excerpt=excerpt,
category=category,
category_color=category_color,
tag=tag,
category_id=category_id,
tag_id=tag_id,
featured_image_url=featured_image_url,
is_published=is_published,
is_featured=is_featured,
published_at=published_at,
read_time_minutes=read_time_minutes,
created_by=created_by,
)
db.add(article)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(article)
logger.info(
'Created news article',
article_id=article.id,
slug=article.slug,
is_published=article.is_published,
)
return article
async def get_news_article_by_id(db: AsyncSession, article_id: int) -> NewsArticle | None:
"""Get a news article by ID with author, category, and tag relationships."""
result = await db.execute(
select(NewsArticle)
.options(
selectinload(NewsArticle.author),
selectinload(NewsArticle.category_obj),
selectinload(NewsArticle.tag_obj),
)
.where(NewsArticle.id == article_id)
)
return result.scalar_one_or_none()
async def get_news_article_by_slug(db: AsyncSession, slug: str) -> NewsArticle | None:
"""Get a news article by slug with author, category, and tag relationships."""
result = await db.execute(
select(NewsArticle)
.options(
selectinload(NewsArticle.author),
selectinload(NewsArticle.category_obj),
selectinload(NewsArticle.tag_obj),
)
.where(NewsArticle.slug == slug)
)
return result.scalar_one_or_none()
async def get_published_news(
db: AsyncSession,
*,
category: str | None = None,
limit: int = 20,
offset: int = 0,
) -> list[NewsArticle]:
"""Get published news articles, ordered by published_at descending.
Does NOT load the author relationship -- list views do not need it.
"""
stmt = select(NewsArticle).where(NewsArticle.is_published.is_(True))
if category:
stmt = stmt.where(NewsArticle.category == category)
# NULLs last so articles without published_at don't float to the top in DESC
stmt = stmt.order_by(nullslast(NewsArticle.published_at.desc())).offset(offset).limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_published_news_count(
db: AsyncSession,
*,
category: str | None = None,
) -> int:
"""Get count of published news articles, optionally filtered by category."""
stmt = select(func.count(NewsArticle.id)).where(NewsArticle.is_published.is_(True))
if category:
stmt = stmt.where(NewsArticle.category == category)
result = await db.execute(stmt)
return result.scalar_one() or 0
async def get_all_news(
db: AsyncSession,
*,
limit: int = 50,
offset: int = 0,
) -> list[NewsArticle]:
"""Get all news articles (admin), ordered by created_at descending."""
stmt = select(NewsArticle).order_by(NewsArticle.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_all_news_count(db: AsyncSession) -> int:
"""Get total count of all news articles."""
result = await db.execute(select(func.count(NewsArticle.id)))
return result.scalar_one() or 0
async def get_news_categories(db: AsyncSession) -> list[str]:
"""Get distinct categories from published articles."""
result = await db.execute(
select(NewsArticle.category)
.where(NewsArticle.is_published.is_(True))
.where(NewsArticle.category != '')
.distinct()
.order_by(NewsArticle.category)
)
return list(result.scalars().all())
async def unfeature_all_news(db: AsyncSession) -> None:
"""Remove featured flag from all articles (so only one can be featured).
Does NOT commit. The caller must commit the session to persist this change.
This is intentional the caller should commit both this operation and the
subsequent feature operation atomically.
"""
await db.execute(update(NewsArticle).where(NewsArticle.is_featured.is_(True)).values(is_featured=False))
async def update_news_article(
db: AsyncSession,
article: NewsArticle,
**kwargs: Any,
) -> NewsArticle:
"""Update a news article. Only whitelisted fields are applied.
Raises:
IntegrityError: if slug conflicts with another article (caller must handle).
"""
update_data: dict[str, Any] = {}
for key, value in kwargs.items():
if key not in _ALLOWED_UPDATE_FIELDS:
continue
if value is None and key not in _NULLABLE_UPDATE_FIELDS:
continue
update_data[key] = value
# Auto-set published_at when transitioning to published
if update_data.get('is_published') and not article.is_published and not update_data.get('published_at'):
if article.published_at is None:
update_data['published_at'] = datetime.now(UTC)
if not update_data:
return article
update_data['updated_at'] = datetime.now(UTC)
await db.execute(update(NewsArticle).where(NewsArticle.id == article.id).values(**update_data))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(article)
logger.info(
'Updated news article',
article_id=article.id,
slug=article.slug,
updated_fields=list(update_data.keys()),
)
return article
async def delete_news_article(db: AsyncSession, article: NewsArticle) -> None:
"""Delete a news article."""
# Capture fields before commit expires the ORM instance attributes
article_id = article.id
article_slug = article.slug
await db.execute(delete(NewsArticle).where(NewsArticle.id == article_id))
await db.commit()
logger.info('Deleted news article', article_id=article_id, slug=article_slug)
async def increment_views(db: AsyncSession, article_id: int) -> int:
"""Atomically increment the views counter and return the new count.
Uses UPDATE RETURNING so the caller can patch the ORM instance directly
without issuing a second SELECT (db.refresh).
"""
result = await db.execute(
update(NewsArticle)
.where(NewsArticle.id == article_id)
.values(views_count=NewsArticle.views_count + 1)
.returning(NewsArticle.views_count)
)
await db.commit()
row = result.fetchone()
return row[0] if row else 0
+87
View File
@@ -0,0 +1,87 @@
"""CRUD operations for news categories."""
import structlog
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import NewsArticle, NewsCategory
logger = structlog.get_logger(__name__)
async def get_all_categories(db: AsyncSession) -> list[NewsCategory]:
"""Get all news categories ordered by name."""
result = await db.execute(select(NewsCategory).order_by(NewsCategory.name))
return list(result.scalars().all())
async def get_category_by_id(db: AsyncSession, category_id: int) -> NewsCategory | None:
"""Get a single news category by primary key."""
result = await db.execute(select(NewsCategory).where(NewsCategory.id == category_id))
return result.scalar_one_or_none()
async def create_category(db: AsyncSession, *, name: str, color: str = '#00e5a0') -> NewsCategory:
"""Create a new news category.
Raises:
IntegrityError: if a category with the same name already exists (caller must handle).
"""
category = NewsCategory(name=name.strip(), color=color)
db.add(category)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(category)
logger.info('Created news category', category_id=category.id, name=category.name)
return category
async def update_category(
db: AsyncSession,
category: NewsCategory,
**kwargs: str | None,
) -> NewsCategory:
"""Update an existing news category.
Supported kwargs: name, color.
Raises:
IntegrityError: if the new name conflicts with an existing category.
"""
update_data: dict[str, str] = {}
if 'name' in kwargs and kwargs['name'] is not None:
update_data['name'] = kwargs['name'].strip()
if 'color' in kwargs and kwargs['color'] is not None:
update_data['color'] = kwargs['color']
if not update_data:
return category
await db.execute(update(NewsCategory).where(NewsCategory.id == category.id).values(**update_data))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(category)
logger.info('Updated news category', category_id=category.id, updated_fields=list(update_data.keys()))
return category
async def delete_category(db: AsyncSession, category: NewsCategory) -> None:
"""Delete a news category and clear category fields from all linked articles."""
cat_id, cat_name = category.id, category.name
# Clear legacy string fields on articles that reference this category
await db.execute(
update(NewsArticle)
.where(NewsArticle.category_id == cat_id)
.values(category='', category_color='#00e5a0', category_id=None)
)
await db.delete(category)
await db.commit()
logger.info('Deleted news category', category_id=cat_id, name=cat_name)
+83
View File
@@ -0,0 +1,83 @@
"""CRUD operations for news tags."""
import structlog
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import NewsArticle, NewsTag
logger = structlog.get_logger(__name__)
async def get_all_tags(db: AsyncSession) -> list[NewsTag]:
"""Get all news tags ordered by name."""
result = await db.execute(select(NewsTag).order_by(NewsTag.name))
return list(result.scalars().all())
async def get_tag_by_id(db: AsyncSession, tag_id: int) -> NewsTag | None:
"""Get a single news tag by primary key."""
result = await db.execute(select(NewsTag).where(NewsTag.id == tag_id))
return result.scalar_one_or_none()
async def create_tag(db: AsyncSession, *, name: str, color: str = '#94a3b8') -> NewsTag:
"""Create a new news tag.
Raises:
IntegrityError: if a tag with the same name already exists (caller must handle).
"""
tag = NewsTag(name=name.strip(), color=color)
db.add(tag)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(tag)
logger.info('Created news tag', tag_id=tag.id, name=tag.name)
return tag
async def update_tag(
db: AsyncSession,
tag: NewsTag,
**kwargs: str | None,
) -> NewsTag:
"""Update an existing news tag.
Supported kwargs: name, color.
Raises:
IntegrityError: if the new name conflicts with an existing tag.
"""
update_data: dict[str, str] = {}
if 'name' in kwargs and kwargs['name'] is not None:
update_data['name'] = kwargs['name'].strip()
if 'color' in kwargs and kwargs['color'] is not None:
update_data['color'] = kwargs['color']
if not update_data:
return tag
await db.execute(update(NewsTag).where(NewsTag.id == tag.id).values(**update_data))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise
await db.refresh(tag)
logger.info('Updated news tag', tag_id=tag.id, updated_fields=list(update_data.keys()))
return tag
async def delete_tag(db: AsyncSession, tag: NewsTag) -> None:
"""Delete a news tag and clear tag fields from all linked articles."""
tag_id, tag_name = tag.id, tag.name
# Clear legacy string field on articles that reference this tag
await db.execute(update(NewsArticle).where(NewsArticle.tag_id == tag_id).values(tag=None, tag_id=None))
await db.delete(tag)
await db.commit()
logger.info('Deleted news tag', tag_id=tag_id, name=tag_name)
+8 -2
View File
@@ -34,6 +34,8 @@ async def record_notification(
subscription_id: int,
notification_type: str,
days_before: int | None = None,
*,
commit: bool = True,
) -> None:
already_exists = await notification_sent(db, user_id, subscription_id, notification_type, days_before)
if already_exists:
@@ -45,7 +47,8 @@ async def record_notification(
days_before=days_before,
)
db.add(notification)
await db.commit()
if commit:
await db.commit()
async def clear_notifications(db: AsyncSession, subscription_id: int, *, commit: bool = True) -> None:
@@ -58,6 +61,8 @@ async def clear_notification_by_type(
db: AsyncSession,
subscription_id: int,
notification_type: str,
*,
commit: bool = True,
) -> None:
await db.execute(
delete(SentNotification).where(
@@ -65,4 +70,5 @@ async def clear_notification_by_type(
SentNotification.notification_type == notification_type,
)
)
await db.commit()
if commit:
await db.commit()
+2 -2
View File
@@ -3,7 +3,7 @@ from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, User, UserPromoGroup
from app.database.models import PromoGroup, Subscription, User, UserPromoGroup
def _normalize_period_discounts(period_discounts: dict[int, int] | None) -> dict[int, int]:
@@ -259,7 +259,7 @@ async def get_promo_group_members(
) -> list[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
.where(User.promo_group_id == group_id)
.order_by(User.created_at.desc())
.offset(offset)
+1 -1
View File
@@ -430,7 +430,7 @@ async def get_server_connected_users(db: AsyncSession, server_id: int) -> list[U
),
)
.where(or_(*connection_filters))
.options(selectinload(User.subscription))
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
.order_by(User.id)
)
+323 -21
View File
@@ -1,8 +1,9 @@
import secrets
from collections.abc import Iterable
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import and_, delete, func, select
from sqlalchemy import and_, case, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.exc import StaleDataError
@@ -23,6 +24,18 @@ from app.utils.timezone import format_local_datetime
logger = structlog.get_logger(__name__)
async def generate_unique_short_id(db: AsyncSession, max_attempts: int = 10) -> str:
"""Generate a unique remnawave_short_id (6 hex chars) with collision check."""
for _ in range(max_attempts):
short_id = secrets.token_hex(3)
existing = await db.execute(select(Subscription.id).where(Subscription.remnawave_short_id == short_id).limit(1))
if existing.scalar_one_or_none() is None:
return short_id
# Fallback: 8 chars for extra entropy
return secrets.token_hex(4)
_WEBHOOK_GUARD_SECONDS = 60
@@ -67,6 +80,14 @@ def is_active_paid_subscription(subscription: Subscription | None) -> bool:
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None:
"""Get primary subscription for user.
Returns the first active/trial subscription, or the most recently created one.
Multi-tariff compatible: prioritizes active subscriptions.
For multi-tariff operations on a specific subscription, use get_subscription_by_id_for_user().
"""
from app.database.models import SubscriptionStatus
result = await db.execute(
select(Subscription)
.options(
@@ -74,7 +95,16 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscri
selectinload(Subscription.tariff),
)
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
.order_by(
# Active/trial subscriptions first, then by end_date (most remaining time)
case(
(Subscription.status == SubscriptionStatus.ACTIVE.value, 0),
(Subscription.status == SubscriptionStatus.TRIAL.value, 1),
else_=2,
),
Subscription.end_date.desc().nulls_last(),
Subscription.created_at.desc(),
)
.limit(1)
)
subscription = result.scalar_one_or_none()
@@ -135,7 +165,17 @@ async def create_trial_subscription(
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)
# In multi-tariff mode, only reuse a subscription for the SAME tariff to avoid
# overwriting a paid subscription for a different tariff.
existing = None
if settings.is_multi_tariff_enabled() and tariff_id:
for sub in await get_active_subscriptions_by_user_id(db, user_id):
if sub.tariff_id == tariff_id:
existing = sub
break
else:
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.now(UTC)
@@ -144,6 +184,8 @@ async def create_trial_subscription(
existing.device_limit = device_limit
existing.connected_squads = final_squads
existing.tariff_id = tariff_id
if not existing.remnawave_short_id:
existing.remnawave_short_id = await generate_unique_short_id(db)
await db.commit()
await db.refresh(existing)
logger.info(
@@ -151,6 +193,8 @@ async def create_trial_subscription(
)
return existing
short_id = await generate_unique_short_id(db)
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
@@ -160,9 +204,10 @@ async def create_trial_subscription(
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=final_squads,
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_enabled=False,
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
tariff_id=tariff_id,
remnawave_short_id=short_id,
)
db.add(subscription)
@@ -230,6 +275,8 @@ async def create_paid_subscription(
except Exception as error:
logger.error('❌ Не удалось получить fallback сквад', user_id=user_id, error=error)
short_id = await generate_unique_short_id(db)
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
@@ -242,6 +289,7 @@ async def create_paid_subscription(
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
tariff_id=tariff_id,
remnawave_short_id=short_id,
)
db.add(subscription)
@@ -251,6 +299,20 @@ async def create_paid_subscription(
else:
await db.flush()
# Kill all trial subscriptions when creating a paid subscription
# Trial = probe, must die on any paid purchase (regardless of path: bot, cabinet, webhook)
if not is_trial:
try:
killed = await deactivate_user_trial_subscriptions(db, user_id, exclude_subscription_id=subscription.id)
if killed:
logger.info(
'Deactivated trial subscriptions on paid purchase',
user_id=user_id,
killed_count=len(killed),
)
except Exception as trial_err:
logger.warning('Failed to deactivate trials on paid purchase', error=trial_err)
logger.info(
'💎 Создана платная подписка для пользователя ID: статус',
user_id=user_id,
@@ -637,6 +699,21 @@ async def extend_subscription(
await clear_notifications(db, subscription.id, commit=commit)
# Kill other trial subscriptions if this extension converts trial to paid
if not subscription.is_trial and days > 0:
try:
killed = await deactivate_user_trial_subscriptions(
db, subscription.user_id, exclude_subscription_id=subscription.id
)
if killed:
logger.info(
'Deactivated trial subscriptions on extend',
user_id=subscription.user_id,
killed_count=len(killed),
)
except Exception as trial_err:
logger.warning('Failed to deactivate trials on extend', error=trial_err)
logger.info('✅ Подписка продлена до', end_date=subscription.end_date)
logger.info('📊 Новые параметры: статус=, окончание', status=subscription.status, end_date=subscription.end_date)
@@ -694,6 +771,7 @@ async def add_subscription_devices(db: AsyncSession, subscription: Subscription,
locked_result = await db.execute(
select(Subscription)
.where(Subscription.id == subscription.id)
.options(selectinload(Subscription.tariff))
.with_for_update()
.execution_options(populate_existing=True)
)
@@ -712,6 +790,18 @@ async def add_subscription_devices(db: AsyncSession, subscription: Subscription,
)
new_limit = max_devices
# Check tariff max device limit
tariff_max = subscription.tariff.max_device_limit if subscription.tariff else None
if tariff_max is not None and tariff_max > 0 and new_limit > tariff_max:
logger.warning(
'📱 Попытка превысить лимит устройств тарифа',
user_id=subscription.user_id,
current=subscription.device_limit,
requested=devices,
tariff_max_devices=tariff_max,
)
new_limit = tariff_max
subscription.device_limit = new_limit
subscription.updated_at = datetime.now(UTC)
@@ -812,10 +902,11 @@ async def decrement_subscription_server_counts(
async def update_subscription_autopay(
db: AsyncSession, subscription: Subscription, enabled: bool, days_before: int = 3
db: AsyncSession, subscription: Subscription, enabled: bool, days_before: int | None = None
) -> Subscription:
subscription.autopay_enabled = enabled
subscription.autopay_days_before = days_before
if days_before is not None:
subscription.autopay_days_before = days_before
subscription.updated_at = datetime.now(UTC)
await db.commit()
@@ -826,18 +917,19 @@ async def update_subscription_autopay(
return subscription
async def deactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
async def deactivate_subscription(db: AsyncSession, subscription: Subscription, *, commit: bool = True) -> Subscription:
subscription.status = SubscriptionStatus.DISABLED.value
subscription.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
if commit:
await db.commit()
await db.refresh(subscription)
logger.info('❌ Подписка пользователя деактивирована', user_id=subscription.user_id)
return subscription
async def reactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
async def reactivate_subscription(db: AsyncSession, subscription: Subscription, *, commit: bool = True) -> Subscription:
"""Реактивация подписки (например, после повторной подписки на канал или докупки трафика).
Активирует если подписка была DISABLED или EXPIRED и ещё не истекла по времени.
@@ -861,8 +953,9 @@ async def reactivate_subscription(db: AsyncSession, subscription: Subscription)
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = now
await db.commit()
await db.refresh(subscription)
if commit:
await db.commit()
await db.refresh(subscription)
logger.info(
'✅ Подписка реактивирована',
@@ -1356,6 +1449,7 @@ async def create_subscription_no_commit(
if connected_squads is None:
connected_squads = []
short_id = await generate_unique_short_id(db)
subscription = Subscription(
user_id=user_id,
status=status,
@@ -1366,6 +1460,7 @@ async def create_subscription_no_commit(
device_limit=device_limit,
connected_squads=connected_squads,
remnawave_short_uuid=remnawave_short_uuid,
remnawave_short_id=short_id,
subscription_url=subscription_url,
subscription_crypto_link=subscription_crypto_link,
autopay_enabled=(settings.is_autopay_enabled_by_default() if autopay_enabled is None else autopay_enabled),
@@ -1406,6 +1501,7 @@ async def create_subscription(
if connected_squads is None:
connected_squads = []
short_id = await generate_unique_short_id(db)
subscription = Subscription(
user_id=user_id,
status=status,
@@ -1416,6 +1512,7 @@ async def create_subscription(
device_limit=device_limit,
connected_squads=connected_squads,
remnawave_short_uuid=remnawave_short_uuid,
remnawave_short_id=short_id,
subscription_url=subscription_url,
subscription_crypto_link=subscription_crypto_link,
autopay_enabled=(settings.is_autopay_enabled_by_default() if autopay_enabled is None else autopay_enabled),
@@ -1442,6 +1539,7 @@ async def create_pending_subscription(
payment_method: str = 'pending',
total_price_kopeks: int = 0,
is_trial: bool = False,
tariff_id: int | None = None,
) -> Subscription:
"""Creates a pending subscription that will be activated after payment.
@@ -1452,7 +1550,23 @@ async def create_pending_subscription(
current_time = datetime.now(UTC)
end_date = current_time + timedelta(days=duration_days)
existing_subscription = await get_subscription_by_user_id(db, user_id)
if settings.is_multi_tariff_enabled() and tariff_id:
active_subs = await get_active_subscriptions_by_user_id(db, user_id)
existing_subscription = next((s for s in active_subs if s.tariff_id == tariff_id), None)
if not existing_subscription:
# Also check non-active subs for this tariff
result = await db.execute(
select(Subscription)
.where(
Subscription.user_id == user_id,
Subscription.tariff_id == tariff_id,
)
.order_by(Subscription.created_at.desc())
.limit(1)
)
existing_subscription = result.scalar_one_or_none()
else:
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
if (
@@ -1475,6 +1589,8 @@ async def create_pending_subscription(
existing_subscription.connected_squads = connected_squads or []
existing_subscription.traffic_used_gb = 0.0
existing_subscription.updated_at = current_time
if tariff_id is not None:
existing_subscription.tariff_id = tariff_id
await db.commit()
await db.refresh(existing_subscription)
@@ -1488,6 +1604,7 @@ async def create_pending_subscription(
)
return existing_subscription
short_id = await generate_unique_short_id(db)
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.PENDING.value,
@@ -1497,8 +1614,10 @@ async def create_pending_subscription(
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads or [],
tariff_id=tariff_id,
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
remnawave_short_id=short_id,
)
db.add(subscription)
@@ -1526,6 +1645,7 @@ async def create_pending_trial_subscription(
connected_squads: list[str] = None,
payment_method: str = 'pending',
total_price_kopeks: int = 0,
tariff_id: int | None = None,
) -> Subscription:
"""Creates a pending trial subscription. Wrapper for create_pending_subscription with is_trial=True."""
return await create_pending_subscription(
@@ -1538,18 +1658,34 @@ async def create_pending_trial_subscription(
payment_method=payment_method,
total_price_kopeks=total_price_kopeks,
is_trial=True,
tariff_id=tariff_id,
)
async def activate_pending_subscription(db: AsyncSession, user_id: int, period_days: int = None) -> Subscription | None:
async def activate_pending_subscription(
db: AsyncSession,
user_id: int,
period_days: int = None,
subscription_id: int | None = None,
) -> Subscription | None:
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
logger.info('Активация pending подписки: пользователь период дней', user_id=user_id, period_days=period_days)
logger.info(
'Активация pending подписки: пользователь период дней',
user_id=user_id,
period_days=period_days,
subscription_id=subscription_id,
)
# Находим pending подписку пользователя (последнюю созданную при наличии нескольких)
conditions = [
Subscription.user_id == user_id,
Subscription.status == SubscriptionStatus.PENDING.value,
]
if subscription_id is not None:
conditions.append(Subscription.id == subscription_id)
# Находим pending подписку пользователя
result = await db.execute(
select(Subscription).where(
and_(Subscription.user_id == user_id, Subscription.status == SubscriptionStatus.PENDING.value)
)
select(Subscription).where(and_(*conditions)).order_by(Subscription.created_at.desc()).limit(1)
)
pending_subscription = result.scalar_one_or_none()
@@ -1860,6 +1996,8 @@ async def update_daily_charge_time(
db: AsyncSession,
subscription: Subscription,
charge_time: datetime = None,
*,
commit: bool = True,
) -> Subscription:
"""Обновляет время последнего суточного списания и продлевает подписку на 1 день."""
now = charge_time or datetime.now(UTC)
@@ -1871,8 +2009,11 @@ async def update_daily_charge_time(
subscription.end_date = new_end_date
logger.info('📅 Продлена подписка до', subscription_id=subscription.id, new_end_date=new_end_date)
await db.commit()
await db.refresh(subscription)
if commit:
await db.commit()
await db.refresh(subscription)
else:
await db.flush()
return subscription
@@ -1929,3 +2070,164 @@ async def toggle_daily_subscription_pause(
if subscription.is_daily_paused:
return await resume_daily_subscription(db, subscription)
return await pause_daily_subscription(db, subscription)
# ── Multi-tariff CRUD functions ──────────────────────────────────────────────
async def get_active_subscriptions_by_user_id(db: AsyncSession, user_id: int) -> list[Subscription]:
"""Get all active/trial/limited subscriptions for a user.
Includes LIMITED status because those subscriptions still have time remaining
(just ran out of traffic) and should be treated as "alive" for renewal,
duplicate prevention, and display purposes.
"""
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(
Subscription.user_id == user_id,
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.LIMITED.value,
]
),
)
.order_by(Subscription.created_at.desc())
)
return list(result.scalars().all())
async def get_subscription_by_id_for_user(db: AsyncSession, subscription_id: int, user_id: int) -> Subscription | None:
"""Get subscription by ID with ownership check (IDOR protection)."""
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(
Subscription.id == subscription_id,
Subscription.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def get_subscription_by_id(db: AsyncSession, subscription_id: int) -> Subscription | None:
"""Get subscription by ID (admin use only, no ownership check)."""
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(Subscription.id == subscription_id)
)
return result.scalar_one_or_none()
async def get_subscription_by_user_and_tariff(db: AsyncSession, user_id: int, tariff_id: int) -> Subscription | None:
"""Get active/trial/limited subscription for a specific user+tariff combination.
Includes LIMITED status because those subscriptions still have time remaining
(just ran out of traffic) and should be extended rather than duplicated.
"""
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(
Subscription.user_id == user_id,
Subscription.tariff_id == tariff_id,
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.LIMITED.value,
]
),
)
.order_by(Subscription.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def deactivate_user_trial_subscriptions(
db: AsyncSession,
user_id: int,
*,
exclude_subscription_id: int | None = None,
) -> list[Subscription]:
"""Deactivate all trial subscriptions for a user.
Called when user purchases a paid tariff trial is a probe that must die on purchase.
Returns remaining trial time in seconds (for TRIAL_ADD_REMAINING_DAYS_TO_PAID).
Handles both tariff-based and squad-based trials uniformly.
"""
result = await db.execute(
select(Subscription).where(
Subscription.user_id == user_id,
Subscription.is_trial.is_(True),
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
]
),
)
)
trial_subs = list(result.scalars().all())
deactivated = []
for sub in trial_subs:
if exclude_subscription_id and sub.id == exclude_subscription_id:
continue
sub.status = SubscriptionStatus.DISABLED.value
sub.is_trial = False
sub.autopay_enabled = False
sub.updated_at = datetime.now(UTC)
deactivated.append(sub)
logger.info(
'Trial subscription deactivated on paid purchase',
subscription_id=sub.id,
user_id=user_id,
tariff_id=sub.tariff_id,
)
if deactivated:
await db.flush()
return deactivated
async def get_all_subscriptions_by_user_id(db: AsyncSession, user_id: int) -> list[Subscription]:
"""Get all subscriptions for a user (any status).
Ordering: active first, then trial, then everything else newest first within each group.
"""
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(Subscription.user_id == user_id)
.order_by(
case(
(Subscription.status == SubscriptionStatus.ACTIVE.value, 0),
(Subscription.status == SubscriptionStatus.TRIAL.value, 1),
else_=2,
),
Subscription.created_at.desc(),
)
)
return list(result.scalars().all())
+27 -5
View File
@@ -3,7 +3,7 @@ from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, Subscription, Tariff
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Tariff
logger = structlog.get_logger(__name__)
@@ -83,13 +83,16 @@ async def count_tariffs(db: AsyncSession, *, include_inactive: bool = False) ->
async def get_trial_tariff(db: AsyncSession) -> Tariff | None:
"""Получает тариф, доступный для триала (is_trial_available=True).
Триальный тариф может быть неактивным это сделано специально,
чтобы он не отображался в списке покупки, но использовался для триала
со своими лимитами (трафик, устройства, серверы).
Сортируется по updated_at DESC, чтобы вернуть последний установленный
триальный тариф (на случай если их несколько).
"""
query = (
select(Tariff)
.where(Tariff.is_trial_available.is_(True))
.where(Tariff.is_active.is_(True))
.options(selectinload(Tariff.allowed_promo_groups))
.order_by(Tariff.updated_at.desc().nullslast(), Tariff.id.desc())
.limit(1)
@@ -119,6 +122,12 @@ async def clear_trial_tariff(db: AsyncSession) -> None:
await db.commit()
async def get_all_active_tariffs(db: AsyncSession) -> list[Tariff]:
"""Get all active tariffs."""
result = await db.execute(select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.tier_level))
return list(result.scalars().all())
async def get_tariffs_for_user(
db: AsyncSession,
promo_group_id: int | None = None,
@@ -188,7 +197,7 @@ async def create_tariff(
# Видимость в разделе подарков
show_in_gift: bool = True,
# Режим сброса трафика
traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, MONTH_ROLLING, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = None,
) -> Tariff:
@@ -390,7 +399,8 @@ async def update_tariff(
async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool:
"""
Удаляет тариф.
Подписки с этим тарифом получат tariff_id = NULL.
FK с ondelete=RESTRICT удаление невозможно, если есть привязанные подписки.
Вызывающий код должен проверить отсутствие активных подписок до вызова.
"""
tariff_id = tariff.id
tariff_name = tariff.name
@@ -401,7 +411,7 @@ async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool:
)
affected_subscriptions = subscriptions_count.scalar_one()
# Удаляем тариф (FK с ondelete=SET NULL автоматически обнулит tariff_id в подписках)
# Удаляем тариф (FK RESTRICT — подписок с tariff_id быть не должно)
await db.delete(tariff)
await db.commit()
@@ -421,6 +431,18 @@ async def get_tariff_subscriptions_count(db: AsyncSession, tariff_id: int) -> in
return int(result.scalar_one())
async def get_active_subscriptions_count_by_tariff_id(db: AsyncSession, tariff_id: int) -> int:
"""Подсчитывает количество активных (active/trial) подписок на тарифе."""
active_statuses = [SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]
result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.tariff_id == tariff_id,
Subscription.status.in_(active_statuses),
)
)
return int(result.scalar_one())
async def set_tariff_promo_groups(
db: AsyncSession,
tariff: Tariff,
+7 -2
View File
@@ -235,7 +235,12 @@ async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
and_(
Transaction.user_id == user_id,
Transaction.is_completed.is_(True),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.type.in_(
[
TransactionType.SUBSCRIPTION_PAYMENT.value,
TransactionType.GIFT_PAYMENT.value,
]
),
)
)
)
@@ -344,7 +349,7 @@ async def get_transactions_statistics(
select(
Transaction.payment_method,
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('total_amount'),
)
.where(
and_(
+75 -19
View File
@@ -8,6 +8,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
from app.database.crud.promo_group import get_default_promo_group
from app.database.crud.promo_offer_log import log_promo_offer_action
@@ -86,7 +87,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(Subscription.tariff),
selectinload(User.subscriptions).selectinload(Subscription.tariff),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -106,7 +107,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(Subscription.tariff),
selectinload(User.subscriptions).selectinload(Subscription.tariff),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -134,11 +135,12 @@ async def find_phantom_user_by_username(db: AsyncSession, username: str) -> User
result = await db.execute(
select(User)
.options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscriptions).selectinload(Subscription.tariff),
)
.where(
User.telegram_id.is_(None),
User.auth_type == 'telegram',
User.status != UserStatus.DELETED.value,
func.lower(User.username) == normalized,
)
.with_for_update()
@@ -155,7 +157,7 @@ async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscriptions).selectinload(Subscription.tariff),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -176,7 +178,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(Subscription.tariff),
selectinload(User.subscriptions).selectinload(Subscription.tariff),
selectinload(User.promo_group),
selectinload(User.referrer),
)
@@ -195,7 +197,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(Subscription.tariff),
selectinload(User.subscriptions).selectinload(Subscription.tariff),
selectinload(User.promo_group),
selectinload(User.referrer),
)
@@ -203,6 +205,21 @@ async def get_user_by_remnawave_uuid(db: AsyncSession, remnawave_uuid: str) -> U
)
user = result.scalar_one_or_none()
# Multi-tariff: UUID lives on Subscription, not User
if not user and settings.is_multi_tariff_enabled():
from app.database.models import Subscription as _Subscription
sub_result = await db.execute(
select(_Subscription)
.options(
selectinload(_Subscription.user).selectinload(User.subscriptions).selectinload(_Subscription.tariff)
)
.where(_Subscription.remnawave_uuid == remnawave_uuid)
)
sub = sub_result.scalar_one_or_none()
if sub and sub.user:
user = sub.user
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
@@ -315,6 +332,23 @@ async def create_user(
referral_code = await create_unique_referral_code(db)
normalized_language = _normalize_language_code(language)
# If no referrer provided, check Redis for pending referral from /start
if not referred_by_id and telegram_id:
try:
from app.services.referral_service import clear_pending_referral, get_pending_referral
pending = await get_pending_referral(telegram_id)
if pending and pending.get('referrer_id'):
referred_by_id = pending['referrer_id']
logger.info(
'Resolved referral from Redis pending_referral',
telegram_id=telegram_id,
referrer_id=referred_by_id,
)
await clear_pending_referral(telegram_id)
except Exception as e:
logger.warning('Failed to check pending referral from Redis', error=e)
attempts = 3
for attempt in range(1, attempts + 1):
@@ -421,7 +455,7 @@ async def lock_user_for_update(db: AsyncSession, user: User) -> User:
select(User)
.where(User.id == user.id)
.options(
selectinload(User.subscription),
selectinload(User.subscriptions).selectinload(Subscription.tariff),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
selectinload(User.referrer),
@@ -441,6 +475,7 @@ async def add_user_balance(
transaction_type: TransactionType = TransactionType.DEPOSIT,
bot=None,
payment_method: PaymentMethod | None = None,
commit: bool = True,
) -> bool:
try:
# Lock the user row to prevent concurrent balance race conditions
@@ -449,7 +484,7 @@ async def add_user_balance(
select(User)
.where(User.id == user.id)
.options(
selectinload(User.subscription),
selectinload(User.subscriptions).selectinload(Subscription.tariff),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
selectinload(User.referrer),
@@ -459,6 +494,14 @@ async def add_user_balance(
)
user = locked_result.scalar_one()
if amount_kopeks < 0:
logger.error(
'add_user_balance вызван с отрицательной суммой — используйте subtract_user_balance',
amount_kopeks=amount_kopeks,
user_id=user.id,
)
return False
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.now(UTC)
@@ -475,8 +518,9 @@ async def add_user_balance(
payment_method=payment_method,
)
await db.commit()
await db.refresh(user)
if commit:
await db.commit()
await db.refresh(user)
user_id_display = user.telegram_id or user.email or f'#{user.id}'
logger.info(
@@ -496,7 +540,8 @@ async def add_user_balance(
except Exception as e:
logger.error('Ошибка изменения баланса пользователя', user_id=user.id, error=e)
await db.rollback()
if commit:
await db.rollback()
return False
@@ -541,7 +586,7 @@ async def lock_user_for_pricing(db: AsyncSession, user_id: int) -> User:
.options(
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscriptions).selectinload(Subscription.tariff),
)
.with_for_update()
.execution_options(populate_existing=True)
@@ -580,7 +625,7 @@ async def subtract_user_balance(
select(User)
.where(User.id == user.id)
.options(
selectinload(User.subscription),
selectinload(User.subscriptions).selectinload(Subscription.tariff),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
selectinload(User.referrer),
@@ -801,7 +846,7 @@ async def get_users_list(
order_by_purchase_count: bool = False,
) -> list[User]:
query = select(User).options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscriptions).selectinload(Subscription.tariff),
selectinload(User.promo_group),
selectinload(User.referrer),
)
@@ -876,7 +921,7 @@ async def get_users_list(
query = query.offset(offset).limit(limit)
result = await db.execute(query)
users = result.scalars().all()
users = result.scalars().unique().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
@@ -961,7 +1006,7 @@ async def get_referrals(db: AsyncSession, user_id: int) -> list[User]:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscriptions).selectinload(Subscription.tariff),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -986,7 +1031,7 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[Us
base_query = (
select(User)
.options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscriptions).selectinload(Subscription.tariff),
selectinload(User.promo_group),
selectinload(User.referrer),
)
@@ -1048,7 +1093,7 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscriptions).selectinload(Subscription.tariff),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -1132,7 +1177,7 @@ async def get_users_with_active_subscriptions(db: AsyncSession) -> list[User]:
Subscription.end_date > current_time,
)
)
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
)
return result.scalars().unique().all()
@@ -1460,3 +1505,14 @@ async def create_user_by_oauth(
logger.warning('Failed to emit user.created event', error=error)
return user
async def lock_user_subscriptions_for_update(db: AsyncSession, user_id: int) -> list[Subscription]:
"""Lock all subscriptions for a user using SELECT FOR UPDATE."""
result = await db.execute(
select(Subscription)
.where(Subscription.user_id == user_id)
.with_for_update()
.order_by(Subscription.created_at.desc())
)
return list(result.scalars().all())
+5 -4
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime
import structlog
@@ -221,10 +222,10 @@ def replace_placeholders(text: str, user) -> str:
first_name = first_name.strip() if first_name else None
username = username.strip() if username else None
user_name = first_name or username or 'друг'
display_first_name = first_name or 'друг'
display_username = f'@{username}' if username else (first_name or 'друг')
clean_username = username or first_name or 'друг'
user_name = html.escape(first_name or username or 'друг')
display_first_name = html.escape(first_name or 'друг')
display_username = f'@{html.escape(username)}' if username else html.escape(first_name or 'друг')
clean_username = html.escape(username or first_name or 'друг')
replacements = {
'{user_name}': user_name,
+127 -6
View File
@@ -28,6 +28,7 @@ from sqlalchemy import (
Time,
TypeDecorator,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base
@@ -132,6 +133,7 @@ class TransactionType(Enum):
WITHDRAWAL = 'withdrawal'
SUBSCRIPTION_PAYMENT = 'subscription_payment'
REFUND = 'refund'
FAILED_REFUND = 'failed_refund'
REFERRAL_REWARD = 'referral_reward'
POLL_REWARD = 'poll_reward'
GIFT_PAYMENT = 'gift_payment'
@@ -931,6 +933,13 @@ class PromoGroup(Base):
if period_days in discounts:
return discounts[period_days]
# For daily tariffs (period_days=1): fallback to the smallest configured period discount.
# Admins configure discounts for standard periods (30, 90, 180, 360) but not for daily.
# If all periods have 100% discount, daily should too.
if period_days <= 1 and discounts:
smallest_period = min(discounts)
return discounts[smallest_period]
if self.is_default:
try:
from app.config import settings
@@ -1042,7 +1051,7 @@ class Tariff(Base):
# Видимость в разделе подарков
show_in_gift = Column(Boolean, default=True, server_default='true', nullable=False)
# Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига)
# Режим сброса трафика: DAY, WEEK, MONTH, MONTH_ROLLING, NO_RESET (по умолчанию берётся из конфига)
traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
# Внешний сквад RemnaWave (UUID) — назначается пользователю при создании подписки
@@ -1202,6 +1211,8 @@ class User(Base):
password_reset_token = Column(String(255), nullable=True)
password_reset_expires = Column(AwareDateTime(), nullable=True)
cabinet_last_login = Column(AwareDateTime(), nullable=True)
# Campaign slug saved at registration, consumed at email verification
pending_campaign_slug = Column(String(64), nullable=True)
# Email change fields
email_change_new = Column(String(255), nullable=True) # New email pending verification
email_change_code = Column(String(6), nullable=True) # 6-digit verification code
@@ -1215,7 +1226,23 @@ class User(Base):
referrals = relationship(
'User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id', post_update=True
)
subscription = relationship('Subscription', back_populates='user', uselist=False)
subscriptions = relationship('Subscription', back_populates='user', order_by='Subscription.created_at.desc()')
@property
def subscription(self) -> 'Subscription | None':
"""Deprecated: returns the first active subscription or most recent one.
Use user.subscriptions directly for multi-tariff support.
"""
if not self.subscriptions:
return None
# Prefer active/trial subscription
for sub in self.subscriptions:
if sub.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value):
return sub
# Fallback to most recent (already ordered by created_at desc)
return self.subscriptions[0]
transactions = relationship('Transaction', back_populates='user')
referral_earnings = relationship('ReferralEarning', foreign_keys='ReferralEarning.user_id', back_populates='user')
discount_offers = relationship('DiscountOffer', back_populates='user')
@@ -1331,10 +1358,20 @@ class Subscription(Base):
__table_args__ = (
Index('ix_subscriptions_status_trial', 'status', 'is_trial'),
Index('ix_subscriptions_trial_created', 'is_trial', 'created_at'),
Index('ix_subscriptions_user_id', 'user_id'),
Index('ix_subscriptions_user_status', 'user_id', 'status'),
Index('ix_subscriptions_user_tariff_status', 'user_id', 'tariff_id', 'status'),
Index(
'uq_subscriptions_user_tariff_active',
'user_id',
'tariff_id',
unique=True,
postgresql_where=text("tariff_id IS NOT NULL AND status IN ('active', 'trial', 'limited')"),
),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, unique=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
status = Column(String(20), default=SubscriptionStatus.TRIAL.value)
is_trial = Column(Boolean, default=True)
@@ -1366,9 +1403,13 @@ class Subscription(Base):
last_webhook_update_at = Column(AwareDateTime(), nullable=True)
remnawave_short_uuid = Column(String(255), nullable=True)
remnawave_uuid = Column(String(255), nullable=True)
remnawave_short_id = Column(
String(16), nullable=False, unique=True, server_default=''
) # Permanent short ID for username suffix
# Тариф (для режима продаж "Тарифы")
tariff_id = Column(Integer, ForeignKey('tariffs.id', ondelete='SET NULL'), nullable=True, index=True)
tariff_id = Column(Integer, ForeignKey('tariffs.id', ondelete='RESTRICT'), nullable=True, index=True)
# Суточная подписка
is_daily_paused = Column(
@@ -1376,7 +1417,7 @@ class Subscription(Base):
) # Приостановлена ли суточная подписка пользователем
last_daily_charge_at = Column(AwareDateTime(), nullable=True) # Время последнего суточного списания
user = relationship('User', back_populates='subscription')
user = relationship('User', back_populates='subscriptions')
tariff = relationship('Tariff', back_populates='subscriptions')
discount_offers = relationship('DiscountOffer', back_populates='subscription')
temporary_accesses = relationship(
@@ -1655,6 +1696,8 @@ class PromoCode(Base):
is_active = Column(Boolean, default=True)
first_purchase_only = Column(Boolean, default=False) # Только для первой покупки
tariff_id = Column(Integer, ForeignKey('tariffs.id', ondelete='SET NULL'), nullable=True, index=True)
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
promo_group_id = Column(Integer, ForeignKey('promo_groups.id', ondelete='SET NULL'), nullable=True, index=True)
@@ -1663,6 +1706,7 @@ class PromoCode(Base):
uses = relationship('PromoCodeUse', back_populates='promocode')
promo_group = relationship('PromoGroup')
tariff = relationship('Tariff', foreign_keys=[tariff_id])
@property
def is_valid(self) -> bool:
@@ -2203,6 +2247,9 @@ class BroadcastHistory(Base):
created_at = Column(AwareDateTime(), server_default=func.now())
completed_at = Column(AwareDateTime(), nullable=True)
# Broadcast category for user notification preferences filtering
category = Column(String(20), default='system', nullable=False) # system|news|promo
# Email broadcast fields
channel = Column(String(20), default='telegram', nullable=False) # telegram|email|both
email_subject = Column(String(255), nullable=True)
@@ -2364,7 +2411,7 @@ class SubscriptionServer(Base):
__tablename__ = 'subscription_servers'
id = Column(Integer, primary_key=True, index=True)
subscription_id = Column(Integer, ForeignKey('subscriptions.id'), nullable=False)
subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='CASCADE'), nullable=False, index=True)
server_squad_id = Column(Integer, ForeignKey('server_squads.id'), nullable=False)
connected_at = Column(AwareDateTime(), default=func.now())
@@ -3288,6 +3335,8 @@ class GuestPurchase(Base):
auto_login_token = Column(Text, nullable=True)
recipient_warning = Column(String(50), nullable=True)
retry_count = Column(Integer, nullable=False, default=0, server_default='0')
receipt_uuid = Column(String(255), nullable=True, index=True)
receipt_created_at = Column(AwareDateTime(), nullable=True)
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
tariff = relationship('Tariff', lazy='selectin')
@@ -3297,3 +3346,75 @@ class GuestPurchase(Base):
def __repr__(self) -> str:
token_prefix = self.token[:5] if self.token else '?'
return f"<GuestPurchase token='{token_prefix}...' status='{self.status}'>"
class NewsArticle(Base):
"""News article for the cabinet news section."""
__tablename__ = 'news_articles'
__table_args__ = (
# Covers the main public list query: WHERE is_published = true ORDER BY published_at DESC
Index('ix_news_articles_published_at_published', 'is_published', 'published_at'),
# Covers the category-filtered public list: WHERE is_published = true AND category = ?
Index('ix_news_articles_published_category', 'is_published', 'category'),
# Covers the admin list query: ORDER BY created_at DESC
Index('ix_news_articles_created_at', 'created_at'),
)
id = Column(Integer, primary_key=True, index=True)
title = Column(String(500), nullable=False)
slug = Column(String(500), unique=True, nullable=False, index=True)
content = Column(Text, nullable=False, default='', server_default='')
excerpt = Column(Text, nullable=True)
category = Column(String(100), nullable=False, default='', server_default='')
category_color = Column(String(20), nullable=False, default='#00e5a0', server_default='#00e5a0')
tag = Column(String(50), nullable=True)
featured_image_url = Column(Text, nullable=True)
is_published = Column(Boolean, nullable=False, default=False, server_default='false')
is_featured = Column(Boolean, nullable=False, default=False, server_default='false')
published_at = Column(AwareDateTime(), nullable=True)
read_time_minutes = Column(Integer, nullable=False, default=1, server_default='1')
views_count = Column(Integer, nullable=False, default=0, server_default='0')
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
category_id = Column(Integer, ForeignKey('news_categories.id', ondelete='SET NULL'), nullable=True)
tag_id = Column(Integer, ForeignKey('news_tags.id', ondelete='SET NULL'), nullable=True)
author = relationship('User', backref='created_news_articles', foreign_keys=[created_by])
category_obj = relationship('NewsCategory', foreign_keys=[category_id], lazy='noload')
tag_obj = relationship('NewsTag', foreign_keys=[tag_id], lazy='noload')
def __repr__(self) -> str:
return f"<NewsArticle id={self.id} slug='{self.slug}' published={self.is_published}>"
class NewsCategory(Base):
"""Managed news category with a display color."""
__tablename__ = 'news_categories'
__table_args__ = (Index('ix_news_categories_name_lower', text('lower(name)'), unique=True),)
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False)
color = Column(String(20), nullable=False, server_default='#00e5a0')
created_at = Column(AwareDateTime(), server_default=func.now(), nullable=False)
def __repr__(self) -> str:
return f"<NewsCategory id={self.id} name='{self.name}'>"
class NewsTag(Base):
"""Managed news tag with a display color."""
__tablename__ = 'news_tags'
__table_args__ = (Index('ix_news_tags_name_lower', text('lower(name)'), unique=True),)
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), nullable=False)
color = Column(String(20), nullable=False, server_default='#94a3b8')
created_at = Column(AwareDateTime(), server_default=func.now(), nullable=False)
def __repr__(self) -> str:
return f"<NewsTag id={self.id} name='{self.name}'>"
+2 -2
View File
@@ -125,8 +125,8 @@ class CryptoBotService:
# По документации CryptoBot, ключ ВСЕГДА SHA256 от API токена
token = self.api_token
if not token:
logger.warning('CryptoBot API token не настроен, пропуск проверки подписи')
return True
logger.error('CryptoBot API token не настроен, отклоняем webhook')
return False
try:
secret_hash = hashlib.sha256(token.encode()).digest()
+2 -2
View File
@@ -155,8 +155,8 @@ class HeleketService:
def verify_webhook_signature(self, payload: dict[str, Any]) -> bool:
if not self.is_configured:
logger.warning('Heleket сервис не настроен, подпись пропускается')
return True
logger.error('Heleket сервис не настроен, отклоняем webhook')
return False
if not isinstance(payload, dict):
logger.error('Heleket webhook payload не dict', payload=payload)
+2 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import hashlib
import hmac
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Any
@@ -194,7 +195,7 @@ class Pal24Client:
except Pal24APIError:
logger.error('Pal24 signature verification failed: missing token')
return False
return expected == signature.upper()
return hmac.compare_digest(expected, signature.upper())
@staticmethod
def normalize_amount(amount_kopeks: int) -> Decimal:
+172 -47
View File
@@ -27,6 +27,7 @@ class TrafficLimitStrategy(Enum):
DAY = 'DAY'
WEEK = 'WEEK'
MONTH = 'MONTH'
MONTH_ROLLING = 'MONTH_ROLLING'
@dataclass
@@ -59,8 +60,6 @@ class RemnaWaveUser:
created_at: datetime
updated_at: datetime
user_traffic: UserTraffic | None = None
sub_last_user_agent: str | None = None
sub_last_opened_at: datetime | None = None
sub_revoked_at: datetime | None = None
last_traffic_reset_at: datetime | None = None
trojan_password: str | None = None
@@ -147,29 +146,27 @@ class RemnaWaveNode:
country_code: str
is_connected: bool
is_disabled: bool
users_online: int | None
users_online: int
traffic_used_bytes: int | None
traffic_limit_bytes: int | None
port: int | None = None
is_connecting: bool = False
xray_version: str | None = None
node_version: str | None = None
view_position: int = 0
tags: list[str] | None = None
# Новые поля API
last_status_change: datetime | None = None
last_status_message: str | None = None
xray_uptime: str | None = None
xray_uptime: int = 0
is_traffic_tracking_active: bool = False
traffic_reset_day: int | None = None
notify_percent: int | None = None
consumption_multiplier: float = 1.0
cpu_count: int | None = None
cpu_model: str | None = None
total_ram: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
provider_uuid: str | None = None
# v2.7.0: replaced cpuCount/cpuModel/totalRam/xrayVersion/nodeVersion
versions: dict[str, str] | None = None # {xray, node}
system: dict[str, Any] | None = None # {info: {arch, cpus, cpuModel, memoryTotal, ...}, stats: {...}}
active_plugin_uuid: str | None = None
@property
def is_node_online(self) -> bool:
@@ -379,15 +376,16 @@ class RemnaWaveAPI:
except json.JSONDecodeError:
response_data = {'raw_response': response_text}
if response.status == 429 and attempt < max_retries:
if response.status in (429, 502, 503, 504) and attempt < max_retries:
retry_after = float(response.headers.get('Retry-After', base_delay * (2**attempt)))
logger.warning(
'Rate limited (429) on , retry / after s',
method=method,
endpoint=endpoint,
attempt=attempt + 1,
max_retries=max_retries,
retry_after=retry_after,
'Retryable %s on %s %s, retry %s/%s after %ss',
response.status,
method,
endpoint,
attempt + 1,
max_retries,
retry_after,
)
await asyncio.sleep(retry_after)
continue
@@ -448,7 +446,7 @@ class RemnaWaveAPI:
'trafficLimitStrategy': traffic_limit_strategy.value,
}
if telegram_id:
if telegram_id is not None:
data['telegramId'] = telegram_id
if email:
data['email'] = email
@@ -469,7 +467,22 @@ class RemnaWaveAPI:
hwidDeviceLimit=data.get('hwidDeviceLimit'),
status=data.get('status'),
)
response = await self._make_request('POST', '/api/users', data)
try:
response = await self._make_request('POST', '/api/users', data)
except RemnaWaveAPIError as e:
# A039 = FK violation on externalSquadUuid — retry without it
error_code = (e.response_data or {}).get('errorCode', '')
if error_code == 'A039' and 'externalSquadUuid' in data:
stale_uuid = data.pop('externalSquadUuid')
logger.warning(
'A039 FK violation on externalSquadUuid, retrying without it',
stale_uuid=stale_uuid,
username=data.get('username'),
)
response = await self._make_request('POST', '/api/users', data)
else:
logger.error('POST /api/users FAILED — full payload', payload=data)
raise
user = self._parse_user(response['response'])
logger.info(
'POST /api/users response',
@@ -570,10 +583,20 @@ class RemnaWaveAPI:
try:
response = await self._make_request('PATCH', '/api/users', data)
except Exception:
# Логируем полный payload при ошибке для диагностики A039
logger.error('PATCH /api/users FAILED — full payload', payload=data)
raise
except RemnaWaveAPIError as e:
# A039 = FK violation on externalSquadUuid — retry without it
error_code = (e.response_data or {}).get('errorCode', '')
if error_code == 'A039' and 'externalSquadUuid' in data:
stale_uuid = data.pop('externalSquadUuid')
logger.warning(
'A039 FK violation on externalSquadUuid, retrying without it',
stale_uuid=stale_uuid,
uuid=uuid,
)
response = await self._make_request('PATCH', '/api/users', data)
else:
logger.error('PATCH /api/users FAILED — full payload', payload=data)
raise
user = self._parse_user(response['response'])
logger.info(
'PATCH /api/users response',
@@ -711,7 +734,7 @@ class RemnaWaveAPI:
async def remove_users_from_internal_squad(self, uuid: str) -> bool:
"""Удаляет всех пользователей из Internal Squad (bulk action)"""
response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/remove-users')
response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}/bulk-actions/remove-users')
return response['response']['eventSent']
async def reorder_internal_squads(self, items: list[dict[str, Any]]) -> list[RemnaWaveInternalSquad]:
@@ -791,7 +814,7 @@ class RemnaWaveAPI:
async def remove_users_from_external_squad(self, uuid: str) -> bool:
"""Удаляет всех пользователей из External Squad (bulk action)"""
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/remove-users')
response = await self._make_request('DELETE', f'/api/external-squads/{uuid}/bulk-actions/remove-users')
return response['response']['eventSent']
async def reorder_external_squads(self, items: list[dict[str, Any]]) -> list[RemnaWaveExternalSquad]:
@@ -843,8 +866,9 @@ class RemnaWaveAPI:
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/restart')
return response['response']['eventSent']
async def restart_all_nodes(self) -> bool:
response = await self._make_request('POST', '/api/nodes/actions/restart-all')
async def restart_all_nodes(self, force_restart: bool = False) -> bool:
data = {'forceRestart': force_restart}
response = await self._make_request('POST', '/api/nodes/actions/restart-all', data)
return response['response']['eventSent']
async def get_subscription_info(self, short_uuid: str) -> SubscriptionInfo:
@@ -920,8 +944,71 @@ class RemnaWaveAPI:
response = await self._make_request('GET', '/api/system/stats/nodes')
return response['response']
async def get_nodes_metrics(self) -> dict[str, Any]:
response = await self._make_request('GET', '/api/system/nodes/metrics')
return response.get('response', {})
async def get_nodes_realtime_usage(self) -> list[dict[str, Any]]:
return await self.get_bandwidth_stats_nodes_realtime()
"""Get per-node metrics with per-inbound traffic breakdown.
Uses /api/system/nodes/metrics (replacement for removed /api/bandwidth-stats/nodes/realtime).
Returns list of dicts with node totals + inbounds/outbounds arrays.
"""
try:
metrics = await self.get_nodes_metrics()
nodes = metrics.get('nodes', [])
if isinstance(metrics, list):
nodes = metrics
result = []
for node in nodes:
download_bytes = 0
upload_bytes = 0
inbounds = []
for ib in node.get('inboundsStats', []):
ib_dl = parse_bytes(ib.get('download', '0'))
ib_ul = parse_bytes(ib.get('upload', '0'))
download_bytes += ib_dl
upload_bytes += ib_ul
inbounds.append(
{
'tag': ib.get('tag', 'unknown'),
'downloadBytes': ib_dl,
'uploadBytes': ib_ul,
'totalBytes': ib_dl + ib_ul,
}
)
outbounds = []
for ob in node.get('outboundsStats', []):
ob_dl = parse_bytes(ob.get('download', '0'))
ob_ul = parse_bytes(ob.get('upload', '0'))
outbounds.append(
{
'tag': ob.get('tag', 'unknown'),
'downloadBytes': ob_dl,
'uploadBytes': ob_ul,
'totalBytes': ob_dl + ob_ul,
}
)
result.append(
{
'nodeUuid': node.get('nodeUuid', ''),
'nodeName': node.get('nodeName', ''),
'countryEmoji': node.get('countryEmoji', ''),
'providerName': node.get('providerName', ''),
'downloadBytes': download_bytes,
'uploadBytes': upload_bytes,
'totalBytes': download_bytes + upload_bytes,
'usersOnline': node.get('usersOnline', 0),
'inbounds': inbounds,
'outbounds': outbounds,
}
)
return result
except Exception as e:
logger.warning('Failed to get nodes metrics for realtime usage', error=e)
return []
async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> dict[str, Any]:
return await self.get_bandwidth_stats_user_legacy(user_uuid, start_date, end_date)
@@ -933,10 +1020,6 @@ class RemnaWaveAPI:
response = await self._make_request('GET', '/api/bandwidth-stats/nodes', params=params)
return response['response']
async def get_bandwidth_stats_nodes_realtime(self) -> list[dict[str, Any]]:
response = await self._make_request('GET', '/api/bandwidth-stats/nodes/realtime')
return response['response']
async def get_bandwidth_stats_node_users(
self, node_uuid: str, start_date: str, end_date: str, top_users_limit: int = 10
) -> dict[str, Any]:
@@ -1058,9 +1141,35 @@ class RemnaWaveAPI:
return {'total': 0, 'devices': []}
raise
async def get_user_devices_all(self, user_uuid: str) -> dict[str, Any]:
"""GET /api/hwid/devices/{user_uuid} — all devices for a user (paginated)."""
all_devices: list[dict[str, Any]] = []
start = 0
page_size = 1000
try:
while True:
response = await self._make_request(
'GET', f'/api/hwid/devices/{user_uuid}', params={'start': start, 'size': page_size}
)
data = response.get('response', {'devices': [], 'total': 0})
devices = data.get('devices', [])
total = data.get('total', 0)
all_devices.extend(devices)
if len(all_devices) >= total or not devices:
break
start += len(devices)
except RemnaWaveAPIError as e:
if e.status_code == 404:
return {'total': 0, 'devices': []}
raise
return {'devices': all_devices, 'total': len(all_devices)}
async def reset_user_devices(self, user_uuid: str) -> bool:
try:
devices_info = await self.get_user_devices(user_uuid)
devices_info = await self.get_user_devices_all(user_uuid)
devices = devices_info.get('devices', [])
if not devices:
@@ -1166,8 +1275,6 @@ class RemnaWaveAPI:
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
user_traffic=user_traffic,
sub_last_user_agent=user_data.get('subLastUserAgent'),
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
trojan_password=user_data.get('trojanPassword'),
@@ -1185,6 +1292,16 @@ class RemnaWaveAPI:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return None
@staticmethod
def _safe_int(value: Any, default: int = 0) -> int:
"""Safely convert a value to int, returning default on failure."""
if value is None:
return default
try:
return int(value)
except (ValueError, TypeError):
return default
def _parse_inbound(self, inbound_data: dict) -> RemnaWaveInbound:
"""Парсит данные inbound"""
return RemnaWaveInbound(
@@ -1232,29 +1349,26 @@ class RemnaWaveAPI:
country_code=node_data.get('countryCode', ''),
is_connected=node_data.get('isConnected', False),
is_disabled=node_data.get('isDisabled', False),
users_online=node_data.get('usersOnline'),
users_online=node_data.get('usersOnline', 0),
traffic_used_bytes=node_data.get('trafficUsedBytes'),
traffic_limit_bytes=node_data.get('trafficLimitBytes'),
port=node_data.get('port'),
is_connecting=node_data.get('isConnecting', False),
xray_version=node_data.get('xrayVersion'),
node_version=node_data.get('nodeVersion'),
view_position=node_data.get('viewPosition', 0),
tags=node_data.get('tags', []),
# Новые поля API
last_status_change=self._parse_optional_datetime(node_data.get('lastStatusChange')),
last_status_message=node_data.get('lastStatusMessage'),
xray_uptime=node_data.get('xrayUptime'),
xray_uptime=self._safe_int(node_data.get('xrayUptime')),
is_traffic_tracking_active=node_data.get('isTrafficTrackingActive', False),
traffic_reset_day=node_data.get('trafficResetDay'),
notify_percent=node_data.get('notifyPercent'),
consumption_multiplier=node_data.get('consumptionMultiplier', 1.0),
cpu_count=node_data.get('cpuCount'),
cpu_model=node_data.get('cpuModel'),
total_ram=node_data.get('totalRam'),
created_at=self._parse_optional_datetime(node_data.get('createdAt')),
updated_at=self._parse_optional_datetime(node_data.get('updatedAt')),
provider_uuid=node_data.get('providerUuid'),
versions=node_data.get('versions'),
system=node_data.get('system'),
active_plugin_uuid=node_data.get('activePluginUuid'),
)
def _parse_subscription_info(self, data: dict) -> SubscriptionInfo:
@@ -1290,12 +1404,23 @@ def format_bytes(bytes_value: int) -> str:
def parse_bytes(size_str: str) -> int:
size_str = size_str.upper().strip()
size_str = size_str.strip()
units = {'B': 1, 'KB': 1024, 'MB': 1024**2, 'GB': 1024**3, 'TB': 1024**4}
# Check longest suffixes first; support both IEC (GiB) and SI (GB) units
units = [
('TiB', 1024**4),
('GiB', 1024**3),
('MiB', 1024**2),
('KiB', 1024),
('TB', 1024**4),
('GB', 1024**3),
('MB', 1024**2),
('KB', 1024),
('B', 1),
]
for unit, multiplier in units.items():
if size_str.endswith(unit):
for unit, multiplier in units:
if size_str.endswith(unit) or size_str.upper().endswith(unit.upper()):
try:
value = float(size_str[: -len(unit)].strip())
return int(value * multiplier)
+2 -2
View File
@@ -35,8 +35,8 @@ class TributeService:
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
if not self.api_key:
logger.warning('API key не настроен, пропускаем проверку')
return True
logger.error('Tribute API key не настроен — отклоняем webhook')
return False
try:
expected_signature = hmac.new(self.api_key.encode(), payload.encode(), hashlib.sha256).hexdigest()
+3 -1
View File
@@ -2,6 +2,8 @@
Обработчики админ-панели для управления черным списком
"""
import html
import structlog
from aiogram import types
from aiogram.filters import StateFilter
@@ -147,7 +149,7 @@ async def show_blacklist_users(callback: types.CallbackQuery, db_user: User, sta
# Показываем первые 20 записей
for i, (tg_id, username, reason) in enumerate(blacklist_users[:20], 1):
text += f'{i}. <code>{tg_id}</code> {username or ""}{reason}\n'
text += f'{i}. <code>{tg_id}</code> {html.escape(username or "")}{html.escape(reason or "")}\n'
if len(blacklist_users) > 20:
text += f'\n... и еще {len(blacklist_users) - 20} записей'
+2 -1
View File
@@ -5,6 +5,7 @@
и выполнять очистку БД и панели Remnawave.
"""
import html
from datetime import UTC, datetime
from enum import Enum
from typing import Any
@@ -437,7 +438,7 @@ async def show_blocked_list(
name = user_data.get('full_name') or user_data.get('username') or 'Без имени'
telegram_id = user_data.get('telegram_id', '?')
text += BlockedUsersText.BLOCKED_USER_ROW.value.format(
name=name,
name=html.escape(name),
telegram_id=telegram_id,
)
+1 -1
View File
@@ -1906,7 +1906,7 @@ async def test_payment_provider(
return
amount_kopeks = 10 * 100
description = (settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),)
description = settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id)
payment_result = await payment_service.create_yookassa_payment(
db=db,
user_id=db_user.id,
+8 -7
View File
@@ -1,3 +1,4 @@
import html
import re
import structlog
@@ -67,8 +68,8 @@ def _format_campaign_summary(campaign, texts) -> str:
bonus_info = '❓ Неизвестный тип бонуса'
return (
f'<b>{campaign.name}</b>\n'
f'Стартовый параметр: <code>{campaign.start_parameter}</code>\n'
f'<b>{html.escape(campaign.name)}</b>\n'
f'Стартовый параметр: <code>{html.escape(campaign.start_parameter)}</code>\n'
f'Статус: {status}\n'
f'{bonus_info}\n'
)
@@ -244,7 +245,7 @@ async def show_campaigns_list(
total_balance = sum(r.balance_bonus_kopeks or 0 for r in regs)
status = '🟢' if campaign.is_active else ''
line = (
f'{status} <b>{campaign.name}</b> — <code>{campaign.start_parameter}</code>\n'
f'{status} <b>{html.escape(campaign.name)}</b> — <code>{html.escape(campaign.start_parameter)}</code>\n'
f' Регистраций: {registrations}, баланс: {texts.format_price(total_balance)}'
)
if campaign.is_subscription_bonus:
@@ -383,7 +384,7 @@ async def start_edit_campaign_name(
await callback.message.edit_text(
(
'✏️ <b>Изменение названия кампании</b>\n\n'
f'Текущее название: <b>{campaign.name}</b>\n'
f'Текущее название: <b>{html.escape(campaign.name)}</b>\n'
'Введите новое название (3-100 символов):'
),
reply_markup=types.InlineKeyboardMarkup(
@@ -1183,8 +1184,8 @@ async def confirm_delete_campaign(
text = (
'🗑️ <b>Удаление кампании</b>\n\n'
f'Название: <b>{campaign.name}</b>\n'
f'Параметр: <code>{campaign.start_parameter}</code>\n\n'
f'Название: <b>{html.escape(campaign.name)}</b>\n'
f'Параметр: <code>{html.escape(campaign.start_parameter)}</code>\n\n'
'Вы уверены, что хотите удалить кампанию?'
)
@@ -1591,7 +1592,7 @@ async def select_campaign_tariff(
await state.update_data(campaign_tariff_id=tariff_id, campaign_tariff_name=tariff.name)
await state.set_state(AdminStates.creating_campaign_tariff_days)
await callback.message.edit_text(
f'🎁 Выбран тариф: <b>{tariff.name}</b>\n\n📅 Введите длительность тарифа в днях (1-730):',
f'🎁 Выбран тариф: <b>{html.escape(tariff.name)}</b>\n\n📅 Введите длительность тарифа в днях (1-730):',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_campaigns')]]
),
+16 -15
View File
@@ -1,3 +1,4 @@
import html
import math
from datetime import UTC, datetime, time
from zoneinfo import ZoneInfo
@@ -70,7 +71,7 @@ def _format_contest_summary(contest, texts, tz: ZoneInfo) -> str:
f'Дневная сводка: <b>{summary_times}</b>',
]
if contest.prize_text:
parts.append(texts.t('ADMIN_CONTEST_PRIZE', 'Приз: {prize}').format(prize=contest.prize_text))
parts.append(texts.t('ADMIN_CONTEST_PRIZE', 'Приз: {prize}').format(prize=html.escape(contest.prize_text)))
if contest.last_daily_summary_date:
parts.append(
texts.t('ADMIN_CONTEST_LAST_DAILY', 'Последняя сводка: {date}').format(
@@ -188,7 +189,7 @@ async def list_contests(
lines.append(texts.t('ADMIN_CONTESTS_EMPTY', 'Пока нет созданных конкурсов.'))
else:
for contest in contests:
lines.append(f'• <b>{contest.title}</b> (#{contest.id})')
lines.append(f'• <b>{html.escape(contest.title)}</b> (#{contest.id})')
contest_tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
lines.append(_format_contest_summary(contest, texts, contest_tz))
lines.append('')
@@ -250,21 +251,21 @@ async def show_contest_details(
total_events = await get_contest_events_count(db, contest.id) + virtual_count
lines = [
f'🏆 <b>{contest.title}</b>',
f'🏆 <b>{html.escape(contest.title)}</b>',
_format_contest_summary(contest, texts, tz),
texts.t('ADMIN_CONTEST_TOTAL_EVENTS', 'Зачётов: <b>{count}</b>').format(count=total_events),
]
if contest.description:
lines.append('')
lines.append(contest.description)
lines.append(html.escape(contest.description))
if leaderboard:
lines.append('')
lines.append(texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'))
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
virt_mark = ' 👻' if is_virtual else ''
lines.append(f'{idx}. {name}{virt_mark}{score}')
lines.append(f'{idx}. {html.escape(name)}{virt_mark}{score}')
await callback.message.edit_text(
'\n'.join(lines),
@@ -444,7 +445,7 @@ async def show_leaderboard(
]
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
virt_mark = ' 👻' if is_virtual else ''
lines.append(f'{idx}. {name}{virt_mark}{score}')
lines.append(f'{idx}. {html.escape(name)}{virt_mark}{score}')
await callback.message.edit_text(
'\n'.join(lines),
@@ -690,7 +691,7 @@ async def show_detailed_stats(
# Общее сообщение с основной статистикой
general_lines = [
'📈 <b>Статистика конкурса</b>',
f'🏆 {contest.title}',
f'🏆 {html.escape(contest.title)}',
'',
f'👥 Участников (рефереров): <b>{stats["total_participants"]}</b>',
f'📨 Приглашено рефералов: <b>{stats["total_invited"]}</b>',
@@ -751,7 +752,7 @@ async def show_detailed_stats_page(
for p in page_participants:
lines.extend(
[
f'• <b>{p["full_name"]}</b>',
f'• <b>{html.escape(p["full_name"] or "")}</b>',
f' 📨 Приглашено: {p["total_referrals"]}',
f' 💰 Оплатили: {p["paid_referrals"]}',
f' ❌ Не оплатили: {p["unpaid_referrals"]}',
@@ -828,7 +829,7 @@ async def sync_contest(
lines = [
'✅ <b>Синхронизация завершена!</b>',
'',
f'📊 <b>Конкурс:</b> {contest.title}',
f'📊 <b>Конкурс:</b> {html.escape(contest.title)}',
f'📅 <b>Период:</b> {contest.start_at.strftime("%d.%m.%Y")} - {contest.end_at.strftime("%d.%m.%Y")}',
'🔍 <b>Фильтр транзакций:</b>',
f' <code>{start_str}</code>',
@@ -870,7 +871,7 @@ async def sync_contest(
# Обновляем основное сообщение с новой статистикой
detailed_stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
general_lines = [
f'🏆 <b>{contest.title}</b>',
f'🏆 <b>{html.escape(contest.title)}</b>',
f'📅 Период: {contest.start_at.strftime("%d.%m.%Y")} - {contest.end_at.strftime("%d.%m.%Y")}',
'',
f'👥 Участников (рефереров): <b>{detailed_stats["total_participants"]}</b>',
@@ -927,7 +928,7 @@ async def debug_contest_transactions(
lines = [
'🔍 <b>Отладка транзакций конкурса</b>',
'',
f'📊 <b>Конкурс:</b> {contest.title}',
f'📊 <b>Конкурс:</b> {html.escape(contest.title)}',
'📅 <b>Период фильтрации:</b>',
f' Начало: <code>{debug_data.get("contest_start")}</code>',
f' Конец: <code>{debug_data.get("contest_end")}</code>',
@@ -1002,10 +1003,10 @@ async def show_virtual_participants(
vps = await list_virtual_participants(db, contest_id)
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
lines = [f'👻 <b>Виртуальные участники</b> — {html.escape(contest.title)}', '']
if vps:
for vp in vps:
lines.append(f'{vp.display_name}{vp.referral_count} реф.')
lines.append(f'{html.escape(vp.display_name)}{vp.referral_count} реф.')
else:
lines.append('Пока нет виртуальных участников.')
@@ -1156,10 +1157,10 @@ async def delete_virtual_participant_handler(
vps = await list_virtual_participants(db, contest_id)
contest = await get_referral_contest(db, contest_id)
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
lines = [f'👻 <b>Виртуальные участники</b> — {html.escape(contest.title)}', '']
if vps:
for v in vps:
lines.append(f'{v.display_name}{v.referral_count} реф.')
lines.append(f'{html.escape(v.display_name)}{v.referral_count} реф.')
else:
lines.append('Пока нет виртуальных участников.')
+5 -1
View File
@@ -1,3 +1,5 @@
import html
import structlog
from aiogram import Dispatcher, F, types
from aiogram.filters import Command
@@ -135,6 +137,8 @@ async def show_support_submenu(callback: types.CallbackQuery, db_user: User, db:
# Moderator panel entry (from main menu quick button)
@admin_required
@error_handler
async def show_moderator_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
kb = InlineKeyboardMarkup(
@@ -283,7 +287,7 @@ async def clear_rules_command(message: types.Message, db_user: User, db: AsyncSe
f'📊 <b>Статистика:</b>\n'
f'• Очищено правил: {stats["total_active"]}\n'
f'• Язык: {db_user.language}\n'
f'• Выполнил: {db_user.full_name}\n\n'
f'• Выполнил: {html.escape(db_user.full_name or "")}\n\n'
f'Теперь используются стандартные правила по умолчанию.'
)
+3 -1
View File
@@ -1,3 +1,5 @@
import html
import structlog
from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
@@ -133,7 +135,7 @@ async def process_maintenance_reason(message: types.Message, db_user: User, db:
if success:
response_text = 'Режим техработ включен'
if reason:
response_text += f'\nПричина: {reason}'
response_text += f'\nПричина: {html.escape(reason)}'
else:
response_text = 'Ошибка включения режима техработ'
+62 -59
View File
@@ -83,7 +83,6 @@ CABINET_MINIAPP_BUTTON_KEYS = {
'connect',
'subscription',
'support',
'home',
}
@@ -97,7 +96,11 @@ def get_updated_message_buttons_selector_keyboard(
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> types.InlineKeyboardMarkup | None:
def create_broadcast_keyboard(
selected_buttons: list,
language: str = 'ru',
custom_buttons: list[dict] | None = None,
) -> types.InlineKeyboardMarkup | None:
selected_buttons = selected_buttons or []
keyboard: list[list[types.InlineKeyboardButton]] = []
button_config_map = get_broadcast_button_config(language)
@@ -123,6 +126,20 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
if row_buttons:
keyboard.append(row_buttons)
# Append custom buttons (each on its own row)
if custom_buttons:
for btn in custom_buttons:
label = btn.get('label', '')
action_type = btn.get('action_type', 'callback')
action_value = btn.get('action_value', '')
if not label or not action_value:
continue
if action_type == 'url':
keyboard.append([types.InlineKeyboardButton(text=label, url=action_value)])
else:
# callback type
keyboard.append([types.InlineKeyboardButton(text=label, callback_data=action_value)])
if not keyboard:
return None
@@ -626,7 +643,7 @@ async def show_messages_history(callback: types.CallbackQuery, db_user: User, db
{status_emoji} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
🎯 Аудитория: {get_target_name(broadcast.target_type)}
👤 Админ: {broadcast.admin_name}
👤 Админ: {html.escape(broadcast.admin_name or '')}
📝 Сообщение: {message_preview}
"""
@@ -1460,7 +1477,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
f'• Не доставлено: {failed_count}\n'
f'• Всего пользователей: {total_users_count}\n'
f'• Успешность: {success_rate}%{media_info}\n\n'
f'<b>Администратор:</b> {admin_name}'
f'<b>Администратор:</b> {html.escape(admin_name)}'
)
back_keyboard = types.InlineKeyboardMarkup(
@@ -1582,42 +1599,28 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
result = await db.execute(query)
return result.scalar() or 0
if target == 'expired':
# Истекшие подписки
if target in ('expired', 'expired_subscribers'):
# Истекшие подписки — исключаем юзеров с хотя бы одной активной
now = datetime.now(UTC)
expired_statuses = [
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
]
query = (
select(sql_func.count(distinct(User.id)))
.outerjoin(Subscription, User.id == Subscription.user_id)
has_active_sub = (
select(Subscription.id)
.where(
base_filter,
or_(
Subscription.status.in_(expired_statuses),
and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value),
and_(Subscription.id == None, User.has_had_paid_subscription == True),
),
Subscription.user_id == User.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
.exists()
)
result = await db.execute(query)
return result.scalar() or 0
if target == 'expired_subscribers':
# То же что и expired
now = datetime.now(UTC)
expired_statuses = [
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
]
query = (
select(sql_func.count(distinct(User.id)))
.outerjoin(Subscription, User.id == Subscription.user_id)
.where(
base_filter,
~has_active_sub,
or_(
Subscription.status.in_(expired_statuses),
and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value),
@@ -1745,14 +1748,14 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
return [
user
for user in users
if user.subscription and user.subscription.is_active and not user.subscription.is_trial
if any(s.is_active and not s.is_trial for s in (getattr(user, 'subscriptions', None) or []))
]
if target == 'trial':
return [user for user in users if user.subscription and user.subscription.is_trial]
return [user for user in users if any(s.is_trial for s in (getattr(user, 'subscriptions', None) or []))]
if target == 'no':
return [user for user in users if not user.subscription or not user.subscription.is_active]
return [user for user in users if not any(s.is_active for s in (getattr(user, 'subscriptions', None) or []))]
if target == 'expiring':
expiring_subs = await get_expiring_subscriptions(db, 3)
@@ -1766,14 +1769,14 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
}
expired_users = []
for user in users:
subscription = user.subscription
if subscription:
if subscription.status in expired_statuses:
subs = getattr(user, 'subscriptions', None) or []
if subs:
has_active = any(s.is_active for s in subs)
if has_active:
continue # Skip users who have at least one active subscription
has_expired = any(s.status in expired_statuses or (s.end_date <= now and not s.is_active) for s in subs)
if has_expired:
expired_users.append(user)
continue
if subscription.end_date <= now and not subscription.is_active:
expired_users.append(user)
continue
elif user.has_had_paid_subscription:
expired_users.append(user)
return expired_users
@@ -1782,27 +1785,27 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
return [
user
for user in users
if user.subscription
and not user.subscription.is_trial
and user.subscription.is_active
and (user.subscription.traffic_used_gb or 0) <= 0
if any(
not s.is_trial and s.is_active and (s.traffic_used_gb or 0) <= 0
for s in (getattr(user, 'subscriptions', None) or [])
)
]
if target == 'trial_zero':
return [
user
for user in users
if user.subscription
and user.subscription.is_trial
and user.subscription.is_active
and (user.subscription.traffic_used_gb or 0) <= 0
if any(
s.is_trial and s.is_active and (s.traffic_used_gb or 0) <= 0
for s in (getattr(user, 'subscriptions', None) or [])
)
]
if target == 'zero':
return [
user
for user in users
if user.subscription and user.subscription.is_active and (user.subscription.traffic_used_gb or 0) <= 0
if any(s.is_active and (s.traffic_used_gb or 0) <= 0 for s in (getattr(user, 'subscriptions', None) or []))
]
if target == 'expiring_subscribers':
@@ -1817,14 +1820,14 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
}
expired_users = []
for user in users:
subscription = user.subscription
if subscription:
if subscription.status in expired_statuses:
subs = getattr(user, 'subscriptions', None) or []
if subs:
has_active = any(s.is_active for s in subs)
if has_active:
continue # Skip users who have at least one active subscription
has_expired = any(s.status in expired_statuses or (s.end_date <= now and not s.is_active) for s in subs)
if has_expired:
expired_users.append(user)
continue
if subscription.end_date <= now and not subscription.is_active:
expired_users.append(user)
continue
elif user.has_had_paid_subscription:
expired_users.append(user)
return expired_users
@@ -1833,7 +1836,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
return [
user
for user in users
if user.subscription and user.subscription.status == SubscriptionStatus.DISABLED.value
if any(s.status == SubscriptionStatus.DISABLED.value for s in (getattr(user, 'subscriptions', None) or []))
]
if target == 'trial_ending':
@@ -1842,10 +1845,10 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
return [
user
for user in users
if user.subscription
and user.subscription.is_trial
and user.subscription.is_active
and user.subscription.end_date <= in_3_days
if any(
s.is_trial and s.is_active and s.end_date <= in_3_days
for s in (getattr(user, 'subscriptions', None) or [])
)
]
if target == 'trial_expired':
@@ -1853,7 +1856,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
return [
user
for user in users
if user.subscription and user.subscription.is_trial and user.subscription.end_date <= now
if any(s.is_trial and s.end_date <= now for s in (getattr(user, 'subscriptions', None) or []))
]
if target == 'autopay_failed':
@@ -1898,7 +1901,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
return [
user
for user in users
if user.subscription and user.subscription.is_active and user.subscription.tariff_id == tariff_id
if any(s.is_active and s.tariff_id == tariff_id for s in (getattr(user, 'subscriptions', None) or []))
]
return []
+2 -1
View File
@@ -1,4 +1,5 @@
import asyncio
import html
from datetime import UTC, date, datetime, timedelta
import structlog
@@ -741,7 +742,7 @@ async def traffic_check_callback(callback: CallbackQuery):
if violations:
text += '\n⚠️ <b>Превышения дельты:</b>\n'
for v in violations[:10]:
name = v.full_name or v.user_uuid[:8]
name = html.escape(v.full_name or '') or v.user_uuid[:8]
text += f'{name}: +{v.used_traffic_gb:.1f} ГБ\n'
if len(violations) > 10:
text += f'... и ещё {len(violations) - 10}\n'
+1 -1
View File
@@ -886,7 +886,7 @@ async def _render_poll_details(poll: Poll, language: str) -> str:
texts = get_texts(language)
lines = [f'🗳️ <b>{html.escape(poll.title)}</b>']
if poll.description:
lines.append(poll.description)
lines.append(html.escape(poll.description))
lines.append(_format_reward_text(poll, language))
lines.append(texts.t('ADMIN_POLLS_QUESTIONS_COUNT', 'Вопросов: {count}').format(count=len(poll.questions)))
+26 -35
View File
@@ -1,3 +1,4 @@
import html
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
import structlog
@@ -293,7 +294,7 @@ def _build_edit_menu_content(
header = texts.t(
'ADMIN_PROMO_GROUP_EDIT_MENU_TITLE',
'✏️ Настройки промогруппы «{name}»',
).format(name=group.name)
).format(name=html.escape(group.name))
lines = [header]
lines.extend(_format_discount_lines(texts, group))
@@ -462,28 +463,17 @@ async def show_promo_groups_menu(
keyboard_rows = []
for group, member_count in groups:
icon = '' if group.is_default else '🎯'
default_suffix = texts.t('ADMIN_PROMO_GROUPS_DEFAULT_LABEL', ' (базовая)') if group.is_default else ''
group_lines = [
f'{"" if group.is_default else "🎯"} <b>{group.name}</b>{default_suffix}',
]
group_lines.extend(_format_discount_lines(texts, group))
group_lines.append(_format_auto_assign_line(texts, group))
group_lines.append(
texts.t(
'ADMIN_PROMO_GROUPS_MEMBERS_COUNT',
'Участников: {count}',
).format(count=member_count)
)
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
group_lines.extend(period_lines)
group_lines.append('')
lines.extend(group_lines)
members_label = texts.t(
'ADMIN_PROMO_GROUPS_MEMBERS_COUNT',
'Участников: {count}',
).format(count=member_count)
lines.append(f'{icon} <b>{html.escape(group.name)}</b>{default_suffix}{members_label}')
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=f'{"" if group.is_default else "🎯"} {group.name}',
text=f'{icon} {group.name}',
callback_data=f'promo_group_manage_{group.id}',
)
]
@@ -535,7 +525,7 @@ async def show_promo_group_details(
texts.t(
'ADMIN_PROMO_GROUP_DETAILS_TITLE',
'💳 <b>Промогруппа:</b> {name}',
).format(name=group.name)
).format(name=html.escape(group.name))
]
lines.extend(_format_discount_lines(texts, group))
lines.append(_format_auto_assign_line(texts, group))
@@ -813,7 +803,7 @@ async def process_create_group_auto_assign(
await state.clear()
await message.answer(
texts.t('ADMIN_PROMO_GROUP_CREATED', 'Промогруппа «{name}» создана.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_CREATED', 'Промогруппа «{name}» создана.').format(name=html.escape(group.name)),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
@@ -886,7 +876,7 @@ async def prompt_edit_promo_group_field(
prompt = texts.t(
'ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT',
'Введите новое название промогруппы (текущее: {name}):',
).format(name=group.name)
).format(name=html.escape(group.name))
elif field == 'priority':
await state.set_state(AdminStates.editing_promo_group_priority)
prompt = texts.t(
@@ -962,7 +952,7 @@ async def process_edit_group_name(
texts,
group,
data.get('language', db_user.language),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
)
@@ -1004,7 +994,7 @@ async def process_edit_group_priority(
texts,
group,
data.get('language', db_user.language),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
)
@@ -1039,7 +1029,7 @@ async def process_edit_group_traffic(
texts,
group,
data.get('language', db_user.language),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
)
@@ -1074,7 +1064,7 @@ async def process_edit_group_servers(
texts,
group,
data.get('language', db_user.language),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
)
@@ -1109,7 +1099,7 @@ async def process_edit_group_devices(
texts,
group,
data.get('language', db_user.language),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
)
@@ -1149,7 +1139,7 @@ async def process_edit_group_period_discounts(
texts,
group,
data.get('language', db_user.language),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
)
@@ -1193,7 +1183,7 @@ async def process_edit_group_auto_assign(
texts,
group,
data.get('language', db_user.language),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
)
@@ -1223,19 +1213,20 @@ async def show_promo_group_members(
title = texts.t(
'ADMIN_PROMO_GROUP_MEMBERS_TITLE',
'👥 Участники группы {name}',
).format(name=group.name)
).format(name=html.escape(group.name))
if not members:
body = texts.t('ADMIN_PROMO_GROUP_MEMBERS_EMPTY', 'В этой группе пока нет участников.')
else:
lines = []
for index, user in enumerate(members, start=offset + 1):
username = f'@{user.username}' if user.username else ''
username = f'@{html.escape(user.username)}' if user.username else ''
safe_name = html.escape(user.full_name or '')
if user.telegram_id:
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
user_link = f'<a href="tg://user?id={user.telegram_id}">{safe_name}</a>'
tg_display = str(user.telegram_id)
else:
user_link = f'<b>{user.full_name}</b>'
user_link = f'<b>{safe_name}</b>'
tg_display = user.email or f'#{user.id}'
lines.append(f'{index}. {user_link} (ID {user.id}, {username}, TG {tg_display})')
body = '\n'.join(lines)
@@ -1284,7 +1275,7 @@ async def request_delete_promo_group(
confirm_text = texts.t(
'ADMIN_PROMO_GROUP_DELETE_CONFIRM',
'Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.',
).format(name=group.name)
).format(name=html.escape(group.name))
await callback.message.edit_text(
confirm_text,
@@ -1319,7 +1310,7 @@ async def delete_promo_group_confirmed(
return
await callback.message.edit_text(
texts.t('ADMIN_PROMO_GROUP_DELETED', 'Промогруппа «{name}» удалена.').format(name=group.name),
texts.t('ADMIN_PROMO_GROUP_DELETED', 'Промогруппа «{name}» удалена.').format(name=html.escape(group.name)),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_promo_groups')]]
),
+90 -9
View File
@@ -677,7 +677,7 @@ def _describe_offer(
label = texts.t(config.get('label_key', ''), config.get('default_label', template.offer_type))
icon = config.get('icon', '📨')
lines = [f'{icon} <b>{template.name}</b>', '']
lines = [f'{icon} <b>{html.escape(template.name)}</b>', '']
lines.append(texts.t('ADMIN_PROMO_OFFER_TYPE', 'Тип: {label}').format(label=label))
lines.append(texts.t('ADMIN_PROMO_OFFER_VALID', 'Срок действия: {hours} ч').format(hours=template.valid_hours))
@@ -1468,7 +1468,51 @@ async def show_selected_user_details(
)
subscription = getattr(user, 'subscription', None)
if subscription:
subscriptions_list = getattr(user, 'subscriptions', None) or []
if settings.is_multi_tariff_enabled() and subscriptions_list:
lines.append('')
lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION', '💳 <b>Подписки</b>'))
for sub in subscriptions_list:
tariff_name = sub.tariff.name if sub.tariff else f'#{sub.id}'
lines.append(f'<b>{tariff_name}</b>')
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS',
'Статус: {status}',
).format(status=sub.status_display)
)
end_date_text = (
format_datetime(sub.end_date)
if sub.end_date
else texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN',
'не указано',
)
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END',
'Истекает: {date}',
).format(date=end_date_text)
)
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC',
'Трафик: {used}/{limit} ГБ',
).format(
used=sub.traffic_used_gb or 0,
limit=sub.traffic_limit_gb or 0,
)
)
connected = sub.connected_squads or []
if connected:
lines.append(
texts.t(
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS',
'Подключено сквадов: {count}',
).format(count=len(connected))
)
elif subscription:
lines.append('')
lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION', '💳 <b>Подписка</b>'))
lines.append(
@@ -1771,7 +1815,21 @@ async def show_selected_user_details(
).format(count=len(active_offers))
)
if subscription:
if settings.is_multi_tariff_enabled() and subscriptions_list:
now = datetime.now(UTC)
sub_ids = [sub.id for sub in subscriptions_list]
result = await db.execute(
select(SubscriptionTemporaryAccess)
.options(selectinload(SubscriptionTemporaryAccess.offer))
.where(
SubscriptionTemporaryAccess.subscription_id.in_(sub_ids),
SubscriptionTemporaryAccess.is_active == True,
SubscriptionTemporaryAccess.expires_at > now,
)
.order_by(SubscriptionTemporaryAccess.expires_at.desc())
)
accesses = result.scalars().all()
elif subscription:
now = datetime.now(UTC)
result = await db.execute(
select(SubscriptionTemporaryAccess)
@@ -1844,7 +1902,11 @@ async def show_selected_user_details(
def _build_connect_button_rows(user: User, texts) -> list[list[InlineKeyboardButton]]:
subscription = getattr(user, 'subscription', None)
if settings.is_multi_tariff_enabled():
subs = getattr(user, 'subscriptions', None) or []
subscription = next((s for s in subs if s.subscription_url), None)
else:
subscription = getattr(user, 'subscription', None)
if not subscription:
return []
@@ -1930,12 +1992,25 @@ async def _send_offer_to_users(
try:
# Используем отдельную сессию для изоляции транзакции
async with AsyncSessionLocal() as new_db:
if settings.is_multi_tariff_enabled():
_user_subs = getattr(user, 'subscriptions', None) or []
_active_subs = [s for s in _user_subs if s.is_active]
if _active_subs:
_non_daily = [s for s in _active_subs if not getattr(s, 'is_daily_tariff', False)]
_eligible = _non_daily or _active_subs
_best = max(_eligible, key=lambda s: s.days_left)
_offer_sub_id = _best.id
elif _user_subs:
_offer_sub_id = _user_subs[0].id
else:
_offer_sub_id = None
else:
_offer_sub = getattr(user, 'subscription', None)
_offer_sub_id = _offer_sub.id if _offer_sub else None
offer_record = await upsert_discount_offer(
new_db,
user_id=user.id,
subscription_id=getattr(user, 'subscription', None).id
if getattr(user, 'subscription', None)
else None,
subscription_id=_offer_sub_id,
notification_type=f'promo_template_{template.id}',
discount_percent=template.discount_percent,
bonus_amount_kopeks=0,
@@ -2051,8 +2126,14 @@ async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: Asyn
if template.offer_type == 'test_access' and squad_uuid:
filtered_users: list[User] = []
for user in users:
subscription = getattr(user, 'subscription', None)
connected = set(subscription.connected_squads or []) if subscription else set()
if settings.is_multi_tariff_enabled():
all_squads: set[str] = set()
for s in getattr(user, 'subscriptions', None) or []:
all_squads.update(s.connected_squads or [])
connected = all_squads
else:
subscription = getattr(user, 'subscription', None)
connected = set(subscription.connected_squads or []) if subscription else set()
if squad_uuid in connected:
continue
filtered_users.append(user)
+9 -6
View File
@@ -1,3 +1,4 @@
import html
from datetime import UTC, datetime, timedelta
import structlog
@@ -94,7 +95,7 @@ async def show_promocodes_list(callback: types.CallbackQuery, db_user: User, db:
text += f'📅 Дней: {promo.subscription_days}\n'
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
text += f'🏷️ Промогруппа: {promo.promo_group.name}\n'
text += f'🏷️ Промогруппа: {html.escape(promo.promo_group.name)}\n'
elif promo.type == PromoCodeType.DISCOUNT.value:
discount_hours = promo.subscription_days
if discount_hours > 0:
@@ -170,7 +171,7 @@ async def show_promocode_management(callback: types.CallbackQuery, db_user: User
text += f'📅 <b>Дней:</b> {promo.subscription_days}\n'
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
text += f'🏷️ <b>Промогруппа:</b> {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n'
text += f'🏷️ <b>Промогруппа:</b> {html.escape(promo.promo_group.name)} (приоритет: {promo.promo_group.priority})\n'
elif promo.promo_group_id:
text += f'🏷️ <b>Промогруппа ID:</b> {promo.promo_group_id} (не найдена)\n'
elif promo.type == PromoCodeType.DISCOUNT.value:
@@ -472,7 +473,9 @@ async def process_promocode_code(message: types.Message, db_user: User, state: F
text = f'🏷️ <b>Промокод:</b> <code>{code}</code>\n\nВыберите промогруппу для назначения:\n\n'
for promo_group, user_count in groups_with_counts:
text += f'{promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n'
text += (
f'{html.escape(promo_group.name)} (приоритет: {promo_group.priority}, пользователей: {user_count})\n'
)
keyboard.append(
[
types.InlineKeyboardButton(
@@ -509,7 +512,7 @@ async def process_promo_group_selection(
await callback.message.edit_text(
f'🏷️ <b>Промокод для промогруппы</b>\n\n'
f'Промогруппа: {promo_group.name}\n'
f'Промогруппа: {html.escape(promo_group.name)}\n'
f'Приоритет: {promo_group.priority}\n\n'
f'📊 Введите количество использований промокода (или 0 для безлимита):'
)
@@ -1039,9 +1042,9 @@ async def show_promocode_stats(callback: types.CallbackQuery, db_user: User, db:
use_date = format_datetime(use.used_at)
if hasattr(use, 'user_username') and use.user_username:
user_display = f'@{use.user_username}'
user_display = f'@{html.escape(use.user_username)}'
elif hasattr(use, 'user_full_name') and use.user_full_name:
user_display = use.user_full_name
user_display = html.escape(use.user_full_name)
elif hasattr(use, 'user_telegram_id'):
user_display = f'ID{use.user_telegram_id}'
else:
+64 -34
View File
@@ -1,4 +1,5 @@
import asyncio
import html
import json
from datetime import UTC, datetime, timedelta
@@ -218,9 +219,9 @@ async def _show_top_referrers_filtered(callback: types.CallbackQuery, db: AsyncS
id_display = telegram_id or user_email or f'#{user_id}' if user_id else 'N/A'
if username:
display_text = f'@{username} (ID{id_display})'
display_text = f'@{html.escape(username)} (ID{id_display})'
elif display_name and display_name != f'ID{id_display}':
display_text = f'{display_name} (ID{id_display})'
display_text = f'{html.escape(display_name)} (ID{id_display})'
else:
display_text = f'ID{id_display}'
@@ -312,7 +313,7 @@ async def show_pending_withdrawal_requests(callback: types.CallbackQuery, db_use
for req in requests[:10]:
user = await get_user_by_id(db, req.user_id)
user_name = user.full_name if user else 'Неизвестно'
user_name = html.escape(user.full_name) if user and user.full_name else 'Неизвестно'
user_tg_id = user.telegram_id if user else 'N/A'
risk_emoji = (
@@ -359,7 +360,7 @@ async def view_withdrawal_request(callback: types.CallbackQuery, db_user: User,
return
user = await get_user_by_id(db, request.user_id)
user_name = user.full_name if user else 'Неизвестно'
user_name = html.escape(user.full_name) if user and user.full_name else 'Неизвестно'
user_tg_id = (user.telegram_id or user.email or f'#{user.id}') if user else 'N/A'
analysis = json.loads(request.risk_analysis) if request.risk_analysis else {}
@@ -381,7 +382,7 @@ async def view_withdrawal_request(callback: types.CallbackQuery, db_user: User,
📊 Статус: {status_text}
💳 <b>Реквизиты:</b>
<code>{request.payment_details}</code>
<code>{html.escape(request.payment_details or '')}</code>
📅 Создана: {request.created_at.strftime('%d.%m.%Y %H:%M')}
@@ -639,7 +640,7 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
await message.answer(
f'✅ <b>Тестовое начисление создано!</b>\n\n'
f'👤 Пользователь: {target_user.full_name or "Без имени"}\n'
f'👤 Пользователь: {html.escape(target_user.full_name) if target_user.full_name else "Без имени"}\n'
f'🆔 ID: <code>{target_telegram_id}</code>\n'
f'💰 Сумма: <b>{amount_rubles:.0f}₽</b>\n'
f'💳 Новый баланс: <b>{target_user.balance_kopeks / 100:.0f}₽</b>\n\n'
@@ -736,14 +737,17 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS
status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
# Имя или ID
user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
if lost.username:
user_name = f'@{lost.username}'
user_name = f'@{html.escape(lost.username)}'
elif lost.full_name:
user_name = html.escape(lost.full_name)
else:
user_name = f'ID{lost.telegram_id}'
# Ожидаемый реферер
referrer_info = ''
if lost.expected_referrer_name:
referrer_info = f'{lost.expected_referrer_name}'
referrer_info = f'{html.escape(lost.expected_referrer_name)}'
elif lost.expected_referrer_id:
referrer_info = f' → ID{lost.expected_referrer_id}'
@@ -751,7 +755,7 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS
time_str = lost.click_time.strftime('%H:%M')
text += f'{i}. {user_name}{status}\n'
text += f' <code>{lost.referral_code}</code>{referrer_info} ({time_str})\n'
text += f' <code>{html.escape(lost.referral_code)}</code>{referrer_info} ({time_str})\n'
if len(report.lost_referrals) > 15:
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
@@ -872,16 +876,22 @@ async def preview_referral_fixes(callback: types.CallbackQuery, db_user: User, d
# Показываем первые 10 деталей
for i, detail in enumerate(fix_report.details[:10], 1):
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
if detail.username:
user_name = f'@{detail.username}'
user_name = f'@{html.escape(detail.username)}'
elif detail.full_name:
user_name = html.escape(detail.full_name)
else:
user_name = f'ID{detail.telegram_id}'
if detail.error:
text += f'{i}. {user_name} — ❌ {detail.error}\n'
text += f'{i}. {user_name} — ❌ {html.escape(str(detail.error))}\n'
else:
text += f'{i}. {user_name}\n'
if detail.referred_by_set:
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
referrer_display = (
html.escape(detail.referrer_name) if detail.referrer_name else f'ID{detail.referrer_id}'
)
text += f' • Реферер: {referrer_display}\n'
if detail.had_first_topup:
text += f' • Первое пополнение: {settings.format_price(detail.topup_amount_kopeks)}\n'
if detail.bonus_to_referral_kopeks > 0:
@@ -967,13 +977,19 @@ async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db:
for detail in fix_report.details:
if not detail.error and success_count < 10:
success_count += 1
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
if detail.username:
user_name = f'@{user_name}'
user_name = f'@{html.escape(detail.username)}'
elif detail.full_name:
user_name = html.escape(detail.full_name)
else:
user_name = f'ID{detail.telegram_id}'
text += f'{success_count}. {user_name}\n'
if detail.referred_by_set:
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
referrer_display = (
html.escape(detail.referrer_name) if detail.referrer_name else f'ID{detail.referrer_id}'
)
text += f' • Реферер: {referrer_display}\n'
if detail.bonus_to_referral_kopeks > 0:
text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n'
if detail.bonus_to_referrer_kopeks > 0:
@@ -989,8 +1005,13 @@ async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db:
for detail in fix_report.details:
if detail.error and error_count < 5:
error_count += 1
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
text += f'{user_name}: {detail.error}\n'
if detail.username:
user_name = f'@{html.escape(detail.username)}'
elif detail.full_name:
user_name = html.escape(detail.full_name)
else:
user_name = f'ID{detail.telegram_id}'
text += f'{user_name}: {html.escape(str(detail.error))}\n'
if fix_report.errors > 5:
text += f'<i>... и ещё {fix_report.errors - 5} ошибок</i>\n'
@@ -1055,8 +1076,12 @@ async def check_missing_bonuses(callback: types.CallbackQuery, db_user: User, db
👤 <b>Список ({len(report.missing_bonuses)} чел.):</b>
"""
for i, mb in enumerate(report.missing_bonuses[:15], 1):
referral_name = mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id)
referrer_name = mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id)
referral_name = html.escape(
mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id)
)
referrer_name = html.escape(
mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id)
)
text += f'\n{i}. <b>{referral_name}</b>'
text += f'\n └ Пригласил: {referrer_name}'
text += f'\n └ Пополнение: {mb.first_topup_amount_kopeks / 100:.0f}'
@@ -1191,9 +1216,9 @@ async def sync_referrals_with_contest(
total_created += stats.get('created', 0)
total_updated += stats.get('updated', 0)
total_skipped += stats.get('skipped', 0)
contest_results.append(f'{contest.title}: +{stats.get("created", 0)} новых')
contest_results.append(f'{html.escape(contest.title)}: +{stats.get("created", 0)} новых')
else:
contest_results.append(f'{contest.title}: ошибка')
contest_results.append(f'{html.escape(contest.title)}: ошибка')
text = f"""
🏆 <b>Синхронизация с конкурсами завершена!</b>
@@ -1275,7 +1300,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
if file_ext not in ['.log', '.txt']:
await message.answer(
f'❌ Неверный формат файла: {file_ext}\n\nПоддерживаются только текстовые файлы (.log, .txt)',
f'❌ Неверный формат файла: {html.escape(file_ext)}\n\nПоддерживаются только текстовые файлы (.log, .txt)',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
@@ -1299,7 +1324,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
# Информируем о начале загрузки
status_message = await message.answer(
f'📥 Загружаю файл {file_name} ({message.document.file_size / 1024 / 1024:.1f} MB)...'
f'📥 Загружаю файл {html.escape(file_name)} ({message.document.file_size / 1024 / 1024:.1f} MB)...'
)
temp_file_path = None
@@ -1316,7 +1341,9 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
logger.info('📥 Файл загружен: ( байт)', temp_file_path=temp_file_path, file_size=message.document.file_size)
# Обновляем статус
await status_message.edit_text(f'🔍 Анализирую файл {file_name}...\n\nЭто может занять некоторое время.')
await status_message.edit_text(
f'🔍 Анализирую файл {html.escape(file_name)}...\n\nЭто может занять некоторое время.'
)
# Анализируем файл
from app.services.referral_diagnostics_service import referral_diagnostics_service
@@ -1325,7 +1352,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
# Формируем отчёт
text = f"""
🔍 <b>Анализ лог-файла: {file_name}</b>
🔍 <b>Анализ лог-файла: {html.escape(file_name)}</b>
<b>📊 Статистика переходов:</b>
Всего кликов по реф-ссылкам: {report.total_ref_clicks}
@@ -1348,14 +1375,17 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
# Имя или ID
user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
if lost.username:
user_name = f'@{lost.username}'
user_name = f'@{html.escape(lost.username)}'
elif lost.full_name:
user_name = html.escape(lost.full_name)
else:
user_name = f'ID{lost.telegram_id}'
# Ожидаемый реферер
referrer_info = ''
if lost.expected_referrer_name:
referrer_info = f'{lost.expected_referrer_name}'
referrer_info = f'{html.escape(lost.expected_referrer_name)}'
elif lost.expected_referrer_id:
referrer_info = f' → ID{lost.expected_referrer_id}'
@@ -1363,7 +1393,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
time_str = lost.click_time.strftime('%d.%m.%Y %H:%M')
text += f'{i}. {user_name}{status}\n'
text += f' <code>{lost.referral_code}</code>{referrer_info} ({time_str})\n'
text += f' <code>{html.escape(lost.referral_code)}</code>{referrer_info} ({time_str})\n'
if len(report.lost_referrals) > 15:
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
@@ -1408,8 +1438,8 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
try:
await status_message.edit_text(
f'❌ <b>Ошибка при анализе файла</b>\n\n'
f'Файл: {file_name}\n'
f'Ошибка: {e!s}\n\n'
f'Файл: {html.escape(file_name)}\n'
f'Ошибка: {html.escape(str(e))}\n\n'
f'Проверьте, что файл является текстовым логом бота.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
@@ -1428,7 +1458,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
)
except:
await message.answer(
f'❌ Ошибка при анализе файла: {e!s}',
f'❌ Ошибка при анализе файла: {html.escape(str(e))}',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_referral_diagnostics')]
+71 -40
View File
@@ -995,9 +995,9 @@ async def show_system_stats(callback: types.CallbackQuery, db_user: User, db: As
📊 <b>Детальная статистика Remnawave</b>
🖥 <b>Сервер:</b>
- CPU: {server_info.get('cpu_cores', 0)} ядер ({server_info.get('cpu_physical_cores', 0)} физ.)
- CPU: {server_info.get('cpu_cores', 0)} ядер
- RAM: {format_bytes(server_info.get('memory_used', 0))} / {format_bytes(memory_total)} ({memory_used_percent:.1f}%)
- Свободно: {format_bytes(server_info.get('memory_available', 0))}
- Свободно: {format_bytes(server_info.get('memory_free', 0))}
- Uptime: {uptime_str}
👥 <b>Пользователи ({system.get('total_users', 0)} всего):</b>
@@ -1088,8 +1088,7 @@ async def show_traffic_stats(callback: types.CallbackQuery, db_user: User, db: A
total_realtime_upload = sum(node.get('uploadBytes', 0) for node in realtime_usage)
total_realtime = total_realtime_download + total_realtime_upload
total_download_speed = sum(node.get('downloadSpeedBps', 0) for node in realtime_usage)
total_upload_speed = sum(node.get('uploadSpeedBps', 0) for node in realtime_usage)
total_users_online = sum(node.get('usersOnline', 0) for node in realtime_usage)
periods = {
'last_2_days': bandwidth_stats.get('bandwidthLastTwoDays', {}),
@@ -1109,15 +1108,11 @@ async def show_traffic_stats(callback: types.CallbackQuery, db_user: User, db: A
text = f"""
📊 <b>Статистика трафика Remnawave</b>
<b>Реалтайм данные:</b>
<b>Трафик по inbounds:</b>
- Скачивание: {format_bytes(total_realtime_download)}
- Загрузка: {format_bytes(total_realtime_upload)}
- Общий трафик: {format_bytes(total_realtime)}
🚀 <b>Текущие скорости:</b>
- Скорость скачивания: {format_bytes(total_download_speed)}/с
- Скорость загрузки: {format_bytes(total_upload_speed)}/с
- Общая скорость: {format_bytes(total_download_speed + total_upload_speed)}/с
- Пользователи онлайн: {total_users_online}
📈 <b>Статистика по периодам:</b>
@@ -1244,12 +1239,26 @@ async def show_node_details(callback: types.CallbackQuery, db_user: User, db: As
created_at = format_datetime(node['created_at']) if node.get('created_at') else ''
updated_at = format_datetime(node['updated_at']) if node.get('updated_at') else ''
notify_percent = f'{node["notify_percent"]}%' if node.get('notify_percent') is not None else ''
cpu_info = node.get('cpu_model') or ''
if node.get('cpu_count'):
cpu_info = f'{node["cpu_count"]}x {cpu_info}'
sys_info = (node.get('system') or {}).get('info', {})
cpu_model = html.escape(str(sys_info.get('cpuModel') or ''))
cpu_count = sys_info.get('cpus', 0)
cpu_info = f'{cpu_count}x {cpu_model}' if cpu_count else cpu_model
memory_total = sys_info.get('memoryTotal', 0)
total_ram = format_bytes(memory_total) if memory_total else ''
versions = node.get('versions') or {}
xray_ver = html.escape(str(versions.get('xray') or ''))
node_ver = html.escape(str(versions.get('node') or ''))
xray_uptime_sec = node.get('xray_uptime', 0)
if xray_uptime_sec:
days, rem = divmod(int(xray_uptime_sec), 86400)
hours, rem = divmod(rem, 3600)
mins = rem // 60
xray_uptime_str = f'{days}d {hours}h {mins}m' if days else (f'{hours}h {mins}m' if hours else f'{mins}m')
else:
xray_uptime_str = ''
text = f"""
🖥 <b>Нода: {node['name']}</b>
🖥 <b>Нода: {html.escape(node['name'])}</b>
<b>Статус:</b>
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
@@ -1257,16 +1266,20 @@ async def show_node_details(callback: types.CallbackQuery, db_user: User, db: As
- Подключена: {'📡 Да' if node['is_connected'] else '📵 Нет'}
- Отключена: {'❌ Да' if node['is_disabled'] else '✅ Нет'}
- Изменение статуса: {status_change}
- Сообщение: {node.get('last_status_message') or ''}
- Uptime Xray: {node.get('xray_uptime') or ''}
- Сообщение: {html.escape(str(node.get('last_status_message') or ''))}
- Uptime Xray: {xray_uptime_str}
<b>Версии:</b>
- Xray: {xray_ver}
- Node: {node_ver}
<b>Информация:</b>
- Адрес: {node['address']}
- Страна: {node['country_code']}
- Адрес: {html.escape(node['address'])}
- Страна: {html.escape(node['country_code'])}
- Пользователей онлайн: {node['users_online']}
- CPU: {cpu_info}
- RAM: {node.get('total_ram') or ''}
- Провайдер: {node.get('provider_uuid') or ''}
- RAM: {total_ram}
- Провайдер: {html.escape(str(node.get('provider_uuid') or ''))}
<b>Трафик:</b>
- Использовано: {format_bytes(node['traffic_used_bytes'])}
@@ -1315,6 +1328,17 @@ async def show_node_statistics(callback: types.CallbackQuery, db_user: User, db:
await callback.answer('❌ Нода не найдена', show_alert=True)
return
status_emoji = '🟢' if node['is_node_online'] else '🔴'
xray_emoji = '' if node['is_xray_running'] else ''
xray_uptime_sec = node.get('xray_uptime', 0)
if xray_uptime_sec:
days, rem = divmod(int(xray_uptime_sec), 86400)
hours, rem = divmod(rem, 3600)
mins = rem // 60
xray_uptime_str = f'{days}d {hours}h {mins}m' if days else (f'{hours}h {mins}m' if hours else f'{mins}m')
else:
xray_uptime_str = ''
try:
end_date = datetime.now(UTC)
start_date = end_date - timedelta(days=7)
@@ -1333,28 +1357,36 @@ async def show_node_statistics(callback: types.CallbackQuery, db_user: User, db:
created_at = format_datetime(node['created_at']) if node.get('created_at') else ''
updated_at = format_datetime(node['updated_at']) if node.get('updated_at') else ''
notify_percent = f'{node["notify_percent"]}%' if node.get('notify_percent') is not None else ''
cpu_info = node.get('cpu_model') or ''
if node.get('cpu_count'):
cpu_info = f'{node["cpu_count"]}x {cpu_info}'
status_emoji = '🟢' if node['is_node_online'] else '🔴'
xray_emoji = '' if node['is_xray_running'] else ''
sys_info = (node.get('system') or {}).get('info', {})
cpu_model = html.escape(str(sys_info.get('cpuModel') or ''))
cpu_count = sys_info.get('cpus', 0)
cpu_info = f'{cpu_count}x {cpu_model}' if cpu_count else cpu_model
memory_total = sys_info.get('memoryTotal', 0)
total_ram = format_bytes(memory_total) if memory_total else ''
sys_stats = (node.get('system') or {}).get('stats', {})
load_avg = sys_stats.get('loadAvg', [])
load_str = ' / '.join(f'{v:.2f}' for v in load_avg[:3]) if load_avg else ''
versions = node.get('versions') or {}
xray_ver = html.escape(str(versions.get('xray') or ''))
node_ver = html.escape(str(versions.get('node') or ''))
text = f"""
📊 <b>Статистика ноды: {node['name']}</b>
📊 <b>Статистика ноды: {html.escape(node['name'])}</b>
<b>Статус:</b>
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
- Пользователей онлайн: {node['users_online'] or 0}
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'} (v{xray_ver})
- Node: v{node_ver}
- Пользователей онлайн: {node['users_online']}
- Изменение статуса: {status_change}
- Сообщение: {node.get('last_status_message') or ''}
- Uptime Xray: {node.get('xray_uptime') or ''}
- Сообщение: {html.escape(str(node.get('last_status_message') or ''))}
- Uptime Xray: {xray_uptime_str}
<b>Ресурсы:</b>
- CPU: {cpu_info}
- RAM: {node.get('total_ram') or ''}
- Провайдер: {node.get('provider_uuid') or ''}
- RAM: {total_ram}
- Load: {load_str}
- Провайдер: {html.escape(str(node.get('provider_uuid') or ''))}
<b>Трафик:</b>
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
@@ -1371,12 +1403,11 @@ async def show_node_statistics(callback: types.CallbackQuery, db_user: User, db:
if node_realtime:
text += f"""
<b>Реалтайм статистика:</b>
<b>Трафик по inbounds:</b>
- Скачано: {format_bytes(node_realtime.get('downloadBytes', 0))}
- Загружено: {format_bytes(node_realtime.get('uploadBytes', 0))}
- Общий трафик: {format_bytes(node_realtime.get('totalBytes', 0))}
- Скорость скачивания: {format_bytes(node_realtime.get('downloadSpeedBps', 0))}/с
- Скорость загрузки: {format_bytes(node_realtime.get('uploadSpeedBps', 0))}/с
- Онлайн: {node_realtime.get('usersOnline', 0)}
"""
if node_usage:
@@ -1405,15 +1436,15 @@ async def show_node_statistics(callback: types.CallbackQuery, db_user: User, db:
logger.error('Ошибка получения статистики ноды', node_uuid=node_uuid, error=e)
text = f"""
📊 <b>Статистика ноды: {node['name']}</b>
📊 <b>Статистика ноды: {html.escape(node['name'])}</b>
<b>Статус:</b>
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
- Пользователей онлайн: {node['users_online'] or 0}
- Пользователей онлайн: {node['users_online']}
- Изменение статуса: {format_datetime(node.get('last_status_change')) if node.get('last_status_change') else ''}
- Сообщение: {node.get('last_status_message') or ''}
- Uptime Xray: {node.get('xray_uptime') or ''}
- Сообщение: {html.escape(str(node.get('last_status_message') or ''))}
- Uptime Xray: {xray_uptime_str}
<b>Трафик:</b>
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
+13 -2
View File
@@ -5,6 +5,7 @@ from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.database.crud.server_squad import (
delete_server_squad,
@@ -76,7 +77,7 @@ def _build_server_edit_view(server):
],
[
types.InlineKeyboardButton(
text='🎁 Выдавать сквад' if not server.is_trial_eligible else '🚫 Не выдавать сквад',
text='🎁 Выдавать в триал' if not server.is_trial_eligible else '🚫 Не выдавать в триал',
callback_data=f'admin_server_trial_{server.id}',
),
],
@@ -362,7 +363,17 @@ async def show_server_users(callback: types.CallbackQuery, db_user: User, db: As
if len(display_name) > 30:
display_name = display_name[:27] + '...'
subscription_status = user.subscription.status_display if user.subscription else '❌ Нет подписки'
if settings.is_multi_tariff_enabled() and hasattr(user, 'subscriptions') and user.subscriptions:
status_parts = []
for sub in user.subscriptions:
emoji = '🟢' if sub.is_active else '🔴'
name = sub.tariff.name if sub.tariff else f'#{sub.id}'
status_parts.append(f'{emoji}{name}')
subscription_status = ', '.join(status_parts)
elif user.subscription:
subscription_status = user.subscription.status_display
else:
subscription_status = '❌ Нет подписки'
status_icon = _get_status_icon(subscription_status)
if status_icon:
+5 -1
View File
@@ -3,6 +3,7 @@ from aiogram import Dispatcher, F, types
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.subscription import (
get_all_subscriptions,
get_expired_subscriptions,
@@ -364,8 +365,11 @@ async def send_expiry_reminders(callback: types.CallbackQuery, db_user: User, db
days_left = max(1, subscription.days_left)
tariff_label = ''
if settings.is_multi_tariff_enabled() and hasattr(subscription, 'tariff') and subscription.tariff:
tariff_label = f' «{subscription.tariff.name}»'
reminder_text = f"""
<b>Подписка истекает!</b>
<b>Подписка{tariff_label} истекает!</b>
Ваша подписка истекает через {days_left} день(а).
+57 -21
View File
@@ -1,5 +1,7 @@
"""Управление тарифами в админ-панели."""
import html
import structlog
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
@@ -13,6 +15,7 @@ from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import (
create_tariff,
delete_tariff,
get_active_subscriptions_count_by_tariff_id,
get_tariff_by_id,
get_tariff_subscriptions_count,
get_tariffs_with_subscriptions_count,
@@ -232,6 +235,7 @@ def _format_traffic_reset_mode(mode: str | None) -> str:
'DAY': '📅 Ежедневно',
'WEEK': '📆 Еженедельно',
'MONTH': '🗓️ Ежемесячно',
'MONTH_ROLLING': '🔄 Скользящий месяц',
'NO_RESET': '🚫 Никогда',
}
if mode is None:
@@ -317,7 +321,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
price_block = f'<b>Цены:</b>\n{prices_display}'
tariff_type = '📅 Периодный'
return f"""📦 <b>Тариф: {tariff.name}</b>
return f"""📦 <b>Тариф: {html.escape(tariff.name)}</b>
{status} | {tariff_type}
🎚 Уровень: {tariff.tier_level}
@@ -343,7 +347,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
📊 Подписок на тарифе: {subs_count}
{f'📝 {tariff.description}' if tariff.description else ''}"""
{f'📝 {html.escape(tariff.description)}' if tariff.description else ''}"""
@admin_required
@@ -591,7 +595,7 @@ async def start_edit_daily_price(
await callback.message.edit_text(
f'💰 <b>Редактирование суточной цены</b>\n\n'
f'Тариф: {tariff.name}\n'
f'Тариф: {html.escape(tariff.name)}\n'
f'Текущая цена: {format_price_kopeks(current_price)}/день\n\n'
'Введите новую цену за день в рублях.\n'
'Пример: <code>50</code> или <code>99.90</code>',
@@ -1011,7 +1015,7 @@ async def start_edit_tariff_name(
await state.update_data(tariff_id=tariff_id, language=db_user.language)
await callback.message.edit_text(
f'✏️ <b>Редактирование названия</b>\n\nТекущее название: <b>{tariff.name}</b>\n\nВведите новое название:',
f'✏️ <b>Редактирование названия</b>\n\nТекущее название: <b>{html.escape(tariff.name)}</b>\n\nВведите новое название:',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]]
),
@@ -1801,7 +1805,7 @@ async def start_edit_tariff_traffic_topup(
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')])
await callback.message.edit_text(
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
f'Статус: {status}\n\n'
f'<b>Пакеты:</b>\n{packages_display}\n\n'
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
@@ -1887,7 +1891,7 @@ async def toggle_tariff_traffic_topup(
try:
await callback.message.edit_text(
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
f'Статус: {status}\n\n'
f'<b>Пакеты:</b>\n{packages_display}\n\n'
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
@@ -1931,7 +1935,7 @@ async def start_edit_traffic_topup_packages(
await callback.message.edit_text(
f'📦 <b>Настройка пакетов докупки трафика</b>\n\n'
f'Тариф: <b>{tariff.name}</b>\n\n'
f'Тариф: <b>{html.escape(tariff.name)}</b>\n\n'
f'<b>Текущие пакеты:</b>\n{packages_display}\n\n'
'Введите пакеты в формате:\n'
f'<code>{current_packages}</code>\n\n'
@@ -2010,7 +2014,7 @@ async def process_edit_traffic_topup_packages(
await message.answer(
f'✅ <b>Пакеты обновлены!</b>\n\n'
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
f'Статус: ✅ Включено\n\n'
f'<b>Пакеты:</b>\n{packages_display}\n\n'
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
@@ -2051,7 +2055,7 @@ async def start_edit_max_topup_traffic(
await callback.message.edit_text(
f'📊 <b>Максимальный лимит трафика</b>\n\n'
f'Тариф: <b>{tariff.name}</b>\n'
f'Тариф: <b>{html.escape(tariff.name)}</b>\n'
f'Текущий лимит: <b>{current_display}</b>\n\n'
f'Введите максимальный общий объем трафика (в ГБ), который может быть на подписке после всех докупок.\n\n'
f'• Например, если тариф дает 100 ГБ и лимит 200 ГБ — пользователь сможет докупить еще 100 ГБ\n'
@@ -2127,7 +2131,7 @@ async def process_edit_max_topup_traffic(
await message.answer(
f'✅ <b>Лимит обновлен!</b>\n\n'
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
f'Статус: ✅ Включено\n\n'
f'<b>Пакеты:</b>\n{packages_display}\n\n'
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
@@ -2156,14 +2160,36 @@ async def confirm_delete_tariff(
await callback.answer('Тариф не найден', show_alert=True)
return
active_count = await get_active_subscriptions_count_by_tariff_id(db, tariff_id)
if active_count > 0:
total_count = await get_tariff_subscriptions_count(db, tariff_id)
await callback.message.edit_text(
f'🗑️ <b>Удаление тарифа</b>\n\n'
f'Невозможно удалить тариф <b>{html.escape(tariff.name)}</b>.\n\n'
f'⚠️ <b>Активных подписок:</b> {active_count} (всего: {total_count})\n'
f'Сначала деактивируйте тариф и дождитесь окончания всех активных подписок, '
f'либо переведите подписки на другой тариф.',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text='◀️ Назад к тарифу', callback_data=f'admin_tariff_view:{tariff_id}')],
]
),
parse_mode='HTML',
)
await callback.answer()
return
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
warning = ''
if subs_count > 0:
warning = f'\n\n⚠️ <b>Внимание!</b> На этом тарифе {subs_count} подписок.\nОни будут отвязаны от тарифа.'
warning = (
f'\n\n⚠️ <b>Внимание!</b> На этом тарифе {subs_count} неактивных подписок.\nОни потеряют привязку к тарифу.'
)
await callback.message.edit_text(
f'🗑️ <b>Удаление тарифа</b>\n\nВы действительно хотите удалить тариф <b>{tariff.name}</b>?{warning}',
f'🗑️ <b>Удаление тарифа</b>\n\nВы действительно хотите удалить тариф <b>{html.escape(tariff.name)}</b>?{warning}',
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
@@ -2195,6 +2221,15 @@ async def delete_tariff_confirmed(
await callback.answer('Тариф не найден', show_alert=True)
return
# Защита от удаления тарифа с активными подписками (FK RESTRICT)
active_count = await get_active_subscriptions_count_by_tariff_id(db, tariff.id)
if active_count > 0:
await callback.answer(
f'Невозможно удалить тариф: {active_count} активных подписок. Сначала деактивируйте тариф.',
show_alert=True,
)
return
tariff_name = tariff.name
await delete_tariff(db, tariff)
@@ -2278,7 +2313,7 @@ async def start_edit_tariff_squads(
selected_count = len(current_squads)
await callback.message.edit_text(
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Выбрано: {selected_count} из {len(squads)}\n\n'
'Если не выбран ни один сервер - доступны все.\n'
'Нажмите на сервер для выбора/отмены:',
@@ -2341,7 +2376,7 @@ async def toggle_tariff_squad(
try:
await callback.message.edit_text(
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Выбрано: {len(current_squads)} из {len(squads)}\n\n'
'Если не выбран ни один сервер - доступны все.\n'
'Нажмите на сервер для выбора/отмены:',
@@ -2406,7 +2441,7 @@ async def clear_tariff_squads(
try:
await callback.message.edit_text(
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Выбрано: 0 из {len(squads)}\n\n'
'Если не выбран ни один сервер - доступны все.\n'
'Нажмите на сервер для выбора/отмены:',
@@ -2470,7 +2505,7 @@ async def select_all_tariff_squads(
try:
await callback.message.edit_text(
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Выбрано: {len(squads)} из {len(squads)}\n\n'
'Если не выбран ни один сервер - доступны все.\n'
'Нажмите на сервер для выбора/отмены:',
@@ -2540,7 +2575,7 @@ async def start_edit_tariff_promo_groups(
selected_count = len(current_groups)
await callback.message.edit_text(
f'👥 <b>Промогруппы для тарифа «{tariff.name}»</b>\n\n'
f'👥 <b>Промогруппы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Выбрано: {selected_count}\n\n'
'Если не выбрана ни одна группа - тариф доступен всем.\n'
'Выберите группы, которым доступен этот тариф:',
@@ -2608,7 +2643,7 @@ async def toggle_tariff_promo_group(
try:
await callback.message.edit_text(
f'👥 <b>Промогруппы для тарифа «{tariff.name}»</b>\n\n'
f'👥 <b>Промогруппы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Выбрано: {len(current_groups)}\n\n'
'Если не выбрана ни одна группа - тариф доступен всем.\n'
'Выберите группы, которым доступен этот тариф:',
@@ -2665,7 +2700,7 @@ async def clear_tariff_promo_groups(
try:
await callback.message.edit_text(
f'👥 <b>Промогруппы для тарифа «{tariff.name}»</b>\n\n'
f'👥 <b>Промогруппы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Выбрано: 0\n\n'
'Если не выбрана ни одна группа - тариф доступен всем.\n'
'Выберите группы, которым доступен этот тариф:',
@@ -2682,6 +2717,7 @@ TRAFFIC_RESET_MODES = [
('DAY', '📅 Ежедневно', 'Трафик сбрасывается каждый день'),
('WEEK', '📆 Еженедельно', 'Трафик сбрасывается каждую неделю'),
('MONTH', '🗓️ Ежемесячно', 'Трафик сбрасывается каждый месяц'),
('MONTH_ROLLING', '🔄 Скользящий месяц', 'Трафик сбрасывается через 30 дней от первого подключения'),
('NO_RESET', '🚫 Никогда', 'Трафик не сбрасывается автоматически'),
]
@@ -2731,7 +2767,7 @@ async def start_edit_traffic_reset_mode(
current_mode = getattr(tariff, 'traffic_reset_mode', None)
await callback.message.edit_text(
f'🔄 <b>Режим сброса трафика для тарифа «{tariff.name}»</b>\n\n'
f'🔄 <b>Режим сброса трафика для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Текущий режим: {_format_traffic_reset_mode(current_mode)}\n\n'
'Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n'
'• <b>Глобальная настройка</b> — использовать значение из конфига бота\n'
@@ -2775,7 +2811,7 @@ async def set_traffic_reset_mode(
# Обновляем клавиатуру
await callback.message.edit_text(
f'🔄 <b>Режим сброса трафика для тарифа «{tariff.name}»</b>\n\n'
f'🔄 <b>Режим сброса трафика для тарифа «{html.escape(tariff.name)}»</b>\n\n'
f'Текущий режим: {mode_display}\n\n'
'Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n'
'• <b>Глобальная настройка</b> — использовать значение из конфига бота\n'
+10 -8
View File
@@ -232,8 +232,10 @@ async def view_admin_ticket(
TicketStatus.PENDING.value: texts.t('TICKET_STATUS_PENDING', 'В ожидании'),
}.get(ticket.status, ticket.status)
user_name = ticket.user.full_name if ticket.user else 'Unknown'
telegram_id_display = (ticket.user.telegram_id or ticket.user.email or f'#{ticket.user.id}') if ticket.user else ''
user_name = html.escape(ticket.user.full_name) if ticket.user else 'Unknown'
telegram_id_display = (
html.escape(str(ticket.user.telegram_id or ticket.user.email or f'#{ticket.user.id}')) if ticket.user else ''
)
username_value = ticket.user.username if ticket.user else None
id_label = 'Telegram ID' if (ticket.user and ticket.user.telegram_id) else 'ID'
@@ -245,7 +247,7 @@ async def view_admin_ticket(
header += f'📱 Username: @{safe_username}\n'
else:
header += '📱 Username: отсутствует\n'
header += f'📝 Заголовок: {ticket.title}\n'
header += f'📝 Заголовок: {html.escape(ticket.title)}\n'
header += f'📊 Статус: {ticket.status_emoji} {status_text}\n'
header += f'📅 Создан: {ticket.created_at.strftime("%d.%m.%Y %H:%M")}\n\n'
@@ -261,7 +263,7 @@ async def view_admin_ticket(
message_blocks.append(f'💬 Сообщения ({len(ticket.messages)}):\n\n')
for msg in ticket.messages:
sender = '👤 Пользователь' if msg.is_user_message else '🛠️ Поддержка'
block = f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n{msg.message_text}\n\n'
block = f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n{html.escape(msg.message_text)}\n\n'
if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo':
block += '📎 Вложение: фото\n\n'
message_blocks.append(block)
@@ -801,10 +803,10 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo
TicketStatus.CLOSED.value: texts.t('TICKET_STATUS_CLOSED', 'Закрыт'),
TicketStatus.PENDING.value: texts.t('TICKET_STATUS_PENDING', 'В ожидании'),
}.get(updated.status, updated.status)
user_name = updated.user.full_name if updated.user else 'Unknown'
user_name = html.escape(updated.user.full_name) if updated.user else 'Unknown'
ticket_text = f'🎫 Тикет #{updated.id}\n\n'
ticket_text += f'👤 Пользователь: {user_name}\n'
ticket_text += f'📝 Заголовок: {updated.title}\n'
ticket_text += f'📝 Заголовок: {html.escape(updated.title)}\n'
ticket_text += f'📊 Статус: {updated.status_emoji} {status_text}\n'
ticket_text += f'📅 Создан: {updated.created_at.strftime("%d.%m.%Y %H:%M")}\n'
ticket_text += f'🔄 Обновлен: {updated.updated_at.strftime("%d.%m.%Y %H:%M")}\n'
@@ -823,7 +825,7 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo
ticket_text += f'🔗 Чат по ID: <a href="{chat_link}">{chat_link}</a>\n'
elif updated.user:
# Email-only user
user_id_display = updated.user.email or f'#{updated.user.id}'
user_id_display = html.escape(str(updated.user.email or f'#{updated.user.id}'))
ticket_text += f'🆔 ID: <code>{user_id_display}</code>\n'
ticket_text += '📧 Тип: Email-пользователь\n'
ticket_text += '\n'
@@ -837,7 +839,7 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo
for msg in updated.messages:
sender = '👤 Пользователь' if msg.is_user_message else '🛠️ Поддержка'
ticket_text += f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n'
ticket_text += f'{msg.message_text}\n\n'
ticket_text += f'{html.escape(msg.message_text)}\n\n'
if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo':
ticket_text += '📎 Вложение: фото\n\n'

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