Compare commits

...

1205 Commits

Author SHA1 Message Date
github-actions[bot] 24cd521d8b chore(main): release 3.54.1 2026-05-04 18:22:47 +00:00
Egor d6442b87df Merge pull request #2926 from BEDOLAGA-DEV/dev
docs: add Antilopay/Etoplatezhi/Jupiter/Donut/Lava + Apple IAP to pro…
2026-05-04 21:22:17 +03:00
Fringg 31e3ccd24c docs: add Antilopay/Etoplatezhi/Jupiter/Donut/Lava + Apple IAP to provider list
- Bump provider counter 18 → 24+ in Features and Documentation sections
- Add 7 new rows to providers table:
  - Antilopay (RSA signing)
  - Etoplatezhi
  - Jupiter (FPGate P2P) — partner via @k_juppiter
  - Donut (Donut P2P) — partner via @donut_payment
  - Lava Business
  - Apple In-App Purchase
- Add 2 partner cards (Jupiter, Donut) following the Platega/PayPear pattern
2026-05-04 21:20:44 +03:00
Egor 3d4b7b4582 Merge pull request #2925 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.54.0
2026-05-04 20:58:57 +03:00
github-actions[bot] cedf4922fd chore(main): release 3.54.0 2026-05-04 17:57:52 +00:00
Egor 491f09d547 Merge pull request #2924 from BEDOLAGA-DEV/dev
Dev
2026-05-04 20:57:17 +03:00
Fringg 0d0646770d fix(tests): clean up pre-existing ruff lint warnings in apple_iap tests
- Remove unused unittest.mock.patch import (F401)
- Mark hardcoded /tmp/test.p8 path with noqa S108 (only used for
  is_apple_iap_enabled check, no file actually accessed)
- Replace pytest.raises(Exception) with pytest.raises(ValidationError)
  for Pydantic schema validation tests (B017)
2026-05-04 20:50:29 +03:00
Fringg 17732a0370 style: apply ruff format to payment integrations
CI ruff format --check failed on 13 files. Applied ruff format to bring
them in line with project formatting (line wrapping, trailing commas,
quote consistency). No functional changes.
2026-05-04 20:47:48 +03:00
Fringg cd8be32671 fix: register all providers in payment search and verification services
The admin Payments page filter and pending payments tab were missing newer
providers (paypear, rollypay, aurapay, etoplatezhi, antilopay, jupiter, donut,
lava) because they were never registered in the search/verification registries.
Customer payments via these providers were invisible in admin filtering.

payment_search_service.py:
- Add 8 _search_<provider> functions matching the existing pattern
- Register them in _PROVIDER_SEARCH_MAP (now 22 methods total)
- Filter dropdown and stats.by_method now include all providers

payment_verification_service.py:
- Add 8 _is_<provider>_pending and _fetch_<provider>_payments functions
- Wire them into list_recent_pending_payments and get_payment_record
- Add display name and is_enabled dispatch branches for all 8
- Register paypear / rollypay / aurapay in SUPPORTED_MANUAL_CHECK_METHODS and
  SUPPORTED_AUTO_CHECK_METHODS (they have full API+DB sync via check_*)
- Wire them into run_manual_check
- etoplatezhi / antilopay / jupiter / donut / lava remain webhook-driven and
  appear in pending tab without manual-check button (no fake API sync)
2026-05-04 20:43:47 +03:00
Fringg afea054c8f feat: integrate Lava Business payment provider
- Lava Business via gate.lava.ru (HMAC-SHA256 signed JSON requests)
- Sub-methods: card and SBP via includeService filter
- Webhook signature verified from raw bytes with secret_key_2
- Sticky terminal-status guard (success after amount_mismatch escalates to ERROR)
- Order ID with full uuid4 hex (128-bit entropy)
- Cross-row contamination guard: order_id assertion on invoice_id fallback
- Warning when hook URL cannot be derived from webhook/web_api/cabinet bases
- Explicit failure when Lava response lacks payment_url (no orphan rows)
- Adds LAVA settings category, /lava-webhook endpoint, cabinet topup branch
- Mirrors existing Antilopay/Jupiter/Donut mixin pattern
2026-05-04 20:14:31 +03:00
Fringg f321ded9c0 feat: integrate Jupiter (FPGate P2P) and Donut payment providers
- Jupiter: SBP via app.juppiter.tech (FPGate P2P v2.1)
- Donut: CARD/SBP/SBP_QR via gw.donut.business (Donut P2P)
- HMAC-SHA256 signing verified against spec reference vectors
- Sticky terminal-status guard in callback (amount_mismatch/declined/cancelled
  cannot be re-credited by replayed webhook)
- Mirrors existing Antilopay/Etoplatezhi mixin pattern: service, mixin, CRUD,
  Alembic migration, handlers, keyboards, webhook, cabinet route, status mapping
- Adds JUPITER and DONUT settings categories with title/description/prefix
- Backfills missing ANTILOPAY and ETOPLATEZHI category metadata
2026-05-04 19:36:22 +03:00
Fringg 3fce64858c fix: add pycryptodome dependency for Antilopay RSA signing 2026-05-04 17:31:06 +03:00
Fringg 1ab1ff90bf feat: add subscription reissue with 15-min cooldown
- Add revoke handler for classic and multi-tariff modes with 2-step
  confirmation dialog and TOCTOU-safe cooldown enforcement
- Add cabinet API endpoint POST /subscription/revoke with 429 + Retry-After
  for cooldown, IDOR protection via resolve_subscription
- Add last_revoke_at column to subscriptions (Alembic migration 0071)
- Add SUBSCRIPTION_REVOKE_ENABLED and COOLDOWN_SECONDS config settings
- Add revoke button to classic subscription settings keyboard and
  multi-tariff detail keyboard (gated by feature toggle)
- Add locale keys for revoke UI in all 5 languages (ru, en, ua, zh, fa)
2026-05-04 08:08:56 +03:00
Fringg 719664208e feat: integrate Antilopay payment provider (API v2)
- Add antilopay_service.py with SHA256WithRSA signing (pycryptodome),
  private key for requests, public key for callback verification
- Add payment mixin with create/callback/finalize/check_status flows,
  kopeks↔rubles conversion, 7 status mappings, prefer_methods support
- Add CRUD with FOR UPDATE locking, idempotency checks
- Add handlers with SBP/Card/SberPay sub-method selection
- Add Alembic migration for antilopay_payments table
- Add config (ANTILOPAY_ENABLED, SECRET_ID, PRIVATE_KEY, PUBLIC_KEY,
  PROJECT_ID, SBP/CARD/SBERPAY enabled/display names)
- Add webhook endpoint with X-Apay-Callback header signature verification
- Register in keyboard, router, utils, backup, method config
2026-05-04 07:44:17 +03:00
Fringg 6524f66da2 feat: integrate Etoplatezhi payment provider
- Add etoplatezhi_service.py with HMAC-SHA512+base64 signature algorithm,
  payment URL builder, and callback signature verification
- Add payment mixin with create/process/finalize flow, 12 status mappings
- Add CRUD operations with FOR UPDATE locking, idempotency checks
- Add Telegram handlers with SBP/Card sub-method selection
- Add Alembic migration for etoplatezhi_payments table
- Add config settings (ETOPLATEZHI_ENABLED, PROJECT_ID, SECRET_KEY,
  SBP_ENABLED, CARD_ENABLED, display names, min/max amounts)
- Add webhook endpoint with JSON-body signature verification
- Register in payment keyboard, router, utils, backup, method config
2026-05-04 07:17:54 +03:00
Fringg 17ac3da3c4 fix: AuraPay webhook signature + add SBP/Card payment method selection
- Fix webhook signature: str(None) produced "None" (4 chars) instead of
  "" like PHP implode() does, causing all webhooks with custom_fields=null
  to fail signature verification
- Add AURAPAY_SBP_ENABLED / AURAPAY_CARD_ENABLED env vars with display
  names, following Freekassa pattern for sub-method selection
- Add aurapay_sbp / aurapay_card buttons in payment keyboard
- Add start_aurapay_sbp_topup / start_aurapay_card_topup handlers
- Route dispatch handles aurapay / aurapay_sbp / aurapay_card
- payment_utils updated with SBP/Card availability checks
- service parameter ("sbp"/"card") now passed through to AuraPay API
2026-05-04 06:44:03 +03:00
Fringg e85c40f8cd fix: apple refund handler — lock apple_transactions row to prevent double deduction 2026-05-04 05:54:11 +03:00
Fringg ecde2fb8f0 feat: Apple IAP integration with security hardening 2026-05-04 05:49:38 +03:00
Fringg 99648a956e fix: persist campaign across bot→webapp registration handoff via Redis 2026-05-04 05:27:49 +03:00
Fringg 2478ff7c3d fix: expired_1d notification — use PricingEngine instead of hardcoded PRICE_30_DAYS 2026-05-04 05:14:47 +03:00
Fringg 2385814d77 fix: guide mode buttons — support external type alias, extract urlScheme from blocks
From PR #2923 by @dotX12, with improvements:
- Support type: "external" as alias for "externalLink" in app config
- Extract urlScheme from subscriptionLink buttons in blocks[] when not at root
- Wrap custom URL schemes in HTTPS redirect for Telegram compatibility
- Fallback to plain subscription URL when no redirect template configured

Improvements over original PR:
- Also check btn.get('url') not just btn.get('link') for scheme extraction
- Validate extracted scheme contains :// before accepting
- Skip redundant redirect wrapping when create_deep_link already wrapped
2026-05-04 05:04:27 +03:00
Egor df7e397745 Merge pull request #2918 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.53.0
2026-04-29 12:12:57 +03:00
github-actions[bot] 52868eac5b chore(main): release 3.53.0 2026-04-29 09:12:34 +00:00
Egor 4c600b8557 Merge pull request #2917 from BEDOLAGA-DEV/dev
Dev
2026-04-29 12:11:47 +03:00
Fringg 51dfc3a1a2 feat: protect active paid subscriptions from bulk delete
- Backend: _do_delete_subscription refuses to delete active paid subs
  unless force_delete_active_paid=true is explicitly passed
- Backend: add force_delete_active_paid to BulkActionParams (default false)
- Backend: add is_trial to SubscriptionListItem schema + populate it
2026-04-29 11:31:08 +03:00
Fringg 443a826402 fix: PayPear webhook signature — strip signature field before hashing + IP fallback
The old code hashed the full raw body INCLUDING the 'signature' field
itself — a circular computation that can never match (you can't include
the signature in the data being signed).

Fix:
1. Strip 'signature' key from payload before HMAC-SHA256 computation
2. Try both sorted and unsorted keys (PayPear docs don't specify)
3. Fallback to IP allowlist check (158.160.85.101 per PayPear docs)
4. Pass client_ip from request headers to the verification function
2026-04-29 11:23:20 +03:00
Fringg 06db393488 feat: add bulk_actions, info_pages, news to PERMISSION_REGISTRY
- bulk_actions: read, execute (was using users:edit)
- info_pages: read, create, edit, delete (was using settings:read/edit)
- news: read, create, edit, delete (was missing from registry entirely)

Backend endpoints updated to use dedicated permissions instead of
piggybacking on users:edit / settings:read.
2026-04-29 11:14:24 +03:00
Fringg 0bcb804118 fix: block/unblock endpoints — correct args, response schema, panel sync
4 bugs fixed:
1. block_user() called with User object instead of int user_id, missing admin_id
2. Response used wrong fields (user_id/status instead of old_status/new_status)
3. Return value not checked — reported success even on failure
4. unblock endpoint used DB-only update_user_status instead of UserService.unblock_user
2026-04-29 10:51:23 +03:00
Fringg 735e16afeb fix: cabinet /block endpoint now disables panel user in RemnaWave 2026-04-29 10:47:40 +03:00
Fringg a88e3c80ad fix: traffic addon price mismatch — keyboard showed prorated, handler charged full month
Keyboard calculated: price * days_remaining / 30 (true proration)
Handler calculated: price * max(30, days_remaining) / 30 (always >= 30 days)

With 17 days remaining: keyboard showed 84₽, handler charged 149₽.

Fix: change calculate_prorated_price default min_charge_days from 30 to 1.
Now all callers (traffic, countries, servers, miniapp, auto-purchase)
use true proration matching the displayed price.
2026-04-29 10:42:10 +03:00
Fringg 1110d0c781 fix: media upload leaks staging photo to admin chat
The upload endpoint sent files to the admin notification chat to obtain
a Telegram file_id, but never deleted the staging message. Admins saw
uncontextualized images in their chat before any ticket was created.

Fix: send with disable_notification=True and immediately delete the
staging message after capturing the file_id. Telegram persists file_ids
even after message deletion.
2026-04-29 10:32:55 +03:00
Fringg 62e7ecba01 fix: deadlock on user deletion — webhook handler never checked intentional mark
mark_intentional_panel_deletion was called before api.delete_user,
but _is_intentional_panel_deletion_event was never called in the
webhook handler — it was dead code. The user.deleted webhook processed
unconditionally, causing a deadlock between delete_user_account (Tx1
holding subscription row locks) and the webhook handler (Tx2 trying
to lock the same rows via decrement_subscription_server_counts).

Fix: check _is_intentional_panel_deletion_event at the top of
_handle_user_deleted — if True, log and return immediately without
touching the DB.
2026-04-29 10:28:57 +03:00
Fringg c905fa6000 fix: downgrade Pal24 API validation errors from error to debug 2026-04-29 10:25:08 +03:00
Fringg 768e0b6a73 fix: PollResponse has no created_at — use sent_at for ordering 2026-04-29 10:17:40 +03:00
Fringg 83efc214fe fix: add 6 missing payment providers to payment_utils availability checks
RollyPay (and 5 others) showed buttons but triggered "payment methods
unavailable" because get_available_payment_methods() was missing them.
The keyboard builder (inline.py) had all providers, but the text
generator (payment_utils.py) did not — divergent hand-maintained lists.

Added to all 4 functions: get_available_payment_methods,
is_payment_method_available, get_payment_method_status,
get_enabled_payment_methods_count:
- SeverPay, PayPear, RollyPay, Overpay, AuraPay (new)
- RioPay (was in methods list but missing from status/count)
2026-04-29 08:27:37 +03:00
Fringg 29e177d396 fix: cabinet autopay endpoint — same NULL-safe is_trial guard 2026-04-29 08:21:41 +03:00
Fringg 2fbdbf5ab0 fix: autopay renewing trial subscriptions at classic-mode pricing
Three bugs caused trial subscriptions to be auto-renewed without a
tariff at arbitrary prices:

1. try_auto_extend_expired_after_topup: is_trial guard used truthiness
   check — NULL (legacy rows) passed as falsy. Changed to
   `is_trial is not False` (NULL-safe).

2. Multi-tariff branch: `not s.is_trial` treated NULL as not-trial.
   Changed to `s.is_trial is False`.

3. Telegram bot autopay toggle: no is_trial guard — users could enable
   autopay on trial subscriptions. Added trial check before enabling.
2026-04-29 08:16:39 +03:00
Fringg 422844d78d fix: retry queue action uses _should_create instead of stale subscription UUID 2026-04-29 08:08:24 +03:00
Fringg f37eb9a1bd fix: cabinet purchase fails after panel user deletion — stale UUID
Two bugs caused "RemnaWave UUID не найден" when a user repurchased
after their panel user was deleted (expired user cleanup):

1. Webhook handler only cleared subscription.remnawave_uuid in
   multi-tariff mode. In single-tariff mode the stale UUID remained,
   causing the cabinet to try update_remnawave_user on a deleted
   panel user instead of creating a new one.

2. Cabinet purchase-tariff used subscription.remnawave_uuid for the
   create/update decision. In single-tariff mode this was stale.
   Now mirrors the bot handler logic: checks user.remnawave_uuid
   in single-tariff mode (correctly cleared by webhook).
2026-04-29 08:04:23 +03:00
Fringg 1c38b31e60 fix: send admin notification on promo code activation from cabinet 2026-04-29 07:50:09 +03:00
Fringg 43dd0fd92c fix: referral links now clickable — remove <code> wrapping
The invite message wrapped the entire text including the referral URL
in <blockquote><code>...</code></blockquote>. The <code> tag made the
URL non-clickable — Telegram renders it as monospace copyable text.
Recipients couldn't tap the link to open it.

- Invite message: removed <code> from blockquote, Telegram now auto-links the URL
- Stats panel: removed <code> from bot/cabinet referral links, URLs are now clickable
2026-04-29 07:45:28 +03:00
Fringg a506c6be00 fix: add 5 missing payment providers to pending-payments model_map 2026-04-29 07:40:50 +03:00
Fringg ff7b190527 fix: add RollyPay, PayPear, Overpay, AuraPay to REAL_PAYMENT_METHODS 2026-04-29 07:36:48 +03:00
Fringg 527c5b4498 fix: panel sync subscription duration — ceil for days_remaining 2026-04-29 07:32:08 +03:00
Fringg bada41ecd6 fix: remaining pricing-critical .days floor calculations → math.ceil
Same bug as device pricing: timedelta.days floors partial days.
Fixed 14 more pricing-critical locations across 7 files:

- traffic addon pricing (bot handler + cabinet + miniapp)
- country addon pricing (bot handler + miniapp)
- generic addon pricing helper (common.py)
- auto-purchase device recomputation
- subscription CRUD pricing helper

Display-only .days usages intentionally left as floor (correct for
showing "X days left" to users).
2026-04-29 07:27:55 +03:00
Fringg cf60ae2967 fix: device/traffic addon pricing — use ceil instead of floor for days_left
timedelta.days is integer floor: 29 days 23 hours = 29, not 30.
When a user bought extra devices on the same day as their subscription,
they were charged for ~1 day instead of the full remaining period.

Fix: math.ceil(total_seconds / 86400) rounds partial days UP.
Applied to all 11 locations across 4 files:
- app/handlers/subscription/devices.py (5 spots)
- app/cabinet/routes/subscription_modules/devices.py (3 spots)
- app/keyboards/inline.py (3 spots — display pricing)
- app/utils/pricing_utils.py (1 spot — traffic prorated pricing)
2026-04-29 07:21:07 +03:00
Fringg 47c7d45793 fix: traffic addon discount also bypassed tariff-promo-group check 2026-04-29 07:14:14 +03:00
Fringg 4ab5928b61 fix: promo group discount applied to restricted tariffs in autopay
The pricing engine applied promo group discounts unconditionally,
without checking if the tariff is available for the user's promo group.

In autopay: user with VIP group (60% discount, restricted to Premium
tariff) would get 60% off when auto-renewing a Basic tariff that their
group should not cover.

Fix: in _calculate_tariff_core, check tariff.is_available_for_promo_group
before applying group discounts. If tariff is not available for the
user's promo group, the discount is zeroed — subscription renews at
full price. Protects ALL pricing paths (autopay, recurrent, manual).
2026-04-29 07:09:55 +03:00
Fringg fb857d792b feat: per-category enable/disable for admin notifications
Add ADMIN_NOTIFICATIONS_{CATEGORY}_ENABLED settings (default True) for
all 10 notification categories: purchases, renewals, trials, balance,
addons, infrastructure, errors, promo, partners, tickets.

Setting ADMIN_NOTIFICATIONS_PROMO_ENABLED=false now completely suppresses
promo notifications (promocode activations, campaign visits, promo group
changes) instead of silently falling back to the general topic.

Also fix referral_contest_service direct bot.send_message bypass —
now respects ADMIN_NOTIFICATIONS_PROMO_ENABLED setting.
2026-04-29 06:58:57 +03:00
Fringg 59080f7392 fix: handle A018 error code in admin_users sync endpoints (2 more locations) 2026-04-29 06:52:04 +03:00
Fringg c619dbcae2 fix: handle A018 error code as user-not-found fallback to create_user 2026-04-29 06:48:30 +03:00
Fringg 91de6d03fc fix: update cabinet_last_login on every request (throttled, 5 min) 2026-04-29 06:46:02 +03:00
Fringg 1fc04d842f fix: subscription-request-history — correct API client usage, add ownership check 2026-04-29 06:18:32 +03:00
Fringg e22beb7229 feat: subscription request history API + RemnaWave panel method
- Add get_subscription_request_history to RemnaWave API client
  (GET /api/users/{uuid}/subscription-request-history with pagination)
- Add GET /admin/users/{user_id}/subscription-request-history endpoint
  with subscription_id param for multi-tariff support
2026-04-29 06:12:38 +03:00
Fringg 74999fe99d fix: create locales directory with correct permissions in Dockerfile 2026-04-29 05:55:21 +03:00
Fringg 134e7fb0e1 fix: false subscription expiry notifications — 4 bugs fixed
1. _check_expired_subscription_followups: added Subscription.status=EXPIRED
   filter (was matching ALL statuses including ACTIVE), User.status=ACTIVE
   filter, and 30-day lookback window to stop scanning ancient subscriptions

2. _get_expiring_paid_subscriptions: added User.status=ACTIVE filter to
   prevent sending "expiring" notifications to blocked/deleted users

3. Multi-tariff: before sending expired/followup notifications, check if
   user has another ACTIVE subscription with end_date > now — skip if they
   still have service through another tariff

4. Multi-tariff: same check for _check_expired_subscriptions — don't send
   "subscription expired" if user has another active sub
2026-04-29 05:47:56 +03:00
Fringg c743fc81a5 fix: replace all late callback.answer() with edit_text for error feedback
- Fix 7 intermediate error paths (balance deduction failures) that used
  callback.answer() after the early answer was already consumed — user
  got no error feedback at all
- Fix 2 unfixed handlers: confirm_tariff_purchase, confirm_daily_tariff_purchase
  — same early-answer pattern applied
- All 7 purchase/extend/switch handlers now consistently use early
  callback.answer() + edit_text for errors
2026-04-29 05:37:30 +03:00
Fringg 579e4f2a69 fix: callback.answer() before heavy operations to prevent query timeout
Telegram invalidates callback queries after 30 seconds. When the bot
performed panel sync, DB transactions, and admin notifications before
answering, callback.answer() threw TelegramBadRequest: query is too old.

Moved callback.answer() to immediately after guard checks (balance,
tariff availability) in 5 handlers:
- confirm_tariff_extend
- confirm_custom_tariff_purchase
- confirm_tariff_switch
- confirm_daily_tariff_switch
- confirm_instant_switch

Error feedback now uses callback.message.edit_text() instead of the
expired callback.answer().
2026-04-27 16:56:39 +03:00
Fringg b9b695799c refactor: remove unused EXTERNAL_ADMIN_TOKEN functionality
- Delete app/services/external_admin_service.py entirely
- Remove EXTERNAL_ADMIN_TOKEN and EXTERNAL_ADMIN_TOKEN_BOT_ID from config
- Remove build_external_admin_token, get_external_admin_token, get_external_admin_bot_id methods
- Remove unused hashlib/hmac imports from config.py
- Remove from system_settings_service: READ_ONLY_KEYS, PLAIN_TEXT_KEYS,
  category title, category description, prefix mapping, documentation metadata
- Remove from bot_configuration.py category group
- Remove from main.py startup sequence (ensure_external_admin_token call)
- Remove from .env.example
- Remove from docs/project_structure_reference.md
2026-04-26 19:54:33 +03:00
Fringg 5cf19c76e6 fix: backup import crash + upload handler hardening
- Fix PaypearPayment → PayPearPayment (capital P) — import crash
- Fix AurapayPayment → AuraPayPayment (capital P) — import crash
- Update upload instruction message to mention .tar.gz format
- Add null guard on document.file_name before extension check
2026-04-26 19:39:09 +03:00
Fringg eafb243882 fix: backup completeness — add 15 missing tables, accept .tar.gz uploads
Tables added to backup AND clear lists:
- Payment providers: riopay, severpay, paypear, rollypay, overpay, aurapay, saved_payment_methods
- Content: email_templates, info_pages, news_articles, news_categories, news_tags
- Landing: landing_pages, guest_purchases
- Analytics: yandex_client_id_map

Also:
- Telegram backup upload handler now accepts .tar.gz format (was .json/.json.gz only)
- All 92 ORM models + 3 association tables now covered
2026-04-26 19:29:55 +03:00
Egor 56b0b1fb5f Merge pull request #2914 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.52.1
2026-04-24 18:22:53 +03:00
github-actions[bot] 10519bf68e chore(main): release 3.52.1 2026-04-24 15:22:37 +00:00
Egor 7bff56070c Merge pull request #2913 from BEDOLAGA-DEV/dev
Dev
2026-04-24 18:22:09 +03:00
Fringg 5ed9a0d4fb fix: use fresh DB session for deactivate after long unpin loop 2026-04-24 18:13:48 +03:00
Fringg 63e1127353 fix: broadcast preview count — add .correlate(User) to EXISTS subqueries 2026-04-24 18:03:29 +03:00
Fringg ab4661b5c6 fix: unpin messages in Telegram BEFORE deactivating in DB
The "Unpin all" button called deactivate_active_pinned_message() first,
then looped over users to unpin. If Telegram API calls failed or timed
out, the message was already marked inactive in the DB with no way to
retry. Now: get active message → unpin from all chats → deactivate in DB.
2026-04-24 18:01:52 +03:00
Fringg 52bf2a9589 fix: ignore bot's own messages in unknown message handlers 2026-04-24 17:55:37 +03:00
Egor 14e24a546e Merge pull request #2911 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.52.0
2026-04-24 17:12:13 +03:00
github-actions[bot] f60231e104 chore(main): release 3.52.0 2026-04-24 14:11:54 +00:00
Egor 920bab5b4f Merge pull request #2910 from BEDOLAGA-DEV/dev
Dev
2026-04-24 17:11:17 +03:00
Fringg 68d2350dfd fix: stop printing tracebacks for warning-level logs inside except blocks 2026-04-24 17:09:20 +03:00
Fringg 7d512d214a fix: integrate Yandex Metrika offline conv + S2S postback hooks
Restore integration hooks dropped in PR #2851 merge:
- PurchaseRequest accepts yandex_cid, referrer, subid from frontend
- Cache yandex_cid and subid in Redis at purchase creation (24h TTL)
- On fulfill_purchase: extract subid from cache, persist to DB
- Save Yandex CID from Redis to yandex_client_id_map
- Fire on_registration + S2S postback for new accounts
- Fire on_purchase + S2S postback for all paid purchases
- All hooks wrapped in try/except — failures never block delivery
2026-04-24 16:59:27 +03:00
Fringg 2cde38c63b fix: restore referrer field in admin landing purchases response 2026-04-24 16:53:47 +03:00
Fringg 24dc8d2a5e fix: restore HTTP Referer fallback for landing purchases 2026-04-24 16:47:22 +03:00
Fringg 1522d35f2d fix: gift purchases no longer inflate promo group level
Two bugs caused max promo group assignment on gift send/activate:

1. Buyer: GIFT_PAYMENT was counted in get_user_total_spent_kopeks
   alongside SUBSCRIPTION_PAYMENT. Now only SUBSCRIPTION_PAYMENT
   counts as personal spending for promo group auto-assignment.

2. Recipient: fulfill_purchase and activate_purchase created a
   SUBSCRIPTION_PAYMENT transaction for the recipient with the full
   gift price. Now skipped for gift recipients — they didn't pay.
2026-04-24 16:43:32 +03:00
Fringg 2b0d8a2a88 style: ruff format admin_bulk_actions.py 2026-04-24 16:34:43 +03:00
Fringg 9217f4116f fix: sanitize error messages in all bulk action catch-all handlers
Replace str(exc) with generic 'Action failed: internal error' in both
_execute_for_user and _execute_for_subscription catch-all blocks.
Prevents leaking internal paths, SQL details, or connection strings.
2026-04-24 16:24:45 +03:00
Fringg 2e45a93bd7 fix: bulk delete_user — pass real admin_id, sanitize error messages
- Thread admin_id through _execute_for_user to _do_delete_user for audit trail
- Replace raw exception str(e) with generic error message in client response
- Add subscriptions=[] to failure paths to prevent MissingGreenlet
2026-04-24 16:16:50 +03:00
Fringg d77fd81e16 feat: bulk actions — campaign/partner filters, delete_user action
- Add campaign_id and partner_id query params to GET /admin/users
- Filter users by advertising campaign via EXISTS subquery on registrations
- Filter users by partner via JOIN campaign registrations → campaigns
- Add DELETE_USER bulk action type with delete_from_panel param
- Handler calls UserService.delete_user_account for full bot+panel removal
- Permission check: users:delete required for delete_user action
- Add to _USER_LEVEL_ACTIONS (operates on user_ids, not subscription_ids)
2026-04-24 16:07:14 +03:00
Fringg bdb8cab1c9 feat: info page tab replacement — replaces_tab field + API
- Add replaces_tab column to InfoPage ('faq','rules','privacy','offer')
- Migration 0067: add nullable replaces_tab column
- CRUD: clear_replaces_tab ensures one page per tab, get_tab_replacements
  returns {tab: slug} mapping for active pages
- Admin routes: auto-clear old assignment on create/update
- Public route: GET /info-pages/tab-replacements (no auth)
- Schemas: replaces_tab with regex validation in all request/response models
2026-04-24 14:21:32 +03:00
Fringg d394565fe9 feat: FAQ support in info pages — page_type field + migration
- Add page_type column to InfoPage model ('page' or 'faq')
- Migration 0066: ALTER TABLE ADD COLUMN with server_default='page'
- Update schemas with page_type field and regex validation
- Update CRUD: page_type in create, filter in list
- Update admin/public routes with page_type query filter
- Backward compatible: existing pages default to type 'page'
2026-04-24 14:01:18 +03:00
Fringg 122d12db20 fix: /reorder route unreachable — move before /{page_id} path param 2026-04-24 13:50:38 +03:00
Fringg 2071a680d3 fix: info pages review — deduplicate slug index, type reorder items
- Remove triple-redundant slug index: keep only unique=True on column
  (PostgreSQL creates unique index automatically), remove __table_args__
  index and explicit create_index in migration
- Type ReorderRequest.items with ReorderItem(id: int, sort_order: int)
  instead of raw dict — prevents unvalidated input causing 500
- Migration downgrade: just drop_table (unique constraint drops with it)
2026-04-24 08:21:41 +03:00
Fringg e4b4a54797 feat: information pages — CRUD model, admin API, public API
- InfoPage model: slug, title (JSONB locale dict), content (JSONB),
  is_active, sort_order, icon, created_at/updated_at
- CRUD: create, get by id/slug, list, update, delete, reorder
- Admin routes: /admin/info-pages with full CRUD, toggle-active, reorder
  (permissions: settings:read/settings:edit)
- Public routes: /info-pages list active, /info-pages/{slug} get by slug
- Migration 0065: create info_pages table with unique slug index
- Custom pages support: admins can create any info page with any slug
2026-04-24 08:09:54 +03:00
Fringg 59c54c9b39 fix: privacy policy and offer text display HTML links as plain text
parse_mode='HTML' was missing from message sends during /start
registration. HTML tags like <a href="..."> were shown as literal
text instead of rendered links.

Fixed in 4 places:
- Privacy policy edit_text (line 1151)
- Privacy policy fallback answer (line 1158)
- Welcome/offer text in complete_registration_from_callback (line 1729)
- Welcome/offer text in complete_registration (line 2084)
2026-04-24 07:43:54 +03:00
Fringg 0d2b1dfdc9 feat: support multiple tariff_ids in user list filter
tariff_id query param now accepts comma-separated IDs (e.g.
tariff_id=1,3,5). CRUD functions updated to use IN() operator
for multi-tariff server-side filtering. Pagination works correctly
with multiple tariffs selected.
2026-04-24 07:16:33 +03:00
Fringg 605f202191 feat: bulk delete_subscription action — removes from bot DB + RemnaWave
Deactivates user in RemnaWave panel first, then deletes subscription
with related SubscriptionServer and TrafficPurchase records.
Subscription-level action (works with subscription_ids targeting).
2026-04-24 06:50:44 +03:00
Fringg be787a85bf feat: bulk set_devices action + device info in subscription list
- Add SET_DEVICES bulk action: sets device_limit on subscriptions,
  syncs to RemnaWave panel (subscription-level action)
- Add device_limit to SubscriptionListItem and BulkSubscriptionInfo
  schemas for frontend display
- Populate device_limit in _build_user_list_item and
  _build_subscription_info helpers
2026-04-24 06:35:29 +03:00
Fringg ff41ea9abb fix: suppress 'User already enabled' traceback in bulk add_traffic 2026-04-24 06:11:02 +03:00
Fringg cfbcc3082f fix: always return subscriptions list in user list API
subscriptions were only populated when is_multi_tariff_enabled()
was true. Users with multiple subscriptions in regular tariff mode
had empty subscriptions[] — bulk actions couldn't show or select them.
Now subscriptions are always populated regardless of tariff mode.
2026-04-24 06:08:23 +03:00
Fringg 2ad893badf fix: MissingGreenlet in subscription-ids bulk actions
_execute_for_subscription accessed user.subscriptions after commit,
triggering async lazy load → MissingGreenlet. Actions committed
successfully but reported as failed with cryptic error message.

Fix: use _build_subscription_info([sub]) with the already-loaded
targeted subscription instead of trying to lazy-load the full
user.subscriptions list.
2026-04-24 05:55:06 +03:00
Fringg e78177b2fc feat: multi-tariff bulk actions — subscription-level targeting
- Add subscription_ids to BulkExecuteRequest (mutually exclusive with
  user_ids via model_validator). Admins can now target specific
  subscriptions instead of auto-resolving the first active one.
- Add _execute_for_subscription dispatcher that loads subscription by
  ID, gets user, and passes sub_override to action handlers
- Add sub_override parameter to all 5 subscription-level handlers
  (extend, cancel, activate, change_tariff, add_traffic) — bypasses
  _resolve_subscription when a specific subscription is targeted
- Add SubscriptionListItem to UserListItem response — in multi-tariff
  mode, each user row includes all their subscriptions with tariff
  name, status, days remaining, traffic info
- User-level actions (add_balance, assign_promo_group, grant) reject
  subscription_ids with 400
- Add subscription_id field to BulkUserResult and SSE progress events
- Add _stream_bulk_execute_subscriptions SSE generator
- Backward compatible: existing user_ids requests work unchanged
2026-04-24 05:41:04 +03:00
Fringg daa472570c fix: add subscription/tariff/promo_group filters to admin user list API
The bulk actions page filters (subscription_status, tariff_id,
promo_group_id) were sent by the frontend but ignored by the
backend — the list_users endpoint had no such parameters.

- Add subscription_status, tariff_id, promo_group_id query params
  to GET /cabinet/admin/users
- Add subscription-level filtering via subquery in get_users_list
  and get_users_count CRUD functions
- Add tariff_id, tariff_name, traffic_used_gb, traffic_limit_gb,
  device_limit, days_remaining to UserListItem response schema
- Populate tariff info from subscription.tariff relationship in
  _build_user_list_item
2026-04-24 05:24:53 +03:00
Fringg db7b6734fd fix: bulk change_tariff not clearing squads when new tariff has none
When switching to a tariff with empty/null allowed_squads, the old
tariff's squads were preserved on the subscription. Now always sets
connected_squads from the new tariff (or empty list).
2026-04-24 05:10:47 +03:00
Fringg c0e0756b9a feat: bulk actions — SSE streaming progress, grant subscription, multi-tariff info
- Add SSE streaming mode (?stream=true): per-user progress events with
  real-time success/error counts, final summary event
- Add GRANT_SUBSCRIPTION action: creates new subscription with tariff,
  skips users who already have that tariff (multi-tariff aware),
  handles IntegrityError with graceful rollback
- Add BulkSubscriptionInfo: returns all user's subscriptions in each
  result for multi-tariff visibility
- Refactor: extract _validate_and_prepare and _execute_for_user helpers
  shared by streaming and non-streaming paths
2026-04-24 04:32:16 +03:00
Fringg 5b45d4354f fix: bulk actions review — rollback on error, multi-tariff constraint checks
- Add db.rollback() in per-user exception handler to prevent session
  poisoning (one failed commit would abort all subsequent users)
- Add multi-tariff duplicate subscription check in activate_subscription
  (prevents uq_subscriptions_user_tariff_active violation)
- Add multi-tariff duplicate subscription check in change_tariff
  (prevents switching to a tariff the user already holds)
2026-04-24 04:24:20 +03:00
Fringg fb2773fee4 feat: admin bulk actions API — mass operations on users
Add POST /cabinet/admin/bulk/execute endpoint for applying operations
to multiple users at once (up to 500 per request):

- extend_subscription / add_days: extend subscription by N days
- cancel_subscription: deactivate and expire subscriptions
- activate_subscription: reactivate expired subscriptions
- change_tariff: switch tariff without changing remaining days
- add_traffic: add extra GB to subscription traffic
- add_balance: credit balance with transaction record
- assign_promo_group: set or remove promo group for users

Features: dry_run preview mode, partial success handling (per-user
try/except), param validation before loop, auto-panel sync after
subscription mutations, multi-tariff mode support
2026-04-24 04:15:07 +03:00
Fringg ae7feeb726 fix: server squad sync fails on fresh DB without default promo group
On first startup with empty database, ensure_servers_synced() fetched
squads from RemnaWave but create_server_squad() failed with
ValueError('Server squad must be linked to at least one promo group')
because no default promo group existed yet.

Now _get_default_promo_group_id() auto-creates a default promo group
via _get_or_create_default_promo_group() when none exists, matching
the pattern used throughout the codebase for user registration.
2026-04-24 03:32:29 +03:00
Egor bcf5519880 Merge pull request #2904 from BEDOLAGA-DEV/dev
docs: add Overpay to README with partner block
2026-04-23 05:29:22 +03:00
Fringg 70568f82c5 docs: add Overpay to README with partner block 2026-04-23 05:25:49 +03:00
Egor 6fbd0bff28 Merge pull request #2903 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.51.0
2026-04-23 05:20:56 +03:00
github-actions[bot] dff3eb14a1 chore(main): release 3.51.0 2026-04-23 02:20:25 +00:00
Egor fab3d54f24 Merge pull request #2902 from BEDOLAGA-DEV/dev
Dev
2026-04-23 05:20:02 +03:00
Fringg 2c3ffc8c8a feat: integrate Overpay payment provider (pay.overpay.io)
Full integration across bot, cabinet, admin panel and landing pages:

- Config: 16 OVERPAY_* settings (API URL, credentials, P12 cert path,
  project ID, currency, amount limits, webhook path, payment methods)
- Service: overpay_service.py with httpx mTLS (P12 cert) + Basic Auth,
  create_payment, get_payment, refund_payment methods
- Payment mixin: OverpayPaymentMixin with create, webhook processing,
  finalize (balance credit + notifications), status check
- Model: OverpayPayment table + OVERPAY enum value
- CRUD: 9 standard operations (create, get_by_*, for_update, link)
- Bot handler: start_overpay_topup + process_overpay_payment_amount
- Handler registration: route_payment_by_method + callback registration
- Webhook: POST /overpay-webhook with DB-based anti-spoofing validation
- Cabinet: balance topup + guest payment dispatch
- Keyboard: payment method button in bot
- Admin: settings category + test payment button
- Infrastructure: payment_method_config_service, system_settings_service,
  payment_verification_service, payment_search_service
- Migration 0064: create overpay_payments table

Overpay API specifics: amount as string "100.00" (not kopeks),
success status "charged", HPP redirect via resultUrl
2026-04-23 04:36:56 +03:00
Fringg 6f87563789 fix: pad short RemnaWave usernames to meet 3-char minimum
RemnaWave API requires username >= 3 characters. Users with short
Telegram names (e.g. "Su") produced 2-char usernames that failed
validation, preventing subscription sync after payment.

Now format_remnawave_username pads short results with the user
identifier (telegram_id/email/user_id) to ensure minimum length.
2026-04-23 03:49:35 +03:00
Fringg 7005052156 fix: inactive user cleanup deletes users with paid subscriptions
The get_inactive_users query filtered only by last_activity (last bot
interaction), ignoring subscription end dates. Users who bought long
subscriptions (3-6-12 months) but didn't interact with the bot got
flagged as inactive and deleted+banned while their subscription was
still active or recently expired.

Fix: add SQL subquery excluding users who have ANY subscription with
end_date >= threshold_date. A user is now only deletable when BOTH
their last_activity AND their latest subscription end_date are older
than the configured inactivity period.
2026-04-23 03:40:45 +03:00
Fringg 29ae7089aa feat: respond to unknown media messages (photos, videos, documents)
Previously the bot only responded "Не понимаю эту команду" to text
messages. Photos, videos, documents and stickers sent outside of an
active FSM state were silently ignored, causing users to think their
media was received when it wasn't (e.g. support ticket screenshots
sent as separate messages).

Now the bot replies with the same "use menu buttons" message for any
unhandled media when no FSM state is active.
2026-04-23 03:31:21 +03:00
Egor 80e953a07b Merge pull request #2901 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.50.0
2026-04-22 06:18:07 +03:00
github-actions[bot] aebf58068f chore(main): release 3.50.0 2026-04-22 03:17:36 +00:00
Egor a491fe34bd Merge pull request #2900 from BEDOLAGA-DEV/dev
feat: v3.50.0 release
2026-04-22 06:17:09 +03:00
Egor 830e64afe0 Dev (#2899)
* fix: устранить MissingGreenlet в автоплатежах и починить traceback в логах

- subtract_user_balance: пишем promo_offer_log в отдельной сессии вместо rollback после commit, который экспайрил объекты основной сессии и ломал последующие обращения к subscription/user attrs
- monitoring_service._process_autopayments: перезагружаем subscription с eager-load user/tariff после списания, оборачиваем каждую итерацию в try/except + rollback, чтобы одна ошибка не валила весь батч
- logging_config: новый processor _auto_capture_exc_info автоматически подтягивает traceback из sys.exc_info() или error-kwarg → полный traceback в файле, консоли и Telegram без exc_info=True на каждом вызове
- logging_handler: дублирующая логика захвата exc_info в TelegramNotifierProcessor как резерв

* fix: устранить root cause MissingGreenlet в автоплатежах через refetch по id

Трейс показал: subscription.user падает на lazy-load → pool._checkout →
do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не
поддерживает sync-lazy-load для relationships. Причина рассинхрона:
lock_user_for_pricing делает populate_existing=True + selectinload(
User.subscriptions).selectinload(Subscription.tariff), что разгружает
Subscription.user backref для сестринских подписок того же user.
Последующее обращение sub.user у другой подписки падает.

Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию
делаем fresh refetch через async select с eager load user+tariff+
promo_group. Никаких lazy access в горячем пути. В except используем
локально захваченные id вместо getattr(subscription, ...), чтобы
логирование не падало каскадом на expired объекте.

* fix: grant all available squads for unrestricted trials (#2897)

* feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook (#2894)

* feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook

* style: ruff format main.py

---------

Co-authored-by: Dmitry Lunin <br@slack.ru>

* fix: do not update first_name/last_name from OIDC claims (#2892)

Co-authored-by: Dmitry Lunin <br@slack.ru>

* fix: do not reset subscription_crypto_link when cryptoLink absent in webhook (#2891)

Co-authored-by: Dmitry Lunin <br@slack.ru>

* fix: FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal

- balance/platega: re-set FSM state after min/max validation errors,
  set state before pending_amount path, use balance_topup callback for back button
- balance/main: set FSM state and payment_method in handle_topup_amount_callback
  for all providers before routing, use balance_topup callback in validation errors
- payment/paypear: fix confirmation_url key (was 'url'), add fallback,
  store charged amount with commission for correct webhook amount comparison
- tariff_purchase: redirect to active tariff list when current tariff is
  inactive (hidden trial after promo code activation)
- cabinet/renewal: check tariff.is_active in both GET and POST endpoints
  to prevent hidden trial tariff periods from appearing

* fix: tariff switch pricing showing free for upgrades, admin duplicate subscription guard

- pricing_engine: use shortest period for daily rate comparison instead
  of period closest to remaining_days — fixes incorrect free/zero cost
  for upgrades when tariffs have different period sets
- pricing_engine: remove unused target_days parameter from
  get_tariff_daily_rate_fraction
- admin_users: add duplicate subscription check before create,
  change_tariff and activate actions to prevent UniqueViolationError
  on uq_subscriptions_user_tariff_active constraint
- admin_users: add IntegrityError fallback on create as TOCTOU safety net

* feat: tariff switch direction control, fix device pricing within tariff limit

Tariff switch direction:
- Add TARIFF_SWITCH_UPGRADE_ENABLED and TARIFF_SWITCH_DOWNGRADE_ENABLED
  settings to control allowed switch directions
- Guard all 10 entry points: instant switch (list, preview, confirm),
  legacy switch (list, select, confirm, daily confirm), cabinet (preview,
  execute), purchase-options API
- Filter tariff lists by allowed direction, show "unavailable" when
  both directions disabled
- Expose settings in cabinet purchase-options response for frontend

Device pricing fix:
- Devices within tariff.device_limit are now free when restoring
  (was charging for all devices regardless of tariff inclusion)
- Fix max(100, price) minimum enforcing 1 RUB even when
  chargeable_devices is 0
- Apply fix across all endpoints: bot handlers (confirm_change,
  execute_change, confirm_add), cabinet API (legacy purchase,
  modern purchase, get-price, save-cart), inline keyboard display

* fix: classic mode renewal resets device_limit to 1 via cart key mismatch

- Fix cart key mismatch: extend cart saved 'device_limit' but
  confirm_purchase read 'devices' key, falling back to DEFAULT=1.
  Now both keys are saved in both cart-save paths
- Fix confirm_purchase device resolution: use explicit is None checks
  instead of or-chain to avoid falsy-zero trap
- Fix return_to_saved_cart display: fall back to 'device_limit' and
  'traffic_limit_gb' keys when 'devices'/'traffic_gb' are absent
- Fix second cart-save path in _extend_existing_subscription with
  same dual-key pattern
- Fix RemnaWaveService import path in renewal service
- Add RESET_DEVICES_ON_RENEWAL setting: resets all connected devices
  (hwid) via RemnaWave API on each subscription renewal

* fix: menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports

- Increase icon max_length from 10 to 100 in all three schemas
  (MenuButtonConfig, ButtonUpdateRequest, AddCustomButtonRequest)
  to support Telegram Custom Emoji IDs
- Add traffic_topup_enabled condition to ButtonConditions schema
- Remove shadowing local imports of MenuLayoutService in
  routes/menu_layout.py (top-level import already provides access)

* feat(tickets): multi-media message gallery (media_items JSONB)

- Add media_items JSONB column to TicketMessage model for multi-media
  gallery support (photos/videos/documents in one bubble)
- Add TicketMediaItem schema with type validation and shared
  _validate_media_bundle helper (max 10 items, legacy field compat)
- Update admin and user ticket handlers to store media_items and
  back-fill legacy media_type/media_file_id/media_caption from first
  item for backward compatibility
- Update _message_to_response in both admin and user routes to include
  media_items in API responses
- Allow empty message text when media is attached (message field now
  defaults to empty string with model validator ensuring text or media)
- Add migration 0061 with idempotent column check

Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)

* fix: ticket media_items review fixes

- Add if has_media else None guards in user-side ticket handlers
  (create_ticket, add_message) matching admin handler pattern
- Fix Telegram notification using resolved primary_file_id/primary_type
  instead of raw request fields for gallery messages
- Narrow except Exception to (TypeError, KeyError, ValueError) in
  _message_to_response with warning log for debugging
- Add media_items parameter to TicketCRUD.create_ticket and
  TicketCRUD.add_message for CRUD layer parity
- Add TicketMediaItemResponse and media_items field to webapi
  TicketMessageResponse to prevent data loss on read

* feat: landing page analytics goals and sticky pay button

- Add sticky_pay_button, analytics_view_enabled, analytics_view_goal,
  analytics_click_enabled, analytics_click_goal columns to LandingPage
- Add fields to CRUD updatable fields, admin create/update/detail
  schemas, create_landing() kwargs, _landing_to_detail() response
- Expose sticky_pay_button and analytics fields in public landing
  config response for frontend Yandex Metrika integration
- Add migration 0062 with idempotent column checks

Based on PR #2852 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)

* fix: validate analytics goal is set when analytics is enabled on landing

Prevent enabling analytics_view/click without providing the
corresponding goal identifier, which would result in empty
Yandex Metrika calls on the frontend.

* feat: Yandex Metrika offline conversions + S2S postbacks

- Add YandexClientIdMap model for user → yandex_cid mapping with
  upsert-safe CRUD (ON CONFLICT DO UPDATE)
- Add yandex_cid, subid, referrer columns to GuestPurchase
- Add yandex_offline_conv_service: Measurement Protocol integration
  with mc.yandex.ru/collect (registration, trial, purchase events),
  background task management, CID parsing from /start params
- Add s2s_postback_service: server-to-server affiliate postbacks
  with URL template placeholders and URL-safe encoding
- Add analytics offline conversion info to branding API (masked secret)
- Add POST /analytics/yandex-cid endpoint for cabinet CID capture
- Add 11 config settings (YANDEX_OFFLINE_CONV_*, S2S_POSTBACK_*)
- Add migration 0063 (yandex_client_id_map table + guest_purchases cols)
- Fix: mask measurement secret aggressively (show only last 4 chars)
- Fix: always replace {user_id} placeholder in S2S postback URLs
- Fix: use structlog kwargs instead of f-strings with LOG_PREFIX

Based on PR #2851 by @smediainfo — CI/CD workflow changes excluded

---------

Co-authored-by: c0mrade <killmy666@gmail.com>
Co-authored-by: Danila Yudin <danyayudin2012@gmail.com>
Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com>
Co-authored-by: Dmitry Lunin <br@slack.ru>
2026-04-22 06:08:26 +03:00
Fringg 1068c1387a feat: Yandex Metrika offline conversions + S2S postbacks
- Add YandexClientIdMap model for user → yandex_cid mapping with
  upsert-safe CRUD (ON CONFLICT DO UPDATE)
- Add yandex_cid, subid, referrer columns to GuestPurchase
- Add yandex_offline_conv_service: Measurement Protocol integration
  with mc.yandex.ru/collect (registration, trial, purchase events),
  background task management, CID parsing from /start params
- Add s2s_postback_service: server-to-server affiliate postbacks
  with URL template placeholders and URL-safe encoding
- Add analytics offline conversion info to branding API (masked secret)
- Add POST /analytics/yandex-cid endpoint for cabinet CID capture
- Add 11 config settings (YANDEX_OFFLINE_CONV_*, S2S_POSTBACK_*)
- Add migration 0063 (yandex_client_id_map table + guest_purchases cols)
- Fix: mask measurement secret aggressively (show only last 4 chars)
- Fix: always replace {user_id} placeholder in S2S postback URLs
- Fix: use structlog kwargs instead of f-strings with LOG_PREFIX

Based on PR #2851 by @smediainfo — CI/CD workflow changes excluded
2026-04-22 05:57:46 +03:00
Fringg d31632534b fix: validate analytics goal is set when analytics is enabled on landing
Prevent enabling analytics_view/click without providing the
corresponding goal identifier, which would result in empty
Yandex Metrika calls on the frontend.
2026-04-22 05:43:54 +03:00
Fringg 3272b4bb05 feat: landing page analytics goals and sticky pay button
- Add sticky_pay_button, analytics_view_enabled, analytics_view_goal,
  analytics_click_enabled, analytics_click_goal columns to LandingPage
- Add fields to CRUD updatable fields, admin create/update/detail
  schemas, create_landing() kwargs, _landing_to_detail() response
- Expose sticky_pay_button and analytics fields in public landing
  config response for frontend Yandex Metrika integration
- Add migration 0062 with idempotent column checks

Based on PR #2852 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)
2026-04-22 05:36:41 +03:00
Fringg dd177101f7 fix: ticket media_items review fixes
- Add if has_media else None guards in user-side ticket handlers
  (create_ticket, add_message) matching admin handler pattern
- Fix Telegram notification using resolved primary_file_id/primary_type
  instead of raw request fields for gallery messages
- Narrow except Exception to (TypeError, KeyError, ValueError) in
  _message_to_response with warning log for debugging
- Add media_items parameter to TicketCRUD.create_ticket and
  TicketCRUD.add_message for CRUD layer parity
- Add TicketMediaItemResponse and media_items field to webapi
  TicketMessageResponse to prevent data loss on read
2026-04-22 05:29:51 +03:00
Fringg 36571c4275 feat(tickets): multi-media message gallery (media_items JSONB)
- Add media_items JSONB column to TicketMessage model for multi-media
  gallery support (photos/videos/documents in one bubble)
- Add TicketMediaItem schema with type validation and shared
  _validate_media_bundle helper (max 10 items, legacy field compat)
- Update admin and user ticket handlers to store media_items and
  back-fill legacy media_type/media_file_id/media_caption from first
  item for backward compatibility
- Update _message_to_response in both admin and user routes to include
  media_items in API responses
- Allow empty message text when media is attached (message field now
  defaults to empty string with model validator ensuring text or media)
- Add migration 0061 with idempotent column check

Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)
2026-04-22 05:23:48 +03:00
Fringg 66f8577448 fix: menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports
- Increase icon max_length from 10 to 100 in all three schemas
  (MenuButtonConfig, ButtonUpdateRequest, AddCustomButtonRequest)
  to support Telegram Custom Emoji IDs
- Add traffic_topup_enabled condition to ButtonConditions schema
- Remove shadowing local imports of MenuLayoutService in
  routes/menu_layout.py (top-level import already provides access)
2026-04-22 05:17:56 +03:00
Fringg 9ca3320a02 fix: classic mode renewal resets device_limit to 1 via cart key mismatch
- Fix cart key mismatch: extend cart saved 'device_limit' but
  confirm_purchase read 'devices' key, falling back to DEFAULT=1.
  Now both keys are saved in both cart-save paths
- Fix confirm_purchase device resolution: use explicit is None checks
  instead of or-chain to avoid falsy-zero trap
- Fix return_to_saved_cart display: fall back to 'device_limit' and
  'traffic_limit_gb' keys when 'devices'/'traffic_gb' are absent
- Fix second cart-save path in _extend_existing_subscription with
  same dual-key pattern
- Fix RemnaWaveService import path in renewal service
- Add RESET_DEVICES_ON_RENEWAL setting: resets all connected devices
  (hwid) via RemnaWave API on each subscription renewal
2026-04-22 05:12:27 +03:00
Fringg 9ed4f086b0 feat: tariff switch direction control, fix device pricing within tariff limit
Tariff switch direction:
- Add TARIFF_SWITCH_UPGRADE_ENABLED and TARIFF_SWITCH_DOWNGRADE_ENABLED
  settings to control allowed switch directions
- Guard all 10 entry points: instant switch (list, preview, confirm),
  legacy switch (list, select, confirm, daily confirm), cabinet (preview,
  execute), purchase-options API
- Filter tariff lists by allowed direction, show "unavailable" when
  both directions disabled
- Expose settings in cabinet purchase-options response for frontend

Device pricing fix:
- Devices within tariff.device_limit are now free when restoring
  (was charging for all devices regardless of tariff inclusion)
- Fix max(100, price) minimum enforcing 1 RUB even when
  chargeable_devices is 0
- Apply fix across all endpoints: bot handlers (confirm_change,
  execute_change, confirm_add), cabinet API (legacy purchase,
  modern purchase, get-price, save-cart), inline keyboard display
2026-04-22 04:54:17 +03:00
Fringg da855a7c89 fix: tariff switch pricing showing free for upgrades, admin duplicate subscription guard
- pricing_engine: use shortest period for daily rate comparison instead
  of period closest to remaining_days — fixes incorrect free/zero cost
  for upgrades when tariffs have different period sets
- pricing_engine: remove unused target_days parameter from
  get_tariff_daily_rate_fraction
- admin_users: add duplicate subscription check before create,
  change_tariff and activate actions to prevent UniqueViolationError
  on uq_subscriptions_user_tariff_active constraint
- admin_users: add IntegrityError fallback on create as TOCTOU safety net
2026-04-22 04:25:42 +03:00
Fringg 7be404b918 fix: FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal
- balance/platega: re-set FSM state after min/max validation errors,
  set state before pending_amount path, use balance_topup callback for back button
- balance/main: set FSM state and payment_method in handle_topup_amount_callback
  for all providers before routing, use balance_topup callback in validation errors
- payment/paypear: fix confirmation_url key (was 'url'), add fallback,
  store charged amount with commission for correct webhook amount comparison
- tariff_purchase: redirect to active tariff list when current tariff is
  inactive (hidden trial after promo code activation)
- cabinet/renewal: check tariff.is_active in both GET and POST endpoints
  to prevent hidden trial tariff periods from appearing
2026-04-22 04:05:46 +03:00
Dmitry V. Lunin b71e58c8d2 fix: do not reset subscription_crypto_link when cryptoLink absent in webhook (#2891)
Co-authored-by: Dmitry Lunin <br@slack.ru>
2026-04-21 08:05:11 +03:00
Dmitry V. Lunin 1696e6f884 fix: do not update first_name/last_name from OIDC claims (#2892)
Co-authored-by: Dmitry Lunin <br@slack.ru>
2026-04-21 08:04:00 +03:00
Dmitry V. Lunin 7093d368d3 feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook (#2894)
* feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook

* style: ruff format main.py

---------

Co-authored-by: Dmitry Lunin <br@slack.ru>
2026-04-21 08:02:53 +03:00
Danila Yudin 905cea68b4 fix: grant all available squads for unrestricted trials (#2897) 2026-04-21 08:01:28 +03:00
Egor dc5442223d Merge pull request #2898 from BEDOLAGA-DEV/main
w
2026-04-21 07:53:33 +03:00
c0mrade 3b03c253cc fix: устранить root cause MissingGreenlet в автоплатежах через refetch по id
Трейс показал: subscription.user падает на lazy-load → pool._checkout →
do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не
поддерживает sync-lazy-load для relationships. Причина рассинхрона:
lock_user_for_pricing делает populate_existing=True + selectinload(
User.subscriptions).selectinload(Subscription.tariff), что разгружает
Subscription.user backref для сестринских подписок того же user.
Последующее обращение sub.user у другой подписки падает.

Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию
делаем fresh refetch через async select с eager load user+tariff+
promo_group. Никаких lazy access в горячем пути. В except используем
локально захваченные id вместо getattr(subscription, ...), чтобы
логирование не падало каскадом на expired объекте.
2026-04-19 12:08:34 +03:00
c0mrade db79cc9eb0 fix: устранить MissingGreenlet в автоплатежах и починить traceback в логах
- subtract_user_balance: пишем promo_offer_log в отдельной сессии вместо rollback после commit, который экспайрил объекты основной сессии и ломал последующие обращения к subscription/user attrs
- monitoring_service._process_autopayments: перезагружаем subscription с eager-load user/tariff после списания, оборачиваем каждую итерацию в try/except + rollback, чтобы одна ошибка не валила весь батч
- logging_config: новый processor _auto_capture_exc_info автоматически подтягивает traceback из sys.exc_info() или error-kwarg → полный traceback в файле, консоли и Telegram без exc_info=True на каждом вызове
- logging_handler: дублирующая логика захвата exc_info в TelegramNotifierProcessor как резерв
2026-04-19 11:50:40 +03:00
Egor 1b94d9e700 Merge pull request #2890 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.49.0
2026-04-18 04:00:56 +03:00
github-actions[bot] 39a7c92cd4 chore(main): release 3.49.0 2026-04-18 00:58:34 +00:00
Egor 81ebec676c Merge pull request #2889 from BEDOLAGA-DEV/dev
Dev
2026-04-18 03:58:10 +03:00
Fringg 25ea5c60fd docs: add AuraPay to README with partner block 2026-04-18 03:56:48 +03:00
Fringg 29877fc93b fix: handle edge case when all tariffs are daily in legacy renewal 2026-04-18 01:09:53 +03:00
Fringg 5986c00fab fix: redirect legacy users without tariff to tariff selection on renewal
Users created before tariffs were introduced (tariff_id=NULL) got
"Тариф не найден" when pressing "Продлить подписку". Now they see
a tariff selection list instead, allowing them to pick a tariff
and renew with proper parameters (traffic, devices, etc).
2026-04-18 01:05:48 +03:00
Fringg ecc4a6147d fix: rate-limit daily subscription insufficient balance notifications to 6 hours
Users with daily subscriptions and low balance were getting
"Подписка приостановлена" notification every 30 minutes (on each
charge cycle). Now rate-limited via Redis cache to max 1 notification
per 6 hours per subscription.
2026-04-18 00:59:09 +03:00
Fringg 16bc1d4198 fix: align campaign top registrations revenue with period comparison
Top registrations list was summing DEPOSIT + SUBSCRIPTION_PAYMENT
transactions (total user spending), while period comparison revenue
only counted DEPOSIT with real payment methods (actual money paid in).

Now both use the same calculation: only DEPOSIT transactions with
real payment methods. This fixes the discrepancy where a user showed
500₽ in the list but total revenue was 250₽.
2026-04-18 00:50:23 +03:00
Fringg 0f814be1b7 fix: add missing RollyPay CRUD wrappers and guest payment flow
RollyPay was missing:
- 7 CRUD wrapper functions in payment_service.py (create, get, update, link)
- Guest payment block in create_guest_payment()

Both were present for PayPear and AuraPay but omitted for RollyPay.
2026-04-18 00:23:38 +03:00
Fringg 97179360c0 feat: integrate AuraPay payment provider
Full integration following PayPear/RollyPay patterns:
- API client with X-ApiKey + X-ShopId auth, HMAC-SHA256 webhook verification
  (sorted keys, concatenated values, secret key #2)
- AuraPayPaymentMixin with create, webhook, finalize (all side effects), status check
- CRUD, model, migration (0060), PaymentMethod.AURAPAY enum
- Webhook endpoint, cabinet topup, bot handler with sub-options (card, sbp)
- Admin panel: AURAPAY category in bot_configuration + system_settings_service
- Config: AURAPAY_ENABLED, AURAPAY_API_KEY, AURAPAY_SHOP_ID, AURAPAY_SECRET_KEY
2026-04-18 00:02:49 +03:00
Fringg 2aa5927433 fix: register PayPear and RollyPay in admin panel settings
- bot_configuration.py: added PAYPEAR/ROLLYPAY to payment categories
  and test payment buttons
- system_settings_service.py: added category titles, descriptions,
  and prefix mappings for PAYPEAR_* and ROLLYPAY_* settings
2026-04-17 23:44:35 +03:00
Egor 1c696c69e3 Merge pull request #2887 from BEDOLAGA-DEV/dev
docs: add PayPear and RollyPay to README with partner blocks
2026-04-16 06:26:19 +03:00
Fringg b531959982 docs: add PayPear and RollyPay to README with partner blocks 2026-04-16 06:25:33 +03:00
Egor ead0fc99d3 Merge pull request #2886 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.48.0
2026-04-16 06:11:21 +03:00
github-actions[bot] c09ae7436c chore(main): release 3.48.0 2026-04-16 03:10:42 +00:00
Egor 1ea76575ce Merge pull request #2885 from BEDOLAGA-DEV/dev
Dev
2026-04-16 06:10:19 +03:00
Fringg 25447edc9e docs: add SEVERPAY, PAYPEAR, ROLLYPAY to .env.example 2026-04-16 06:08:10 +03:00
Fringg a59858227f fix: support payment_method selection for RollyPay (sbp/card/crypto)
- Mixin accepts payment_method_type parameter (None = show all on form)
- API service sends payment_method only when specified
- Cabinet route passes payment_option to mixin
- Config service has sub_options: sbp, card, crypto
- Keyboard label no longer hardcodes "СБП"
2026-04-16 06:03:33 +03:00
Fringg ccc2f4efec feat: integrate RollyPay payment provider (SBP via USDT)
Full integration following PayPear/SeverPay patterns:
- API client with X-API-Key + X-Nonce auth, HMAC-SHA256 webhook verification
- RollyPayPaymentMixin with create, webhook, finalize (all side effects), status check
- CRUD, model, migration (0059), PaymentMethod.ROLLYPAY enum
- Webhook endpoint, cabinet topup, bot handler
- Statuses: created, processing, paid, expired, canceled, chargeback
- Config: ROLLYPAY_ENABLED, ROLLYPAY_API_KEY, ROLLYPAY_SIGNING_SECRET, etc.
2026-04-16 05:52:25 +03:00
Fringg a18f6caa9b feat: integrate PayPear payment provider
Full integration following SeverPay patterns:
- API client with Basic Auth, idempotency, HMAC webhook verification
- PayPearPaymentMixin with create, webhook, finalize (all side effects), status check
- CRUD, model, migration (0058), PaymentMethod.PAYPEAR enum
- Webhook endpoint, cabinet topup, bot handler, payment verification
- Sub-options: bank_card, sbp, sberpay, tpay
- Config: PAYPEAR_ENABLED, PAYPEAR_SHOP_ID, PAYPEAR_SECRET_KEY, etc.
2026-04-16 05:36:48 +03:00
Fringg 92eaf45311 fix: increase nalogo receipt queue retry window to 12 hours
Changed defaults from 10 attempts × 5min (50min total) to
72 attempts × 10min (12 hours total). When nalog.ru is temporarily
down, receipts now retry for 12 hours before being dropped,
giving the service enough time to recover.
2026-04-16 04:58:54 +03:00
Fringg 61cf495fc5 fix: show menu buttons for limited subscriptions in back-to-menu paths
The previous fix only covered /start command paths. The more common
show_main_menu and handle_back_to_menu in menu.py used their own
is_active check which returned False for limited status.

Now both paths treat limited subscriptions as active for UI, matching
the _calculate_subscription_flags fix in start.py.
2026-04-16 04:54:57 +03:00
Fringg 0c545490b6 fix: show menu buttons for limited (traffic exhausted) subscriptions
When Remnawave webhook set subscription status to 'limited' (traffic
exhausted), the main menu hid ALL buttons (connect, subscription,
buy traffic) because is_active returns False for non-'active' status.

Now 'limited' is treated as active for UI purposes — the subscription
is not expired, just traffic-exhausted. Users can see the "Buy traffic"
button precisely when they need it most.
2026-04-16 04:50:13 +03:00
Fringg 2d5afe5d75 fix: low balance alerts disabled by default, add quiet hours, expiry filter, top-up button
- Default balance_low_enabled changed to False (opt-in via cabinet)
- Quiet hours: alerts skipped between 22:00-09:00 UTC
- Only alerts when subscription expires within LOW_BALANCE_ALERT_EXPIRY_DAYS (default 3)
- Added inline "Top up" button linking to cabinet miniapp
- Synced defaults across notification_prefs, cabinet notifications route
2026-04-16 04:42:36 +03:00
Egor 50dc5a0fd1 Merge pull request #2882 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.47.0
2026-04-15 14:12:24 +03:00
github-actions[bot] 4dc8b4c091 chore(main): release 3.47.0 2026-04-15 11:10:30 +00:00
Egor 4db9e85062 feat: multi-tariff sync fix, daily discount fix, campaign links, TELEGRAM_API_URL
* fix: update subscription_crypto_link when syncing user from panel (#2867)

* fix: update subscription_crypto_link when syncing user from panel

* fix: update subscription_crypto_link when syncing user from panel

* fix: use PROXY_URL for Telegram OIDC JWKS requests (#2866)

* feat: add TELEGRAM_API_URL for custom Telegram Bot API server

Support custom Telegram Bot API server URL via TELEGRAM_API_URL env var.
Enables bot operation in regions where api.telegram.org is blocked
(Cloudflare Worker, self-hosted telegram-bot-api, nginx reverse proxy).
Uses native aiogram TelegramAPIServer.from_base(), works with PROXY_URL.

* fix(ci): read Docker image version from release-please manifest instead of hardcoding (#2859)

The x-release-please-version markers in workflow files were stuck at v3.7.0
since commit 5070bb34 removed them from extra-files (GitHub Actions returns
403 when release-please tries to modify .github/workflows/ via GITHUB_TOKEN).

Instead of hardcoding the version, read it from .release-please-manifest.json
at build time. This file is always kept in sync by release-please and does
not require workflow file write permissions.

---

Маркеры x-release-please-version в workflow-файлах застряли на v3.7.0
после коммита 5070bb34, который удалил их из extra-files (GitHub Actions
возвращает 403 при попытке release-please изменить .github/workflows/
через GITHUB_TOKEN).

Вместо хардкода версии теперь читаем её из .release-please-manifest.json
во время сборки. Этот файл всегда синхронизируется release-please и не
требует прав на запись в workflow-файлы.

* fix: format telegram_auth.py to use single quotes (ruff)

* fix: remove daily tariff fallback to smallest period discount

Daily tariffs (period_days=1) incorrectly inherited the discount
of the smallest configured period (e.g. 90 days -> 5%). This caused
daily prices to show discounts that were never intended for them.

Now daily tariffs only get a discount if explicitly configured for
period_days=1 in the promo group's period_discounts.

* fix: use CABINET_URL for campaign web links instead of MINIAPP_CUSTOM_URL

Campaign web links were generated from MINIAPP_CUSTOM_URL which is often
empty, causing get_campaign_web_link() to return None. Admins and partners
could only share bot links for campaigns, not cabinet links.

Now prefers CABINET_URL (where the auth flow captures ?campaign= param),
falling back to MINIAPP_CUSTOM_URL for backwards compatibility. This is
consistent with how referral web links already use CABINET_URL.

* feat: add DISPLAY_NAME_RESTRICTION_ENABLED toggle

Allows disabling the display name restriction middleware via .env.
Users with special characters in their Telegram name (e.g. "@")
were blocked from using the bot entirely. Default: true (enabled).

Set DISPLAY_NAME_RESTRICTION_ENABLED=false to disable.

* fix: allow clearing all period discounts from promo groups

Empty period_discounts dict was normalized to None by the schema,
making it indistinguishable from "field absent" (don't update).
Now empty dict passes through to CRUD which correctly sets
period_discounts=None in DB, clearing all discounts.

* fix: create panel user instead of update for new subscriptions in multi-tariff mode

In multi-tariff mode, new subscriptions have remnawave_uuid=None.
The old logic fell back to user.remnawave_uuid (from a previous
subscription) and called update_remnawave_user(), which refused
to work because the NEW subscription had no UUID.

Now correctly: in multi-tariff mode, always CREATE if subscription
has no remnawave_uuid. In single-tariff mode, use user-level UUID.

Fixes: "subscription has no remnawave_uuid, cannot update panel"

* fix: apply same create-vs-update fix to renewal and purchase flows

Same bug as the tariff purchase fix: in multi-tariff mode, new
subscriptions without remnawave_uuid incorrectly fell back to
user.remnawave_uuid and called update instead of create.

Fixed in subscription_renewal_service.py and purchase.py to use
the same _should_create pattern based on mode.

* fix: apply create-vs-update fix to all remaining tariff_purchase flows

Fixed 6 more locations in tariff_purchase.py that had the same broken
pattern (custom purchase, daily purchase, trial conversion, tariff
switch, daily switch, instant switch). All now use _should_create
based on multi-tariff mode instead of falling back to user UUID.

* fix: apply create-vs-update fix to cabinet traffic/devices and monitoring

Same multi-tariff create-vs-update bug in 5 more locations:
- cabinet/subscription_modules/traffic.py (2 instances)
- cabinet/subscription_modules/devices.py (2 instances)
- services/monitoring_service.py (1 instance)

All now use _should_create pattern based on subscription.remnawave_uuid
in multi-tariff mode instead of falling back to user.remnawave_uuid.

* fix: ruff format traffic.py and monitoring_service.py

---------

Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com>
Co-authored-by: Gary Jarrel <gary@jarrel.com.au>
2026-04-15 14:10:05 +03:00
Egor fca8d6da97 Dev (#2880)
* fix: update subscription_crypto_link when syncing user from panel (#2867)

* fix: update subscription_crypto_link when syncing user from panel

* fix: update subscription_crypto_link when syncing user from panel

* fix: use PROXY_URL for Telegram OIDC JWKS requests (#2866)

* feat: add TELEGRAM_API_URL for custom Telegram Bot API server

Support custom Telegram Bot API server URL via TELEGRAM_API_URL env var.
Enables bot operation in regions where api.telegram.org is blocked
(Cloudflare Worker, self-hosted telegram-bot-api, nginx reverse proxy).
Uses native aiogram TelegramAPIServer.from_base(), works with PROXY_URL.

* fix(ci): read Docker image version from release-please manifest instead of hardcoding (#2859)

The x-release-please-version markers in workflow files were stuck at v3.7.0
since commit 5070bb34 removed them from extra-files (GitHub Actions returns
403 when release-please tries to modify .github/workflows/ via GITHUB_TOKEN).

Instead of hardcoding the version, read it from .release-please-manifest.json
at build time. This file is always kept in sync by release-please and does
not require workflow file write permissions.

---

Маркеры x-release-please-version в workflow-файлах застряли на v3.7.0
после коммита 5070bb34, который удалил их из extra-files (GitHub Actions
возвращает 403 при попытке release-please изменить .github/workflows/
через GITHUB_TOKEN).

Вместо хардкода версии теперь читаем её из .release-please-manifest.json
во время сборки. Этот файл всегда синхронизируется release-please и не
требует прав на запись в workflow-файлы.

* fix: format telegram_auth.py to use single quotes (ruff)

* fix: remove daily tariff fallback to smallest period discount

Daily tariffs (period_days=1) incorrectly inherited the discount
of the smallest configured period (e.g. 90 days -> 5%). This caused
daily prices to show discounts that were never intended for them.

Now daily tariffs only get a discount if explicitly configured for
period_days=1 in the promo group's period_discounts.

* fix: use CABINET_URL for campaign web links instead of MINIAPP_CUSTOM_URL

Campaign web links were generated from MINIAPP_CUSTOM_URL which is often
empty, causing get_campaign_web_link() to return None. Admins and partners
could only share bot links for campaigns, not cabinet links.

Now prefers CABINET_URL (where the auth flow captures ?campaign= param),
falling back to MINIAPP_CUSTOM_URL for backwards compatibility. This is
consistent with how referral web links already use CABINET_URL.

* feat: add DISPLAY_NAME_RESTRICTION_ENABLED toggle

Allows disabling the display name restriction middleware via .env.
Users with special characters in their Telegram name (e.g. "@")
were blocked from using the bot entirely. Default: true (enabled).

Set DISPLAY_NAME_RESTRICTION_ENABLED=false to disable.

* fix: allow clearing all period discounts from promo groups

Empty period_discounts dict was normalized to None by the schema,
making it indistinguishable from "field absent" (don't update).
Now empty dict passes through to CRUD which correctly sets
period_discounts=None in DB, clearing all discounts.

* fix: create panel user instead of update for new subscriptions in multi-tariff mode

In multi-tariff mode, new subscriptions have remnawave_uuid=None.
The old logic fell back to user.remnawave_uuid (from a previous
subscription) and called update_remnawave_user(), which refused
to work because the NEW subscription had no UUID.

Now correctly: in multi-tariff mode, always CREATE if subscription
has no remnawave_uuid. In single-tariff mode, use user-level UUID.

Fixes: "subscription has no remnawave_uuid, cannot update panel"

* fix: apply same create-vs-update fix to renewal and purchase flows

Same bug as the tariff purchase fix: in multi-tariff mode, new
subscriptions without remnawave_uuid incorrectly fell back to
user.remnawave_uuid and called update instead of create.

Fixed in subscription_renewal_service.py and purchase.py to use
the same _should_create pattern based on mode.

* fix: apply create-vs-update fix to all remaining tariff_purchase flows

Fixed 6 more locations in tariff_purchase.py that had the same broken
pattern (custom purchase, daily purchase, trial conversion, tariff
switch, daily switch, instant switch). All now use _should_create
based on multi-tariff mode instead of falling back to user UUID.

* fix: apply create-vs-update fix to cabinet traffic/devices and monitoring

Same multi-tariff create-vs-update bug in 5 more locations:
- cabinet/subscription_modules/traffic.py (2 instances)
- cabinet/subscription_modules/devices.py (2 instances)
- services/monitoring_service.py (1 instance)

All now use _should_create pattern based on subscription.remnawave_uuid
in multi-tariff mode instead of falling back to user.remnawave_uuid.

* fix: ruff format traffic.py and monitoring_service.py

---------

Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com>
Co-authored-by: Gary Jarrel <gary@jarrel.com.au>
2026-04-15 14:04:16 +03:00
Fringg 4fe67a9c74 fix: ruff format traffic.py and monitoring_service.py 2026-04-15 13:57:15 +03:00
Fringg 30a1a31978 fix: apply create-vs-update fix to cabinet traffic/devices and monitoring
Same multi-tariff create-vs-update bug in 5 more locations:
- cabinet/subscription_modules/traffic.py (2 instances)
- cabinet/subscription_modules/devices.py (2 instances)
- services/monitoring_service.py (1 instance)

All now use _should_create pattern based on subscription.remnawave_uuid
in multi-tariff mode instead of falling back to user.remnawave_uuid.
2026-04-15 13:28:06 +03:00
Fringg c2b68e1afa fix: apply create-vs-update fix to all remaining tariff_purchase flows
Fixed 6 more locations in tariff_purchase.py that had the same broken
pattern (custom purchase, daily purchase, trial conversion, tariff
switch, daily switch, instant switch). All now use _should_create
based on multi-tariff mode instead of falling back to user UUID.
2026-04-15 13:24:07 +03:00
Fringg cea7260a85 fix: apply same create-vs-update fix to renewal and purchase flows
Same bug as the tariff purchase fix: in multi-tariff mode, new
subscriptions without remnawave_uuid incorrectly fell back to
user.remnawave_uuid and called update instead of create.

Fixed in subscription_renewal_service.py and purchase.py to use
the same _should_create pattern based on mode.
2026-04-15 13:17:35 +03:00
Fringg e3c0caabcf fix: create panel user instead of update for new subscriptions in multi-tariff mode
In multi-tariff mode, new subscriptions have remnawave_uuid=None.
The old logic fell back to user.remnawave_uuid (from a previous
subscription) and called update_remnawave_user(), which refused
to work because the NEW subscription had no UUID.

Now correctly: in multi-tariff mode, always CREATE if subscription
has no remnawave_uuid. In single-tariff mode, use user-level UUID.

Fixes: "subscription has no remnawave_uuid, cannot update panel"
2026-04-15 13:14:10 +03:00
Fringg aeaa4f8e0d fix: allow clearing all period discounts from promo groups
Empty period_discounts dict was normalized to None by the schema,
making it indistinguishable from "field absent" (don't update).
Now empty dict passes through to CRUD which correctly sets
period_discounts=None in DB, clearing all discounts.
2026-04-15 04:13:43 +03:00
Fringg e226ac8637 feat: add DISPLAY_NAME_RESTRICTION_ENABLED toggle
Allows disabling the display name restriction middleware via .env.
Users with special characters in their Telegram name (e.g. "@")
were blocked from using the bot entirely. Default: true (enabled).

Set DISPLAY_NAME_RESTRICTION_ENABLED=false to disable.
2026-04-15 04:03:18 +03:00
Fringg 85403da528 fix: use CABINET_URL for campaign web links instead of MINIAPP_CUSTOM_URL
Campaign web links were generated from MINIAPP_CUSTOM_URL which is often
empty, causing get_campaign_web_link() to return None. Admins and partners
could only share bot links for campaigns, not cabinet links.

Now prefers CABINET_URL (where the auth flow captures ?campaign= param),
falling back to MINIAPP_CUSTOM_URL for backwards compatibility. This is
consistent with how referral web links already use CABINET_URL.
2026-04-15 03:56:34 +03:00
Fringg bdd873382c fix: remove daily tariff fallback to smallest period discount
Daily tariffs (period_days=1) incorrectly inherited the discount
of the smallest configured period (e.g. 90 days -> 5%). This caused
daily prices to show discounts that were never intended for them.

Now daily tariffs only get a discount if explicitly configured for
period_days=1 in the promo group's period_discounts.
2026-04-15 03:39:52 +03:00
Fringg 9d750d9eb0 fix: format telegram_auth.py to use single quotes (ruff) 2026-04-15 03:34:49 +03:00
Gary Jarrel 87b83f59c6 fix(ci): read Docker image version from release-please manifest instead of hardcoding (#2859)
The x-release-please-version markers in workflow files were stuck at v3.7.0
since commit 5070bb34 removed them from extra-files (GitHub Actions returns
403 when release-please tries to modify .github/workflows/ via GITHUB_TOKEN).

Instead of hardcoding the version, read it from .release-please-manifest.json
at build time. This file is always kept in sync by release-please and does
not require workflow file write permissions.

---

Маркеры x-release-please-version в workflow-файлах застряли на v3.7.0
после коммита 5070bb34, который удалил их из extra-files (GitHub Actions
возвращает 403 при попытке release-please изменить .github/workflows/
через GITHUB_TOKEN).

Вместо хардкода версии теперь читаем её из .release-please-manifest.json
во время сборки. Этот файл всегда синхронизируется release-please и не
требует прав на запись в workflow-файлы.
2026-04-15 03:15:35 +03:00
Fringg 8ff6f99229 feat: add TELEGRAM_API_URL for custom Telegram Bot API server
Support custom Telegram Bot API server URL via TELEGRAM_API_URL env var.
Enables bot operation in regions where api.telegram.org is blocked
(Cloudflare Worker, self-hosted telegram-bot-api, nginx reverse proxy).
Uses native aiogram TelegramAPIServer.from_base(), works with PROXY_URL.
2026-04-15 03:12:59 +03:00
Dmitry V. Lunin 94da3c35b0 fix: use PROXY_URL for Telegram OIDC JWKS requests (#2866) 2026-04-15 03:08:06 +03:00
Dmitry V. Lunin d35a8bb74c fix: update subscription_crypto_link when syncing user from panel (#2867)
* fix: update subscription_crypto_link when syncing user from panel

* fix: update subscription_crypto_link when syncing user from panel
2026-04-15 03:07:07 +03:00
Egor c635d88764 Merge pull request #2878 from BEDOLAGA-DEV/main
w
2026-04-15 02:53:04 +03:00
c0mrade 5009703676 Merge pull request #2875 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.46.1
2026-04-13 22:09:58 +03:00
github-actions[bot] e88a5989b6 chore(main): release 3.46.1 2026-04-13 19:07:27 +00:00
c0mrade 02747381dc Merge pull request #2874 from BEDOLAGA-DEV/dev
fix: cabinet_refresh_tokens migration + notification_settings jsonb
2026-04-13 22:06:53 +03:00
c0mrade e74fda954c fix: change notification_settings from json to jsonb for DISTINCT compatibility 2026-04-13 21:56:04 +03:00
c0mrade 8587f03f67 fix: add checkfirst guards to cabinet_refresh_tokens migration 2026-04-13 21:47:16 +03:00
c0mrade 4707cdf60c fix: add missing migration for cabinet_refresh_tokens table 2026-04-13 21:43:46 +03:00
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
Egor 43f5629c8c Merge pull request #2788 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.37.0
2026-03-21 03:18:30 +03:00
github-actions[bot] 448799a3ed chore(main): release 3.37.0 2026-03-21 00:18:10 +00:00
Egor b2b5f104b5 Merge pull request #2787 from BEDOLAGA-DEV/dev
Dev
2026-03-21 03:17:44 +03:00
Egor 5f71eaa926 Merge pull request #2778 from smediainfo/fix/dashboard-guest-revenue
fix: include SUBSCRIPTION_PAYMENT in dashboard revenue
2026-03-21 03:15:20 +03:00
Fringg 54155b5649 style: ruff format cloudpayments.py 2026-03-21 03:14:15 +03:00
Egor 8dc778654d Merge pull request #2785 from BEDOLAGA-DEV/revert-2783-revert-2779-fix/dashboard-revenue-complete
Revert 2783 revert 2779 fix/dashboard revenue complete
2026-03-21 03:11:03 +03:00
Egor 5175cccab6 Revert "Revert "fix: include landing page revenue in dashboard statistics"" 2026-03-21 03:07:19 +03:00
Egor 39ae095d9b Merge pull request #2786 from BEDOLAGA-DEV/main
w
2026-03-21 03:04:27 +03:00
Egor db3ac254ba Revert "Revert "fix: include landing page revenue in dashboard statistics"" 2026-03-21 03:03:06 +03:00
Egor bff9ebf078 Merge pull request #2783 from BEDOLAGA-DEV/revert-2779-fix/dashboard-revenue-complete
Revert "fix: include landing page revenue in dashboard statistics"
2026-03-21 03:02:57 +03:00
Egor 42ddadec5b Revert "fix: include landing page revenue in dashboard statistics" 2026-03-21 03:02:36 +03:00
Egor c6c1599e14 Merge pull request #2779 from smediainfo/fix/dashboard-revenue-complete
fix: include landing page revenue in dashboard statistics
2026-03-21 03:02:09 +03:00
Egor 77b2d645c5 Merge pull request #2782 from SayonaraQ/pr/remnawave-node-webhook-toggle-dev
add toggle for Remnawave node connection webhook alerts
2026-03-21 03:00:13 +03:00
Fringg 3875335cd7 fix: narrow exception handling and fix session leak in gift.py
- Replace broad `except Exception` with `except TelegramAPIError` in
  balance.py and wheel.py Stars invoice creation (prevents masking
  programming errors)
- Fix session leak in gift.py telegram_stars path: wrap PaymentService
  usage in try/finally to ensure bot.session.close() is called
2026-03-21 02:55:48 +03:00
Fringg 0a53b85b8a refactor: centralize Bot instantiation via create_bot() factory
Replace all ~45 direct Bot() calls across the codebase with a centralized
create_bot() factory function that automatically configures SOCKS5 proxy
session when PROXY_URL is set. This ensures proxy support applies uniformly
to all Telegram API traffic.

Key changes:
- Add app/bot_factory.py with create_bot() factory
- Replace direct Bot() instantiation in 33 files
- Fix session leaks in cloudpayments.py and auth.py (async with)
- Replace 2 direct httpx calls to api.telegram.org with
  bot.create_invoice_link() (balance.py, wheel.py)
- Remove now-unused imports (Bot, DefaultBotProperties, ParseMode, httpx)
2026-03-21 02:49:37 +03:00
Fringg 82b6a8bf70 feat: add SOCKS5 proxy support for Telegram API traffic
Route bot traffic through SOCKS5 proxy when PROXY_URL env var is set.
Validates scheme to reject HTTP proxies (would expose bot token).
Credentials are masked in logs.
2026-03-21 02:32:35 +03:00
Fringg 2a72deadd6 fix: resolve EmailService stale SMTP config causing NoneType crash on from_email
EmailService singleton cached SMTP settings in __init__ at import time.
is_configured() read live from settings, but self.from_email stayed None
when SMTP was unconfigured at startup → AttributeError on .split('@').

Replace cached attributes with @property accessors, snapshot from_email
once per send_email call with validation guard.
2026-03-21 02:21:25 +03:00
Fringg afefcc9c07 fix: resolve remaining TOCTOU issues in RioPay, SeverPay and restore paid_at
- RioPay: use create_transaction(commit=False) to keep FOR UPDATE lock,
  replace update_riopay_payment_status with inline assignment + flush,
  add emit_transaction_side_effects after commit
- SeverPay: add db.flush() before _finalize, remove self-assignment,
  add paid_at to both webhook and status-check paths
- Freekassa/KassaAI: add is_paid and paid_at to webhook and status-check
  inline sections (regression from CRUD→inline migration)
- MulenPay: add is_paid and paid_at to webhook inline section
2026-03-21 02:10:41 +03:00
Fringg 82c79c1306 fix: prevent double-payment TOCTOU race in all payment providers
Apply the same FOR UPDATE locking pattern across 8 providers:
- RioPay: added FOR UPDATE lock (had none at all)
- CryptoBot: moved lock before status check, removed redundant lock
- WATA: moved lock before is_paid commit, removed redundant lock
- Freekassa: moved lock before is_paid commit, removed redundant lock
- KassaAI: moved lock before is_paid commit, removed redundant lock
- MulenPay: moved lock before is_paid commit, removed redundant lock
- Pal24: moved lock before is_paid commit, removed redundant lock
- SeverPay: moved lock before is_paid check, removed redundant lock

Pattern applied to all: acquire FOR UPDATE with populate_existing=True
immediately after finding the payment, replace intermediate commits with
inline assignments + flush(), re-check is_paid from locked row.
2026-03-21 01:52:56 +03:00
Fringg 0e1296e0ea fix: prevent double balance credit on concurrent Platega webhooks
- acquire FOR UPDATE lock immediately after payment lookup, before is_paid check
- use populate_existing=True to prevent SQLAlchemy identity map stale reads
- replace intermediate update_platega_payment(commit) with inline assignments + flush
- re-check locked.is_paid after lock in get_platega_payment_status
- guard _finalize_platega_payment: only called when lock held and is_paid=False
- suppress "message is not modified" TelegramBadRequest in message_patch
2026-03-21 01:46:44 +03:00
Fringg 9dd6b54c6e fix: prevent bootstrap from reactivating revoked superadmin roles
- bootstrap _assign_if_missing no longer reactivates revoked UserRole rows
- revoke_role uses SELECT FOR UPDATE + pg_advisory_xact_lock to prevent TOCTOU race on last-superadmin check
- block self-revocation of superadmin role
- block is_active/level changes on system roles
- block expires_at on superadmin role assignments
- single SUPERADMIN_LEVEL constant in crud/rbac.py, imported everywhere
- get_superadmin_count excludes expired assignments
- removed dead UserRoleCRUD.revoke_role method
- warn when revoking RBAC role from a legacy ADMIN_IDS user
- added migration 0043: indexes on user_roles.role_id, access_policies.role_id, lower(users.email)
2026-03-21 01:09:13 +03:00
SayonaraQ 8a5710aff3 style: align webhook service with ruff format 2026-03-21 00:59:18 +03:00
SayonaraQ ce36fba54f style: format remnawave webhook service 2026-03-21 00:54:54 +03:00
SayonaraQ f601bedb48 add toggle for Remnawave node connection webhook alerts 2026-03-21 00:30:49 +03:00
sMedia.tech 801921ff74 fix: increase landing purchase rate limit from 5 to 30 req/min
Users hitting 429 Too Many Requests when trying to purchase on landing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 23:48:02 +03:00
Fringg 67da390371 feat: show both bot and cabinet referral links everywhere
Previously the bot showed only one referral link (cabinet when CABINET_URL
is set, bot otherwise). Users who received the cabinet link were confused —
they opened a web registration form instead of being directed to the bot.

Now the bot, cabinet API, and miniapp API all return both links:
- Bot link (t.me deep link) — always shown
- Cabinet link (web registration) — shown when CABINET_URL is configured

Changes:
- Add get_bot_referral_link() and get_cabinet_referral_link() to config
- Refactor config methods to eliminate code duplication
- Update bot referral handler to display both links
- Fix switch_inline_query 256-char limit with auto-truncation
- Add html_escape() to all user-controlled strings in HTML messages
- Add translations for 5 new keys in all 5 locale files (ru/en/ua/zh/fa)
- Simplify cabinet route to use new methods instead of inline URL construction
- Add bot_referral_link to MiniApp API schema and response
2026-03-20 23:12:22 +03:00
sMedia.tech d400cd7b49 feat: broadcast caption validation + landing daily created stats
- Validate message length for media broadcasts (1024 char Telegram limit)
- Add created count per day to landing stats API (separate from successful)
- Fix total_purchases to show total_created instead of total_successful

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 17:14:27 +03:00
sMedia.tech bf0ba22790 style: ruff format email_templates.py
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:30:47 +03:00
sMedia.tech f82a713110 feat: expose cabinet_email/password vars in subscription delivered template admin UI
Add cabinet_email and cabinet_password to context_vars and sample_contexts
for guest_subscription_delivered template type so they appear in the admin
email template editor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:28:18 +03:00
sMedia.tech fedcf2569a feat: include cabinet credentials in subscription delivered email
Add login/password block to the "subscription ready" email template
so users receive their cabinet credentials in the first email.
The credentials block is only shown when cabinet_password is present
(new accounts). All 5 locales updated (ru, en, zh, ua, fa).

The separate credentials email (GUEST_CABINET_CREDENTIALS) is still
sent as before — this provides redundancy in case one email doesn't
arrive (e.g. due to SMTP quota limits).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:22:22 +03:00
sMedia.tech 1882909b3e fix: derive income_today from revenue_chart to ensure consistency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:25:39 +03:00
sMedia.tech 226d3f2766 fix: default payment_method to BALANCE for bot subscription payments
Prevents double-counting in revenue: bot users create DEPOSIT (real
money) + SUBSCRIPTION_PAYMENT (balance debit). Without explicit
payment_method, subscription_payment had NULL which was patched to
kassa_ai, causing both to count as real revenue.

Now create_transaction defaults to BALANCE for SUBSCRIPTION_PAYMENT
when no payment_method is specified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:19:55 +03:00
sMedia.tech 6982d27378 fix: include SUBSCRIPTION_PAYMENT in recent payments today/week totals
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:19:55 +03:00
sMedia.tech 27ef75214e fix: include SUBSCRIPTION_PAYMENT in sales summary and deposits stats
Same fix as transaction.py — sales dashboard summary and deposits
breakdown were only counting DEPOSIT transactions, missing all
landing page purchases (SUBSCRIPTION_PAYMENT).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:19:55 +03:00
sMedia.tech 13dba5a303 fix: include SUBSCRIPTION_PAYMENT in dashboard revenue calculations
Guest/landing purchases create SUBSCRIPTION_PAYMENT transactions (not
DEPOSIT), so they were excluded from income_today, total_income,
revenue_by_period, and payment_methods breakdown.

Also use func.abs() for SUBSCRIPTION_PAYMENT amounts since they are
stored as negative values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:19:55 +03:00
sMedia.tech d7f91c8358 fix: include SUBSCRIPTION_PAYMENT in dashboard revenue calculations
Guest/landing purchases create SUBSCRIPTION_PAYMENT transactions (not
DEPOSIT), so they were excluded from income_today, total_income,
revenue_by_period, and payment_methods breakdown.

Also use func.abs() for SUBSCRIPTION_PAYMENT amounts since they are
stored as negative values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:10:56 +03:00
Egor ee9f0b7382 Merge pull request #2777 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.36.1
2026-03-20 08:48:30 +03:00
github-actions[bot] e16eba10d9 chore(main): release 3.36.1 2026-03-20 05:48:16 +00:00
Egor 1771cc4d13 Merge pull request #2776 from BEDOLAGA-DEV/dev
Dev
2026-03-20 08:47:53 +03:00
Egor 3bda0a2001 Merge pull request #2775 from BEDOLAGA-DEV/main
w
2026-03-20 08:47:02 +03:00
Fringg 877b1cde11 fix: handle duplicate admin roles in RBAC bootstrap
Use scalars().first() instead of scalar_one_or_none() to tolerate
duplicate rows in admin_roles table by (is_system, level).
2026-03-20 08:45:23 +03:00
Fringg 5faf7015ac fix: make migration 0042 idempotent for retry_count column 2026-03-20 08:44:19 +03:00
Egor 55f386d7e8 Merge pull request #2774 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.36.0
2026-03-20 07:23:48 +03:00
github-actions[bot] e6a310dc32 chore(main): release 3.36.0 2026-03-20 04:21:42 +00:00
Egor ccd8f86e96 Merge pull request #2773 from BEDOLAGA-DEV/dev
Dev
2026-03-20 07:21:11 +03:00
Egor b6d4373933 Merge pull request #2771 from smediainfo/fix/kassa-ai-guest-metadata
fix: use base model name for KassaAI guest metadata patch
2026-03-20 07:20:25 +03:00
Egor 266620904d Merge pull request #2772 from BEDOLAGA-DEV/main
w
2026-03-20 07:18:45 +03:00
Fringg 479af5741a style: ruff format 2026-03-20 07:15:15 +03:00
Fringg 79c110ff41 fix: address review findings for multi-provider recovery
- Use atomic UPDATE SET retry_count = retry_count + 1 instead of
  SELECT+modify+commit to avoid identity map pollution
- Filter retry_count < max_retries in SQL WHERE clause to avoid
  wasting LIMIT slots on exhausted purchases
- Extract _fail_exhausted_purchases_batch() — separate pass for
  exhausted purchases, alert sent outside session context
- HTML-escape all user-controlled values in admin alert messages
- Mark purchases FAILED on amount mismatch (prevents repeated
  error logs every scheduler cycle) with admin alert
- Accept plain dict in _send_stuck_purchase_alert instead of ORM
  object (avoids expired-attribute access after commit)
2026-03-20 07:02:47 +03:00
Fringg 3d78974af7 feat: add multi-provider recovery, retry_count, amount verification, and indexes
- Add retry_count column to guest_purchases with Alembic migration
- Add expression indexes on metadata_json->>'purchase_token' for all 12
  payment provider tables (partial indexes filtered by is_paid/status)
- Implement _find_succeeded_provider_payment() covering all providers:
  YooKassa, Heleket, MulenPay, Pal24, Wata, Platega, CloudPayments,
  Freekassa, KassaAi, RioPay, SeverPay, and CryptoBot (payload field)
- Add amount verification in _check_and_recover_pending_purchase():
  compares provider payment amount with GuestPurchase.amount_kopeks,
  skips for CryptoBot (USD conversion imprecision)
- Increment retry_count on each retry attempt in retry_stuck_paid_purchases
  and retry_stuck_pending_activation
- Mark purchases as FAILED after 20 retries with admin Telegram alert
  via AdminNotificationService (ERRORS category)
2026-03-20 06:53:49 +03:00
Fringg 57c5c679ee fix: address review findings for guest purchase recovery
- Add FOR UPDATE to recovery path in try_fulfill_guest_purchase to prevent
  TOCTOU race that could overwrite DELIVERED back to PAID
- Isolate monitoring phases with independent try/except so Phase 1 failure
  does not block Phase 2/3
- Optimize recover_stuck_pending_purchases to select only token and
  payment_method columns instead of full ORM objects
- Remove dead elif branch in stars_payments.py (try_fulfill_guest_purchase
  no longer returns False)
- Add Phase 3 comment for consistency
2026-03-20 06:45:21 +03:00
Fringg 2781236011 fix: prevent guest purchases from getting stuck in PENDING/FAILED status
- Mark guest purchases as PAID (not FAILED) on transient fulfillment errors
  so monitoring service can retry them automatically
- Use fresh AsyncSessionLocal session for recovery to avoid tainted-session
  issues after rollback
- Add status guard to prevent overwriting terminal states (DELIVERED, etc.)
- Add recover_stuck_pending_purchases() to detect PENDING purchases where
  provider payment already succeeded (checks YooKassa payments table)
- Use SELECT ... FOR UPDATE to prevent TOCTOU races in recovery
- Add 3-phase monitoring pipeline: recover PENDING → retry PAID → retry
  PENDING_ACTIVATION
- Extract shared _resolve_base_payment_method() helper
2026-03-20 06:39:01 +03:00
Fringg 4a002b7db1 fix: allow repeated auto-assignment of promo groups on each purchase
Remove the threshold barrier that prevented re-assignment to the same
promo group tier. Previously, _get_best_group_for_spending was called
with min_threshold_kopeks=previous_threshold, which meant once a user
was auto-assigned to a tier (e.g. 100 kopeks), the check 100 > 100
would fail and the function would skip cleanup of promocode groups.

Now the function always finds the best group for the user's spending
without threshold filtering. The threshold ratchet is preserved only
for the watermark update (auto_promo_group_threshold_kopeks only
increases, never decreases).

Also elevate promo group assignment failure logging from DEBUG to
WARNING across all 3 call sites in transaction.py.
2026-03-20 05:46:59 +03:00
Fringg 8b2668087b fix: prevent premature commits in promocode promo group operations
add_user_to_promo_group and remove_user_from_promo_group in
promocode_service used default commit=True, causing mid-transaction
commits that flushed all pending session changes before the outer
db.commit() at lines 163/404.
2026-03-20 05:33:30 +03:00
Fringg 3ec9e71de7 fix: propagate exceptions from get_primary_user_promo_group
Consistent with has_user_promo_group and get_user_promo_groups which
now propagate exceptions instead of masking them with default returns.
2026-03-20 05:30:42 +03:00
Fringg da7a9cc3c5 fix: prevent duplicate promo groups during auto-assignment after purchase
Root cause: auto-assignment did not remove old auto/promocode groups before
adding new one, causing users to accumulate multiple simultaneous promo groups.
The primary group selection then picked the wrong one.

Changes:
- Remove old auto/promocode groups atomically before adding new one
- Add SELECT FOR UPDATE (lock_user_for_update) to serialize concurrent webhooks
- Fix CRUD rollback when commit=False — re-raise instead of destroying caller tx
- Fix sort order: desc(PromoGroup.id) to match model's get_primary_promo_group()
- Let has_user_promo_group/get_user_promo_groups propagate exceptions (fail-open bug)
- Fix replace_user_promo_groups: remove dead query, add _sync_user_primary_promo_group
- Use SQL COUNT in count_user_promo_groups instead of loading all rows
- Refresh user after removal loop to avoid stale ORM state
2026-03-20 05:25:41 +03:00
Fringg b5471b7720 perf: add covering indexes for referral network queries
Add composite indexes on advertising_campaign_registrations(user_id,
created_at) and transactions(user_id, type, is_completed, amount_kopeks)
to enable index-only scans. Uses CREATE INDEX CONCURRENTLY for zero
downtime. Also enable transaction_per_migration in Alembic env.py.
2026-03-20 02:31:03 +03:00
Fringg 6a4ce3dd38 feat: multi-select scope for referral network graph API
Support multiple campaigns, partners, and users in a single scoped
graph request. Dedup inputs, soft-skip invalid IDs, and discover
campaign registrations across all scope types.
2026-03-20 02:30:56 +03:00
Fringg df086b09c7 feat: add scoped referral network graph with scope selector API
- GET /scope-options: lightweight campaign/partner lists for selector
- GET /scoped?scope=campaign|partner|user&id=N: returns subgraph
- Recursive CTE helpers for ancestor/descendant traversal
- GRAPH_MAX_NODES cap applied to scoped graphs
- Campaign nodes shown even with zero registrations
2026-03-20 01:49:59 +03:00
Fringg 01132a7bc7 feat: add partner → campaign edges to referral network graph 2026-03-20 01:16:57 +03:00
sMedia.tech 182667ecb8 fix: use 'kassa_ai' base model name for guest metadata patch
kassa_ai_sbp has no separate CRUD module, causing guest purchase
metadata to not be saved, which breaks webhook fulfillment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:56:39 +03:00
Fringg c8f4cca340 fix: correct revenue calculations in referral network
Campaign revenue now uses actual subscription payments by campaign
users instead of referral commission earnings (which were often 0).

Branch revenue for user detail now sums subscription payments by
branch users via recursive CTE instead of referral earnings.

Batch branch_revenue helper also updated to use Transaction spending.
2026-03-19 08:51:52 +03:00
Fringg ac9fcd8d30 fix: improve referral network query correctness and cleanup
- Use UNION ALL + count(distinct) for recursive CTE (faster, cycle-safe)
- Derive total_earnings from personal_revenue dict (remove redundant query)
- Merge duplicate campaign registration queries into single query
- Add MAX_REFERRAL_DEPTH constant, _format_datetime type hint
2026-03-19 08:08:16 +03:00
Fringg c08c903e8f feat: add referral network graph visualization admin API
4 endpoints for referral network analysis: full graph with batched
aggregation queries, user detail with recursive CTE branch counting,
campaign detail with conversion metrics, and search with LIKE escaping.

All endpoints rate-limited, scoped queries to prevent full-table scans,
depth-limited recursive CTE, fail_closed on expensive graph endpoint.
2026-03-19 07:55:51 +03:00
Fringg 69bb399b63 feat: add media attachment support for admin ticket replies
Admin can now attach photos, videos, and documents when replying to tickets
via the cabinet. Media is uploaded through the existing /cabinet/media/upload
endpoint and stored as Telegram file_id references in TicketMessage.

Added media_type, media_file_id, media_caption fields to AdminReplyRequest
with cross-field validation via model_validator.
2026-03-19 06:38:54 +03:00
Egor 463c5385d6 Merge pull request #2770 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.35.0
2026-03-18 23:35:06 +03:00
github-actions[bot] 41dfe39518 chore(main): release 3.35.0 2026-03-18 20:34:40 +00:00
Egor f44c9b6903 Merge pull request #2769 from BEDOLAGA-DEV/dev
Dev
2026-03-18 23:34:11 +03:00
Egor 1f35d45dc6 Merge pull request #2768 from BEDOLAGA-DEV/main
w
2026-03-18 23:32:42 +03:00
Fringg ef8f6625bf chore: ruff format 2026-03-18 23:31:31 +03:00
Fringg 7101555da0 feat: add user_email to admin payments API response 2026-03-18 23:30:17 +03:00
Fringg e15b18fb41 feat: раздельные топики для админских уведомлений
Добавлены 9 новых env-переменных для маршрутизации уведомлений по отдельным топикам:
- PURCHASES, RENEWALS, TRIALS, BALANCE, ADDONS
- INFRASTRUCTURE, ERRORS, PROMO, PARTNERS

Обратная совместимость: если топик для категории не задан — fallback на ADMIN_NOTIFICATIONS_TOPIC_ID.
2026-03-18 23:16:54 +03:00
Fringg b80eeea089 feat: include manual admin top-ups in sales statistics revenue 2026-03-18 22:39:31 +03:00
Fringg cb61014d9c fix: remove forced white background from custom email template overrides
Custom email templates with their own styling (background colors, <style> tags)
were wrapped in a white base template, causing visible white areas around dark-themed
templates. Added three-tier detection: full HTML documents pass through as-is,
styled content gets a minimal wrapper, simple fragments keep the base template.
2026-03-18 22:24:58 +03:00
Fringg 5b3353433b fix: undefined currency variable in RioPay payment creation
currency was referenced but never defined in create_riopay_payment,
causing NameError. Use settings.RIOPAY_CURRENCY instead.
2026-03-18 22:05:45 +03:00
Fringg f1d45343e9 fix: handle None autopay_days_before in autopayment processing
Existing subscriptions may have NULL autopay_days_before in DB,
causing TypeError in min(None, 3). Default to 3 when None.
2026-03-18 21:16:08 +03:00
Fringg b40a812f3a fix: fix Platega and CryptoBot webhook verification
Platega: handle verification ping POST without auth headers (empty body → 200 OK)
CryptoBot: always use API token for signature verification per docs, not WEBHOOK_SECRET
CryptoBot: reject requests without signature in both FastAPI and aiohttp handlers
Remove dead self.webhook_secret from CryptoBotService
Update tests to match new behavior
2026-03-18 20:11:55 +03:00
Egor ac00434645 Merge pull request #2765 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.34.1
2026-03-18 18:25:15 +03:00
github-actions[bot] 964c33c772 chore(main): release 3.34.1 2026-03-18 15:23:42 +00:00
Egor ec875837a4 Merge pull request #2767 from BEDOLAGA-DEV/dev
Dev
2026-03-18 18:22:52 +03:00
Egor db0e169a41 Merge pull request #2766 from BEDOLAGA-DEV/main
w
2026-03-18 18:21:41 +03:00
Fringg a6dcf26c20 chore: ruff format and cleanup 2026-03-18 18:20:50 +03:00
Fringg 5081debee7 fix: add null check for subscription in execute_change_devices 2026-03-18 18:15:20 +03:00
Fringg 0ceff44c30 fix: sync crypto link from happ.cryptoLink in webhook handlers
Webhook only checked subscriptionCryptoLink field, missing happ.cryptoLink
fallback that sync already used. Also clear stale crypto link when URL
changes but no new crypto link is provided.
2026-03-18 18:05:53 +03:00
Fringg 136f29c1eb refactor: remove quick amount buttons feature entirely
Removed across 16 files: config, all payment handlers, handler registrations, env example.
2026-03-18 17:55:16 +03:00
Fringg d0eab3f7aa fix: disable quick amount buttons in balance topup
Buttons showed incorrect prices. Hardcoded is_quick_amount_buttons_enabled to False.
2026-03-18 17:47:51 +03:00
Fringg d7ad9d7033 fix: correct CryptoBot webhook signature verification and auto-fill topup amount from cart
- Use API token as fallback for webhook signature verification per CryptoBot docs
- Try raw body, re-serialized compact JSON, and ASCII-escaped JSON for signature matching
- Auto-fill payment amount from saved cart in show_payment_methods instead of hardcoded 0
2026-03-18 17:44:52 +03:00
Fringg 1a87d438fe fix: correct RioPay API header case and remove undocumented fields
Header was 'x-api-token' but RioPay API expects 'X-Api-Token'
(case-sensitive check on their side), causing 403 Invalid API token.

Also removed undocumented 'currency' and 'failUrl' fields from
create_order payload per official RioPay API docs.
2026-03-18 17:00:56 +03:00
Fringg aec01ce0d4 fix: reset device limit to new tariff base on tariff switch
Previously, extra purchased devices were carried over when switching
tariffs, causing incorrect pricing — users upgrading to a more
expensive plan kept the old per-device rate until next renewal.

Now tariff switch resets device_limit to the new tariff's base limit.
Extra purchased devices are not carried over.
2026-03-18 16:57:08 +03:00
c0mrade a33a893d1a Update README.md
Фикс WATA в редми
2026-03-18 10:58:18 +03:00
Egor 37c9b931ca Merge pull request #2763 from BEDOLAGA-DEV/dev
Dev
2026-03-18 07:23:12 +03:00
Fringg 22e7f150b3 docs: increase logo size to 800px 2026-03-18 07:22:27 +03:00
Fringg 688882237f docs: replace header logo with new artwork 2026-03-18 07:21:03 +03:00
Egor c14d7ab0af Merge pull request #2761 from BEDOLAGA-DEV/dev
docs: add Redis to tech stack
2026-03-18 07:13:38 +03:00
Fringg e12cc9f248 docs: add Redis to tech stack 2026-03-18 07:12:53 +03:00
Egor 6ff0460607 Merge pull request #2759 from BEDOLAGA-DEV/dev
Dev
2026-03-18 07:07:53 +03:00
Fringg 8d5a002996 docs: WATA partnership block with logo and table card 2026-03-18 07:07:06 +03:00
Fringg 31bdf8a0ae docs: add WATA partnership block to payments section 2026-03-18 07:04:57 +03:00
Egor 1364158e6c Merge pull request #2757 from BEDOLAGA-DEV/dev
Dev
2026-03-18 06:55:47 +03:00
Fringg d7931a2afa docs: add bot preview screenshot to README 2026-03-18 06:53:44 +03:00
Fringg b032c8f354 docs: add cabinet preview screenshot to README 2026-03-18 06:50:00 +03:00
Fringg 1306c24fa3 docs: add icons and list all 14+1 payment providers 2026-03-18 06:41:43 +03:00
Fringg 38deb70f81 docs: redesign README — concise feature showcase, link to docs
Replace 2200-line README with a clean 190-line version:
- Centered header with badges (for-the-badge style)
- Feature grid (2x2 HTML table)
- Payment providers showcase (14 providers)
- Quick start (4 lines → link to full docs)
- Tech stack table
- Cabinet section with link to repo
- Documentation links to docs.bedolagam.ru
- Community section

All setup/config details moved to docs.bedolagam.ru.
2026-03-18 06:37:49 +03:00
Egor c1e015fb6e Merge pull request #2756 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.34.0
2026-03-18 05:58:02 +03:00
github-actions[bot] 0730173e5b chore(main): release 3.34.0 2026-03-18 02:56:31 +00:00
Egor 968f18b6e4 Merge pull request #2755 from BEDOLAGA-DEV/dev
Dev
2026-03-18 05:56:06 +03:00
Egor 7eea35f111 Merge pull request #2754 from BEDOLAGA-DEV/main
w
2026-03-18 05:54:06 +03:00
Fringg 6920e3a0fb style: ruff format 2026-03-18 05:53:31 +03:00
Fringg fddf8ef5eb fix: remove contains_eager conflicting with selectinload on user relationship
Loader strategies for the same ORM path cannot coexist. The
_apply_user_join_filter helper added contains_eager(model.user) on top
of the selectinload(Model.user) already present in each query, causing
InvalidRequestError at runtime. Removed contains_eager — selectinload
handles user loading correctly on its own.
2026-03-18 05:50:00 +03:00
Fringg ad268329be fix: добавлен импорт MAX_ALL_TIME_DAYS в admin_payments routes 2026-03-18 05:43:22 +03:00
Fringg 1804c28f05 feat: поиск платежей в админ-панели с фильтрами и статистикой
Новый сервис поиска по 13 платёжным провайдерам с ILIKE (escape от инъекций),
фильтрами по статусу/периоду/методу, кастомным диапазоном дат, пагинацией.
Эндпоинты: GET /search, GET /search/stats с валидацией входных данных.
2026-03-18 05:40:49 +03:00
Fringg f967c29bd7 fix: добавлены RioPay и SeverPay в REAL_PAYMENT_METHODS
Без этого платежи через RioPay и SeverPay не учитывались
в статистике доходов, разбивке по методам и отчётах
2026-03-18 03:59:33 +03:00
Fringg 06a00e367c feat: добавлен SeverPay в админ-панель и настройки кабинета
- Категория SEVERPAY в настройках бота
- Кнопка тестового платежа
- Конфигурация метода в кабинете
- DEFAULT_METHOD_ORDER обновлён
2026-03-18 03:55:24 +03:00
Fringg abaf279533 feat: добавлена интеграция SeverPay для пополнения баланса
- API клиент (HMAC-SHA256 подпись, создание/получение платежа)
- CRUD операции с FOR UPDATE блокировкой
- Payment mixin с обработкой webhook и финализацией
- Хендлеры бота для пополнения через SeverPay
- Миграция 0040: таблица severpay_payments
- Webhook endpoint (всегда 200 для предотвращения ретраев)
- Интеграция с payment_verification_service
- Поддержка гостевых покупок (лендинги, подарки)
2026-03-18 03:49:19 +03:00
Egor 6d4430c639 Merge pull request #2753 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.33.0
2026-03-18 02:02:09 +03:00
github-actions[bot] 911df7a05c chore(main): release 3.33.0 2026-03-17 23:01:41 +00:00
Egor f106ce8216 Merge pull request #2752 from BEDOLAGA-DEV/dev
Dev
2026-03-18 02:00:59 +03:00
Fringg dcff6947dd style: ruff format 2026-03-18 01:59:12 +03:00
Fringg 4966e39eb9 fix: скрыть плашку верификации email при выключенной верификации
- Добавлен verification_enabled в ответ /cabinet/branding/email-auth
- Фронтенд использует его для скрытия баннера и бейджа
2026-03-18 01:52:54 +03:00
Fringg 4abb8cb1a3 fix: исправлены проблемы RioPay интеграции после ревью
- Модель: user_id nullable=True + ondelete='SET NULL' (не применилось ранее)
- order_id для гостей: 'rpguest_xxx' вместо 'rpNone_xxx'
- Миграция: добавлено пересоздание FK с ON DELETE SET NULL
- get_latest_payment_by_method: добавлен RioPayPayment в model_map
2026-03-18 00:18:15 +03:00
Fringg 04f4e6bf6e feat: добавлена поддержка RioPay для лендингов и подарков
- Добавлен RioPay в create_guest_payment (landing/gift покупки)
- user_id в RioPayPayment теперь nullable (для гостевых платежей)
- Добавлен guest purchase flow в _finalize_riopay_payment
- Миграция 0039: riopay_payments.user_id nullable
2026-03-18 00:10:51 +03:00
Fringg 3d1fbc70f8 feat: добавлена поддержка RioPay в кабинете
- Добавлен RioPay в create_topup endpoint (cabinet balance)
- Добавлен маппинг статусов RioPay в _get_status_info
- Добавлена поддержка ручной проверки RioPay платежей
- Добавлена автопроверка RioPay в payment_verification_service
- Добавлены success_url/fail_url параметры в create_riopay_payment mixin
2026-03-18 00:05:07 +03:00
Fringg 3089c1704b fix: исправлен расчёт конверсии в статистике продаж
- Добавлен fallback через has_had_paid_subscription для подсчёта конверсий
- Исправлен знаменатель: total_trial_starters = new_trials + conversions
- Ограничение conversion_rate до 100% максимум
- Исправлен .is_(True) вместо == True в subscription_conversion.py
2026-03-17 23:46:15 +03:00
Fringg 20eff6170f fix: add back button to payment amount validation errors
All min/max amount error messages in payment handlers now include
a back button keyboard, so users aren't stuck without navigation.
Fixed 30 message.answer() calls across 12 payment handler files.
2026-03-17 23:34:05 +03:00
Fringg 038c34e52a fix: swap Caddy auth headers — api_key to Authorization, caddy_token to X-Api-Key
Caddy Security expects the caddy token in X-Api-Key and the Remnawave
API key in Authorization: Bearer. The headers were swapped, causing
401 errors for users with Caddy auth type.
2026-03-17 23:28:44 +03:00
Fringg 77f1a764d5 fix: merge phantom users into active accounts on /start
When a user purchases on a landing page by username and Bot.get_chat()
fails, a phantom user (telegram_id=NULL) is created. If that user
already has an active bot account, the phantom was never merged,
creating duplicate user records.

Now cmd_start checks for phantom users matching the active user's
username and merges them: transfers GuestPurchase records, balance,
and subscription (if active user has none). Phantom is soft-deleted
(status=DELETED, username=NULL) to preserve payment/transaction audit
trail and avoid CASCADE FK issues.
2026-03-17 23:06:02 +03:00
Fringg 641da949a9 fix: enforce promo group authorization on country/server selection
Previously, users could retain access to servers removed from their
promo group by re-submitting already-connected UUIDs in country
selection requests. The validation allowed any UUID present in
current connected_squads, bypassing promo group checks.

Now all selected server UUIDs must be in the user's allowed promo
group set. Unauthorized servers are rejected (cabinet/bot) or
filtered out (miniapp). Fixes authorization bypass across all 3
surfaces: cabinet, Telegram bot, and miniapp.
2026-03-17 22:30:57 +03:00
Fringg 3f0b24c1ec fix: add sync_squads=True to admin tariff change handler
Missed in the previous fix — admin tariff change at
handlers/admin/users.py sets connected_squads from tariff but
did not pass sync_squads=True to update_remnawave_user.
2026-03-17 22:25:01 +03:00
Fringg c34fdd10a0 fix: sync squads to Remnawave panel on tariff purchase/switch
When sync_squads parameter was introduced (4aaf0ddd) to prevent FK
violations from stale squad UUIDs, all update_remnawave_user calls
defaulted to sync_squads=False. This broke squad synchronization for
purchase/tariff-change flows where squads are freshly assigned and
must be sent to the panel.

Adds sync_squads=True to all purchase, tariff switch, and country
selection call sites across cabinet, bot handlers, miniapp, and
auto-purchase service.
2026-03-17 22:17:35 +03:00
Fringg 72b5305b87 fix: review findings — db.commit, isinstance guard, constants, ACTIVE check
- Explicit db.commit() for cabinet_last_login before _store_refresh_token
- isinstance(callback.message, types.Message) guard in process_webauth_confirm
- Check UserStatus.ACTIVE (not just DELETED) in bot callback handler
- isinstance guard in consume_web_auth_token for type safety
- Named constants: WEB_AUTH_LINKED_TTL, WEB_AUTH_TOKEN_MIN_LENGTH
- Use str.removeprefix() instead of hardcoded slice
- Move link_web_auth_token import to module level
2026-03-17 21:56:52 +03:00
Fringg 099391eb5f fix: deep link auth security and reliability fixes
- Atomic GETDEL in link_web_auth_token to prevent TOCTOU race
- Session fixation protection: inline keyboard confirmation before linking
- Poll rate limit 30→60/min to support 2.5s polling interval
- Fix double commit in poll endpoint (cabinet_last_login before _store_refresh_token)
- Replace magic string 'active' with UserStatus.ACTIVE.value
- Add response_model=AuthResponse to poll endpoint
- Validate bot_username is set (503 if empty)
- Move web_auth imports to module level
2026-03-17 21:46:09 +03:00
Fringg 322d457652 feat: deep link авторизация в кабинете при блокировке oauth.telegram.org
Когда скрипт Telegram Login Widget не загружается (заблокирован),
фронтенд автоматически переключается на deep link авторизацию:
- POST /cabinet/auth/deeplink/request — генерирует одноразовый токен
- Пользователь открывает t.me/bot?start=webauth_TOKEN
- Бот связывает токен с Telegram-аккаунтом
- POST /cabinet/auth/deeplink/poll — фронтенд получает JWT токены

Новый сервис: app/services/web_auth_service.py (Redis, TTL 5 мин)
2026-03-17 21:29:18 +03:00
Egor 5b722c5210 Merge pull request #2746 from smediainfo/pr/kassa-ai-sbp-card
feat: add SBP and Card sub-options for KassaAI payment method
2026-03-17 20:29:03 +03:00
Egor a80a85c2a4 Merge pull request #2748 from smediainfo/fix/missing-greenlet-purchase
fix: MissingGreenlet crash after subscription purchase in cabinet
2026-03-17 20:27:40 +03:00
Egor f84885cc8a Merge pull request #2747 from smediainfo/pr/fix-external-squad-sync
fix: защита внешних сквадов от удаления при синхронизации серверов
2026-03-17 20:26:58 +03:00
Egor 12898b7eab Merge pull request #2751 from SayonaraQ/fix/extend-period-nameerror
Fix/extend period nameerror
2026-03-17 20:25:51 +03:00
Codex Bot 20a6fa1bcf fix(subscription): remove stale extend promo state fields causing NameError 2026-03-17 17:55:14 +03:00
c0mrade 94199413c2 fix: миграция Tribute webhook с deprecated user_id на trb_user_id
- Убран fallback на deprecated поле user_id (удаляется 14 апреля 2026)
- Добавлен парсинг trb_user_id во всех ветках обработки webhook
- trb_user_id прокинут в результат и логи всех хендлеров
2026-03-17 12:43:04 +03:00
Fringg 826accba51 fix: MissingGreenlet при изменении количества устройств на CLASSIC подписках
lock_user_for_pricing не загружал User.subscription eagerly,
что вызывало lazy load в async контексте при обращении к db_user.subscription
в execute_change_devices.
2026-03-16 11:25:33 +03:00
sMedia.tech 1cc687ac15 fix: MissingGreenlet crash after subscription purchase in cabinet
`_subscription_to_response()` is a sync function that accesses
lazy-loaded relationship attributes (e.g. `subscription.tariff`).
When `send_subscription_purchase_notification()` is called before
building the response, `_record_subscription_event()` internally
calls `create_subscription_event()` which does `db.commit()`.
This expires all ORM objects in the session.

When the sync `_subscription_to_response()` then tries to access
`subscription.tariff`, SQLAlchemy cannot perform the lazy load
outside of an async greenlet context, raising:

  MissingGreenlet: greenlet_spawn has not been called;
  can't call await_only() here.

The fix adds `await db.refresh(subscription)` (and `user` where
accessed) after the admin notification block and before
`_subscription_to_response()` in three purchase endpoints:
- `submit_purchase` (classic mode)
- `purchase_tariff` (tariffs mode)
- `switch_tariff`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:16:53 +03:00
sMedia.tech e4bb0430fb refactor: deduplicate KassaAI handlers with config dict and shared helpers
Extract _KASSA_AI_METHOD_CONFIG dict, _check_topup_restriction() helper,
and generic _start_kassa_ai_sub_topup / _process_kassa_ai_sub_quick_amount
implementations. Public handlers become thin wrappers.

608 → 429 lines (-30%), eliminates 5 copies of restriction check block
and 3 pairs of nearly-identical start/quick-amount handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 08:53:34 +03:00
root 603b9a1f46 fix: sub-method enabled check, guest payment provider, silent FSM return 2026-03-16 04:40:36 +00:00
root 557af5994d style: ruff format kassa_ai files 2026-03-16 04:31:04 +00:00
root 808818ca2b style: ruff format server_squad.py 2026-03-16 04:30:32 +00:00
root b563796091 fix: protect external squads from deletion during server sync 2026-03-16 04:27:59 +00:00
sMedia.tech 6a3e9d92b5 style: ruff format kassa_ai_service.py 2026-03-16 04:12:41 +00:00
root cda2392411 refactor: move KASSA_AI_SUB_METHODS to service layer, add early enabled checks
- Move KASSA_AI_SUB_METHODS from handler to kassa_ai_service.py (fixes service→handler import violation)
- Remove KASSA_AI_PAYMENT_METHODS set (was defined but unused)
- Import KASSA_AI_SUB_METHODS in payment_service.py from service layer
- Add is_kassa_ai_sbp/card_enabled() checks at start of entry handler functions
2026-03-16 04:12:41 +00:00
root 04419fdff7 feat: add SBP and Card sub-options to kassa_ai payment method
- kassa_ai shows single admin entry with СБП/Карта sub-option checkboxes
- SBP routes to payment_system_id=44, Card to payment_system_id=36
- Bot: added kassa_ai_sbp/card handlers and FSM flow (mirrors freekassa pattern)
- Cabinet: KASSA_AI_OPTION_MAP reads payment_option to select correct ps_id
- Config: KASSA_AI_SBP_ENABLED / KASSA_AI_CARD_ENABLED env vars + helpers
- Guest payments: kassa_ai_sbp/card supported in landing page checkout
- payment_method_config_service: kassa_ai has available_sub_options=[sbp,card]
2026-03-16 04:12:41 +00:00
Egor 713146dd6b Merge pull request #2745 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.4
2026-03-16 04:14:18 +03:00
github-actions[bot] 7d41ab44be chore(main): release 3.32.4 2026-03-16 01:13:22 +00:00
Egor 98f6f93487 Merge pull request #2744 from BEDOLAGA-DEV/dev
Dev
2026-03-16 04:12:59 +03:00
Egor 3752b7b067 Merge pull request #2743 from BEDOLAGA-DEV/main
w
2026-03-16 04:10:54 +03:00
Fringg 2f33e55144 fix: режим «Контакт и тикеты» возвращает support_type='both' вместо 'tickets' 2026-03-16 04:09:21 +03:00
Fringg c0b282a189 fix: уведомление об истечении подписки теперь учитывает autopay_enabled пользователя
- Статус автоплатежа в уведомлении основан на subscription.autopay_enabled, а не на глобальном ENABLE_AUTOPAY
- Продление с баланса (_process_autopayments) работает всегда при autopay_enabled=True
- Рекуррентные карточные платежи по-прежнему за гейтом ENABLE_AUTOPAY + YOOKASSA_RECURRENT_ENABLED
2026-03-16 04:04:01 +03:00
Fringg e1bcb1ba91 fix: реферальный бонус инвайтера — сумма вместо максимума, защита флага первого пополнения
- referral_service: inviter_bonus = fixed + commission вместо max(fixed, commission)
- 13 платёжных провайдеров: has_made_first_topup ставится только для нереферальных юзеров
- riopay: критический фикс — флаг ставился до вызова referral_service
- Обновлены уведомления с разбивкой бонуса
- Исправлен и дополнен тест referral_service
2026-03-16 03:57:50 +03:00
Fringg 3d68db0a51 fix: не пересылать externalSquadUuid в рутинных обновлениях RemnaWave
Стейловый externalSquadUuid (c6c0a338-062d-4d3a-826d-7015a24d681c) из тарифа
не существует в таблице ExternalSquads панели → FK violation → A039.
Теперь externalSquadUuid отправляется только при sync_squads=True (создание подписки).
2026-03-16 03:47:13 +03:00
Fringg 8d7f0eea0f fix: лог полного payload при ошибке PATCH /api/users для диагностики A039 2026-03-16 03:44:45 +03:00
Fringg 4aaf0ddd25 fix: не пересылать activeInternalSquads в рутинных обновлениях RemnaWave (A039)
Стейловые UUID сквадов в connected_squads вызывали FK violation в RemnaWave → A039.
- update_remnawave_user: добавлен параметр sync_squads (default=False)
- Сквады шлются только при явном sync_squads=True (promo_offer, countries)
- monitoring_service: убрана пересылка сквадов в рутинном sync
- Расширен лог PATCH payload для диагностики
2026-03-16 03:41:56 +03:00
Fringg db2f0c93f2 fix: расширен лог PATCH /api/users payload для диагностики A039 2026-03-16 03:35:06 +03:00
Fringg 3f8e8993b2 fix: сохранение user_id до rollback чтобы избежать MissingGreenlet при lazy load 2026-03-16 03:28:12 +03:00
Fringg e453521098 fix: устранена отправка externalSquadUuid=null в RemnaWave API (A039) и исправлен reduce_devices
- reduce_devices: убрано молчаливое проглатывание ошибки RemnaWave, теперь при неудаче делается rollback и возвращается HTTP 502
- Убрана отправка external_squad_uuid=None в 8 местах: subscription_service, monitoring_service, remnawave_service, admin/users, cabinet/admin_users
2026-03-16 03:25:24 +03:00
Fringg 8d3cd50098 refactor: централизация всех расчётов цен в PricingEngine
- Мигрирован confirm_purchase() на calculate_classic_new_subscription_price()
- Мигрирован compute_simple_subscription_price на делегацию в PricingEngine
- Мигрирован handle_custom_confirm на calculate_tariff_purchase_price()
- Мигрированы daily confirm handlers (confirm_daily_tariff_purchase,
  confirm_daily_tariff_switch, confirm_instant_switch daily path)
- Мигрирован gift.py на calculate_tariff_purchase_price()
- Мигрированы FSM cache prices (select_period, select_devices, toggle_country)
- Добавлен lock_user_for_pricing в admin_buy_tariff_execute (TOCTOU fix)
- Добавлен lock + recompute в _auto_add_devices и _auto_add_traffic
- Исправлено двойное применение promo-offer в simple_subscription (критический баг)
- Унифицирован daily price display (group+offer) на всех 6 поверхностях
- PricingEngine.get_addon_discount_percent: добавлен promo_group= kwarg
- PricingEngine._calculate_switch_to/from_daily: добавлен promo-offer discount
- Удалён мёртвый код из common.py (_get_addon_discount_percent_for_user)
- Miniapp period_discounts: исправлен доступ через get_discount_percent()
2026-03-16 03:10:22 +03:00
Fringg f80912e444 fix: убрана отправка externalSquadUuid=null в RemnaWave API и исправлен ложный лог синхронизации рулетки
- Не отправляем externalSquadUuid: null — RemnaWave отвечал 500 (A039)
- Проверяем результат update_remnawave_user вместо ложного " синхронизировано"
2026-03-15 17:34:31 +03:00
Egor 484d2f7e34 Merge pull request #2740 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.3
2026-03-15 01:24:46 +03:00
github-actions[bot] 842fb697e6 chore(main): release 3.32.3 2026-03-14 22:24:27 +00:00
Egor 3ac3a92e26 Merge pull request #2739 from BEDOLAGA-DEV/dev
Dev
2026-03-15 01:24:05 +03:00
Fringg 7648707ca2 fix: campaign registration, revenue calculation, backup restore, autopay errors, referral links
- fix campaign registration not recorded when CHANNEL_IS_REQUIRED_SUB + SKIP_RULES_ACCEPT enabled (missing _apply_campaign_bonus_if_needed in required_sub_channel_check fast path)
- fix revenue calculation counting bonus-funded subscription payments as income (now deposits only via REAL_PAYMENT_METHODS)
- fix backup restore PendingRollbackError cascade on unique constraint violations (savepoint wrapping in _restore_table_records and _restore_users_without_referrals)
- fix AttributeError on message.text.strip() when users send media in referral code handlers
- suppress 'message is not modified' TelegramBadRequest in autopay toggle
- add bot_referral_link to referral API response with URL encoding
2026-03-15 01:13:50 +03:00
Egor 7e466ef464 Merge pull request #2736 from Legacyyy777/main
fix: implement case-insensitive email checks in authentication and user retrieval
2026-03-14 22:30:56 +03:00
Egor 28321df4d2 Merge pull request #2738 from SayonaraQ/pr/topup-cart-fix
fix(payment): prioritize saved cart after topup over expired auto-extend
2026-03-14 22:27:56 +03:00
Fringg 6adf70b2da fix: refresh CLASSIC_PERIOD_PRICES when admin changes PRICE_*_DAYS or SALES_MODE
CLASSIC_PERIOD_PRICES was built once at import time and never updated,
causing classic mode to always show hardcoded defaults instead of
admin-configured prices.
2026-03-14 22:24:08 +03:00
SayonaraQ 2d204275da Fix race payment cart 2026-03-14 20:11:47 +03:00
Legacyyy777 ebee8348ca fix: implement case-insensitive email checks in authentication and user retrieval
Updated email queries in authentication routes and user CRUD operations to be case-insensitive. This change ensures that email comparisons ignore case, improving user experience and preventing potential registration/login issues with differently cased emails.
2026-03-14 04:39:11 +05:00
c0mrade 06954c1711 Merge pull request #2735 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.2
2026-03-14 00:18:51 +03:00
github-actions[bot] 5e04e2a020 chore(main): release 3.32.2 2026-03-13 21:17:32 +00:00
c0mrade 08d69fb47f Merge pull request #2734 from BEDOLAGA-DEV/dev
Dev
2026-03-14 00:17:06 +03:00
c0mrade 3306e02902 fix: add nested selectinload and referrer eager loading to prevent MissingGreenlet
Added selectinload(UserPromoGroup.promo_group) nested under
user_promo_groups to prevent lazy-load in get_primary_promo_group().
Added selectinload(User.referrer) for format_referrer_info().
Broadened except clause in format_referrer_info as safety net.
2026-03-14 00:14:42 +03:00
c0mrade 14dceaa39f fix: silence PARTICIPANT_ID_INVALID error in channel subscription check
Handle PARTICIPANT_ID_INVALID same as 'user not found' — expected for
users who authenticated via Telegram Login Widget but never interacted
with the bot or channel directly.
2026-03-13 21:39:39 +03:00
c0mrade 5442f288d4 fix: add selectinload to user lock queries to prevent MissingGreenlet
lock_user_for_update, subtract_user_balance, and add_user_balance use
select(User).with_for_update().populate_existing which expires loaded
relationships. Added selectinload for subscription, user_promo_groups
and promo_group to prevent lazy-load in async context.
2026-03-13 21:39:31 +03:00
c0mrade 5bf4aeb31e Merge pull request #2733 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.1
2026-03-13 19:17:37 +03:00
github-actions[bot] 7356921eeb chore(main): release 3.32.1 2026-03-13 16:11:39 +00:00
c0mrade f24337fb41 Merge pull request #2732 from BEDOLAGA-DEV/dev
Dev
2026-03-13 19:11:14 +03:00
c0mrade 69a38dad25 fix: invalid ISO date format in node usage stats API call
datetime.now(UTC).isoformat() produces +00:00 suffix, appending Z
created invalid +00:00Z format causing RemnaWave API 500 errors.
Use .replace('+00:00', 'Z') instead of concatenation.
2026-03-13 18:58:36 +03:00
c0mrade aa3459b846 fix: platega webhook ID fallback for SBP and card payments
SBP sends `id`, cards send `transactionId`. Use fallback chain
to resolve transaction ID from all known field variants.
2026-03-13 18:41:14 +03:00
c0mrade 4d695be7d5 fix: resolve MissingGreenlet in switch_tariff endpoint
Use local subscription variable and db.refresh() to avoid lazy-load
of expired relationship after subtract_user_balance invalidates
the User identity map entry.
2026-03-13 18:30:32 +03:00
Egor b8fcbc7661 Merge pull request #2729 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.32.0
2026-03-13 06:19:11 +03:00
github-actions[bot] 96042782d9 chore(main): release 3.32.0 2026-03-13 03:18:41 +00:00
Egor a625eaae4f Merge pull request #2728 from BEDOLAGA-DEV/dev
Dev
2026-03-13 06:18:08 +03:00
Egor 869fe06831 Merge pull request #2727 from BEDOLAGA-DEV/main
w
2026-03-13 06:10:27 +03:00
Fringg a5fbd7400f fix: user deletion FK error + connected_squads None TypeError
Bug 1: DELETE /cabinet/admin/users/{id}/full failed with
"saved_payment_methods_user_id_fkey" FK violation.
Root cause: delete_user_account() didn't clean up saved_payment_methods
and riopay_payments before deleting the user row.
Fix: add DELETE for both tables before final user deletion.

Bug 2: show_user_management crashed with TypeError on
len(subscription.connected_squads) when connected_squads was None.
Root cause: remnawave_webhook_service explicitly set connected_squads=None
when clearing subscription data, but 5 call sites assumed it was always a list.
Fix: change None assignment to [] + add "or []" guards at all 5 call sites.
2026-03-13 06:08:54 +03:00
Egor 995d66483b Delete docs/plans directory 2026-03-13 05:57:37 +03:00
Egor 5c77bd7a0f Merge pull request #2726 from BEDOLAGA-DEV/feat/unified-pricing-engine
Feat/unified pricing engine
2026-03-13 05:55:27 +03:00
Fringg 04697fd4cb style: ruff format 8 files 2026-03-13 05:53:02 +03:00
Fringg c9f2dffabf fix: address 6-agent review findings for PricingEngine
H1: log error when tariff_id set but tariff relationship not loaded
H2: warn on CLASSIC_PERIOD_PRICES→PERIOD_PRICES fallback
M1: fix float division in miniapp tariff purchase (use PricingEngine.apply_discount)
M2: fix format_period Russian pluralization for teen-hundreds (111-119, etc.)
M3: deduplicate _resolve_discount_percent — import from pricing_utils
M4: fix N+1 queries in compute_simple_subscription_price (batch fetch)
M5: add period_days validation tests (negative, zero, float)
M6: add user=None tests for tariff and classic modes
M7: fix float division in calculate_prorated_price (use // instead of /)
L1: add context to _calculate_servers_price error log
L2: add comment clarifying ClassicBreakdown.group_discount_pct type
L3: add test for original_total property
L4: inline _apply_percentage_discount wrapper in subscription_purchase_service
L5: replace global _server_id_counter with itertools.count() in tests
2026-03-13 05:45:46 +03:00
Fringg fe4e6acb53 refactor: unify first-purchase discount algorithm with PricingEngine
apply_percentage_discount now delegates to PricingEngine.apply_discount
(floor division). Removes ruble-rounding that caused inconsistency between
first-purchase and renewal pricing.

subscription_purchase_service._apply_percentage_discount now delegates
to the shared apply_percentage_discount.

All 60+ callers across handlers, keyboards, cabinet, miniapp, balance
automatically use the unified algorithm without code changes.
2026-03-13 05:30:44 +03:00
Fringg e24b911283 refactor: migrate all callers to pricing_engine singleton + fix miniapp discount
- 13 PricingEngine() instantiation sites → import pricing_engine singleton
- miniapp _apply_promo_discount now delegates to PricingEngine.apply_discount
  (fixes float division vs floor division inconsistency)
2026-03-13 05:22:18 +03:00
Fringg b551def340 refactor: add typed breakdowns + module-level singleton to PricingEngine
- TariffBreakdown and ClassicBreakdown frozen dataclasses for type safety
- Module-level `pricing_engine` singleton eliminates repeated instantiation
- breakdown remains dict[str, Any] at runtime for backward compatibility
2026-03-13 05:22:10 +03:00
Fringg 5e9a462261 refactor: extract shared formatting helpers into app/utils/formatting.py
Consolidate duplicated _format_traffic, _format_price_kopeks, _format_period
from tariff_purchase.py and admin/tariffs.py into a shared module.
2026-03-13 05:22:04 +03:00
Fringg 3a3bd9d499 test: expand PricingEngine tests + update CryptoBot payment tests
- Add 45 unit tests covering tariff/classic modes, discounts, edge cases
- Update CryptoBot payment tests for new PricingEngine integration
- Add original_total identity tests for both pricing modes
2026-03-13 05:12:47 +03:00
Fringg 75dbd2b4fc refactor: migrate remaining callers to PricingEngine + cleanup dead CRUD
- Migrate bot purchase handlers, menu, admin users to PricingEngine
- SubscriptionRenewalService.finalize() accepts both old and new pricing types
- Remove dead subscription CRUD pricing functions (get_subscription_renewal_cost etc.)
- Remove dead pricing_utils functions
2026-03-13 05:12:32 +03:00
Fringg b4ef52caa4 fix: payment providers — lock_user_for_update + commit=False atomicity
All payment providers now use lock_user_for_update before balance mutations
and commit=False pattern for atomic payment status + fulfillment.
Tribute service refund also uses proper locking.
2026-03-13 05:12:15 +03:00
Fringg ae99358ae9 fix: pricing audit — display/charge parity, race conditions, balance locks
M-2: tariff_purchase.py — _apply_promo_discount delegates to PricingEngine,
     _get_user_period_discount returns (group_pct, offer_pct, combined),
     all ~15 call sites updated for display/charge price parity

M-4: miniapp switch_tariff — add FOR UPDATE lock on subscription,
     commit=False for atomic balance+transaction, emit_transaction_side_effects

M-6: CryptoBot — defer status commit (commit=False) so webhook retry works
     if fulfillment fails

WARNING: add lock_user_for_update before balance_kopeks mutations in
     contest_attempt_service, wheel_service, admin/referrals,
     account_merge_service, cabinet/routes/contests
2026-03-13 05:11:59 +03:00
Fringg 08bea704de fix: address review findings from 5-agent audit
- Add period_days validation (> 0) in PricingEngine
- Add int() cast for tariff period_prices (prevent JSON type errors)
- Fix structlog.get_logger(__name__) in pricing_engine
- Use pricing.original_total property instead of manual reconstruction
- Add CryptoBot price decrease audit logging
- Remove stale cart price fallback in auto-purchase (fail instead)
- Fix _apply_promo_discount_for_tariff to use PricingEngine.apply_discount
- Remove dead code: _get_tariff_price_for_period, _get_countries_price,
  calculate_addon_price_with_remaining_period, _resolve_addon_discount_percent
2026-03-13 05:11:35 +03:00
Fringg 18e2e7841a fix: add period_days whitelist validation and type annotations
Security fix: cabinet /renew endpoint now validates period_days against
available periods (tariff or settings), preventing arbitrary period abuse.

Also:
- Add proper type annotations (AsyncSession, Subscription, User) to PricingEngine
- Add max(0, final_total) guard in both tariff and classic modes
- Type breakdown field as dict[str, Any]
2026-03-12 23:27:51 +03:00
Fringg 652b6dabde refactor: migrate menu.py renewal pricing to PricingEngine
Replace 3 renewal_service.calculate_pricing() calls with
PricingEngine.calculate_renewal_price() in the balance activation handler.
finalize() already supports RenewalPricing via duck typing.
2026-03-12 23:15:37 +03:00
Fringg c9a9816daa refactor: remove dead pricing code and fix miniapp classic mode
- Remove SubscriptionService.calculate_renewal_price (zero callers, replaced by PricingEngine)
- Remove SubscriptionService.calculate_renewal_price_with_months (zero callers)
- Remove _calculate_subscription_renewal_pricing wrapper in miniapp (zero callers)
- Fix miniapp classic mode: pass PricingEngine result directly to finalize() instead of old wrapper
- Fix potential NameError: pricing_snapshot in cryptobot path used undefined 'pricing' variable
- Net: -396 lines of duplicate pricing logic
2026-03-12 23:12:59 +03:00
Fringg 49c0f3fc10 refactor: migrate admin user price calculation to PricingEngine
Replace SubscriptionService.calculate_renewal_price() with PricingEngine
in _calculate_subscription_period_price for admin panel.
2026-03-12 23:04:29 +03:00
Fringg cb43acab31 refactor: migrate miniapp renewal display + execute to PricingEngine 2026-03-12 23:00:58 +03:00
Fringg f59b215645 style: fix import sorting and formatting after lint
ruff auto-fix for import ordering in cabinet/subscription.py and
formatting adjustments across changed files.
2026-03-12 22:58:35 +03:00
Fringg 3efa24bab3 refactor: make finalize() accept both old and new pricing types
SubscriptionRenewalService.finalize() now supports both
SubscriptionRenewalPricing and RenewalPricing from PricingEngine.
Adapts access to promo_discount_value, server_ids, and
servers_individual_prices via duck typing.
2026-03-12 22:58:08 +03:00
Fringg bd2e93a6a5 refactor: migrate cart auto-purchase to PricingEngine (fresh calc)
Replaces stale cart-based pricing and _apply_promo_discount_for_tariff
(4th discount formula with float division) with fresh PricingEngine
calculation. Falls back to saved cart price on PricingEngine error.
2026-03-12 22:51:03 +03:00
Fringg 978f68e7be refactor: migrate recurrent and monitoring services to PricingEngine
Mechanical re-point of calculate_renewal_price calls to use unified
PricingEngine. Both services now get consistent pricing with correct
discount formulas and server fallback behavior.
2026-03-12 22:50:13 +03:00
Fringg 28fc36dca4 refactor: migrate cabinet renewal display + execute to PricingEngine
Replaces inline pricing logic in get_renewal_options and renew_subscription
with unified PricingEngine.calculate_renewal_price(). Fixes:
- Wrong discount formula (int(p*(100-d)/100) vs integer floor division)
- Missing servers/traffic costs in classic mode display
- Inconsistent discount stacking between display and execute paths
2026-03-12 22:49:22 +03:00
Fringg 1660b24f98 fix: add per-category discounts and months multiplier to classic mode
Classic mode now correctly:
- Applies separate promo group discounts per category (period, servers,
  traffic, devices) via promo_group.get_discount_percent(category, days)
- Multiplies servers/traffic/devices monthly prices by months_in_period
- Applies promo offer discount to entire subtotal after per-category discounts
- Tracks total group discount as sum of per-category discounts
2026-03-12 22:46:34 +03:00
Fringg acf27a1023 refactor: migrate bot renewal execute to PricingEngine
Replace ~95 lines of manual pricing calculation in confirm_extend_subscription
with PricingEngine.calculate_renewal_price. Removes per-component discount
logic (period, servers, devices, traffic with separate category discounts,
months multiplication, and validate_pricing_calculation check). Downstream
logic preserved: balance check, cart save, subtract_user_balance,
subscription update, Remnawave sync, transaction creation, and admin
notification all use pricing.final_total and pricing.promo_offer_discount.

Removes unused imports: _apply_promo_offer_discount, validate_pricing_calculation.
2026-03-12 22:41:41 +03:00
Fringg ce82c2c009 refactor: migrate bot renewal display to PricingEngine
Replace manual per-component price calculation in handle_extend_subscription
with PricingEngine.calculate_renewal_price. This eliminates ~55 lines of
duplicated pricing logic (period, servers, devices, traffic calculations with
separate category-specific promo group discounts and months multiplication)
in favor of a single PricingEngine call per period. Also fixes double-application
of promo offer discount that existed in the old code path.
2026-03-12 22:37:55 +03:00
Fringg e6ebc6722d refactor: migrate try_auto_extend_expired to PricingEngine
Replace SubscriptionService.calculate_renewal_price() call in
try_auto_extend_expired_after_topup with PricingEngine.calculate_renewal_price().
Add structured log with pricing breakdown after calculation.
All downstream business logic (balance check, deduction, extend) unchanged.
2026-03-12 22:32:41 +03:00
Fringg 02e5401327 feat: implement calculate_renewal_price with tariff and classic modes
Add the main public method calculate_renewal_price to PricingEngine,
routing to _calculate_tariff_mode or _calculate_classic_mode based on
whether the subscription has a linked tariff. Both modes apply stacked
discounts (promo-group then promo-offer). Classic mode tries
CLASSIC_PERIOD_PRICES first, falling back to PERIOD_PRICES. Adds 8
new tests covering both modes, discounts, extra devices, and fallback.
2026-03-12 22:29:44 +03:00
Fringg c3bb63ffed feat: add CLASSIC_PERIOD_PRICES to config
Add a standalone dict that always reflects env PRICE_*_DAYS settings,
independent of tariffs mode. Unlike PERIOD_PRICES (which may use DB
tariff prices), CLASSIC_PERIOD_PRICES is the canonical source for
classic (non-tariff) subscription pricing. Includes refresh helper.
2026-03-12 22:29:37 +03:00
Fringg 88369eec50 feat: add _calculate_servers_price (fixed fallback) and _calculate_traffic_price
_calculate_servers_price ALWAYS uses real server.price_kopeks even when
is_available=False or is_full=True, fixing the silent zero-price bug.
_calculate_traffic_price separates base from purchased GB to prevent
purchased top-ups from inflating the tier lookup.
2026-03-12 22:20:48 +03:00
Fringg 83ca51cd5b feat: add RenewalPricing dataclass and PricingEngine discount methods 2026-03-12 22:18:08 +03:00
Egor f9dad615ee Merge pull request #2721 from FireWookie/feature/recurrent_method_inline
Отображение привязанных карт в разделе в боте
2026-03-12 20:28:31 +03:00
Fringg ba049ca017 fix: resolve merge conflict with dev (accept calc_device_limit_on_tariff_switch) 2026-03-12 20:27:48 +03:00
Fringg 585baaf63c fix: harden remnawave API error handling and YooKassa user cross-validation
- remnawave_api: use str() before .lower() to handle non-string API messages
- yookassa recovery: cross-validate user_telegram_id metadata against resolved
  user to prevent misattribution when legacy telegram_id fits in int32 range
2026-03-12 20:17:51 +03:00
Fringg 04197817fe fix: downgrade known-harmless RemnaWave 400s to warning level
"User already enabled" and "User already disabled" are expected
responses when reactivating subscriptions (e.g., traffic top-up on
active subscription with exhausted traffic). These should not
trigger error notifications in the admin chat.
2026-03-12 20:08:53 +03:00
Fringg b2ee6c766a fix: add missing settings import in admin_users tariff switch 2026-03-12 19:59:59 +03:00
Fringg d35ee58aa6 fix: harden YooKassa webhook recovery user lookup
- Reject user_id <= 0 early (corrupted metadata)
- Use `is None` checks instead of `or` to avoid falsy-value collisions
- Separate int parse from DB call in telegram_id fallback
- Move _INT32_MAX to module-level constant
2026-03-12 19:51:52 +03:00
Fringg 815a1d9136 fix: handle legacy telegram_id in YooKassa webhook recovery metadata
Legacy payments may store telegram_id (>int32) in metadata['user_id']
instead of internal User.id. The recovery path now:
- Detects values exceeding int32 range and queries by telegram_id
- Falls back to metadata['user_telegram_id'] if primary lookup fails
- Resolves to internal user.id before creating FK-linked payment record
2026-03-12 19:41:48 +03:00
Fringg b7775b72dc fix: guard rollback on commit flag, add flush to promo_offer_log
- subtract_user_balance: only rollback when commit=True, re-raise when
  commit=False so caller controls transaction lifecycle
- log_promo_offer_action: add db.flush() when commit=False to surface
  constraint errors immediately instead of deferring to caller's commit
2026-03-12 19:33:25 +03:00
Fringg ba54819f9c fix: atomicity refactor, review fixes, and DELETED recovery logging
- subtract_user_balance: add commit=False parameter for atomic balance+subscription ops
- extend_subscription: add commit=False parameter, propagate to clear_notifications
- wata_service: wire _MIN_EXPIRATION_MINUTES constant to actual usage
- admin_users: fix no-op ternary in sync_user_from_panel timezone normalization
- start.py: log warning when DELETED recovery zeros non-zero balance (3 locations)
- remnawave_service: preserve PromoCodeUse records and used_promocodes in force_cleanup
2026-03-12 19:26:36 +03:00
Fringg 266340aad1 fix: prevent balance loss on auto-purchase for DISABLED subscriptions and fix WATA expiration
- Block auto-purchase from stale cart when subscription is DISABLED
  (balance deduction is irreversible, Remnawave update would fail)
- Preserve user balance in force_cleanup_user_data (paid money must not be destroyed)
- Keep has_had_paid_subscription flag on cleanup (prevents promo code abuse)
- Add warning in sync_from_panel when local end_date is newer than panel
- Fix WATA payment expiration: enforce minimum 15 minutes to avoid
  hitting WATA API's exclusive lower bound (now + 10 min)
2026-03-12 19:09:16 +03:00
Fringg 8f434525eb feat: add LIMITED subscription status and preserve extra devices on tariff switch
- Add SubscriptionStatus.LIMITED for traffic-exhausted subscriptions
- Webhook user.limited now sets LIMITED directly instead of DISABLED
- Add LIMITED to reactivation, extend, resume, auto-purchase, contest eligibility
- Add traffic_exhausted error response in miniapp API
- Fix device_limit being overwritten on tariff switch in all code paths:
  admin change_tariff, user switch-tariff, miniapp, bot tariff_purchase,
  auto_purchase_service — now preserves extra purchased devices via
  calc_device_limit_on_tariff_switch() helper
- Fix truthiness checks on device_limit (0 is valid, use `is not None`)
2026-03-12 18:35:59 +03:00
Egor efa1b11db5 Merge pull request #2724 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.31.0
2026-03-12 08:30:35 +03:00
github-actions[bot] d0ce193edb chore(main): release 3.31.0 2026-03-12 05:30:07 +00:00
Egor 92d872236f Merge pull request #2723 from BEDOLAGA-DEV/dev
Dev
2026-03-12 08:29:34 +03:00
Egor a11f492801 Merge pull request #2722 from BEDOLAGA-DEV/main
ц
2026-03-12 08:25:26 +03:00
Fringg c8162505ed chore: apply ruff formatting to 4 files 2026-03-12 08:24:47 +03:00
Fringg 076290e0c1 feat: auto-sync squads to Remnawave when admin updates tariff
When admin changes allowed_squads or external_squad_uuid on a tariff,
automatically sync the new squad config to all active/trial subscriptions
in Remnawave panel via a background task (fire-and-forget).
2026-03-12 08:20:25 +03:00
firewookie 2f5674fcd7 правки линтера 2026-03-12 09:40:29 +05:00
FireWookie b9058e115a Merge pull request #8 from FireWookie/dev
Dev
2026-03-12 09:39:58 +05:00
firewookie 673afccb8c правки линтера 2026-03-12 09:35:54 +05:00
firewookie 1badb39c49 правки по импортам 2026-03-12 09:35:07 +05:00
firewookie 23ff40cd2c Отображение привязанных карт в разделе в боте 2026-03-12 09:31:46 +05:00
Fringg bf72f241d8 fix: preserve purchased devices when admin changes user tariff
Previously subscription.device_limit was blindly overwritten with the new
tariff's base limit, losing any extra devices the user had purchased.
Now extra devices are calculated from the old tariff base and carried over,
capped at tariff.max_device_limit or global MAX_DEVICES_LIMIT.
2026-03-12 06:55:56 +03:00
Fringg 12ae871653 feat: referral links now point to web cabinet instead of bot
Centralized referral link generation into settings.get_referral_link().
When CABINET_URL is configured, links use {CABINET_URL}?ref={code}.
Falls back to Telegram bot deep link when CABINET_URL is not set.

- URL-encodes referral_code for safety
- Handles CABINET_URL with existing query params (uses & vs ?)
- Guards against None referral_code in all call sites
- QR code caching uses link hash for auto-invalidation
2026-03-12 06:45:17 +03:00
Fringg 8a362db783 fix: correct skipped_count in sync-squads circuit breaker and simplify ternary
- Add missing skipped_count increment when early-aborting due to circuit breaker
- Remove redundant `new_squads if new_squads else []` (already [] when empty)
- Add comment explaining asyncio safety of shared counter mutations
2026-03-12 06:28:13 +03:00
Fringg b1e2146254 feat: add sync-squads endpoint for bulk updating subscription squads in Remnawave
POST /admin/tariffs/{tariff_id}/sync-squads updates active_internal_squads
and external_squad_uuid for all active/trial subscriptions on a tariff.

- Concurrent API calls (semaphore=5) with circuit breaker (10 consecutive failures)
- Local DB updated only on successful API response to avoid split-brain
- Error messages sanitized (details in server logs only)
- Uses joinedload to avoid N+1 query for User.remnawave_uuid
2026-03-12 06:17:13 +03:00
Fringg 68bc8eb57c fix: update promo group via M2M table so admin changes persist
The admin endpoint wrote only to the legacy users.promo_group_id FK,
which got overwritten by sync_user_primary_promo_group on the next
transaction. Now writes to user_promo_groups M2M table (authoritative
source) and re-derives the FK via sync.

Also re-raise exceptions in _sync_user_primary_promo_group to prevent
committing inconsistent state between M2M and FK columns.
2026-03-12 06:03:26 +03:00
Fringg 9957259881 fix: add post_update=True to User.referrals self-referential relationship
Without post_update, SQLAlchemy's flush ordering cannot resolve the
circular dependency when both a user and their referrer are in the same
session. This caused referred_by_id to be silently NULLed during flush,
breaking Telegram login (which eagerly loads User.referrer) and causing
apparent admin rights loss in the cabinet.

OAuth login was unaffected because it uses a bare select(User) without
eager loading the referrer relationship.

Root cause confirmed by 6 parallel investigation agents tracing the
exact code paths through get_user_by_telegram_id → selectinload →
flush → circular dependency.
2026-03-12 05:39:29 +03:00
Fringg b3f3eba575 fix: prevent account takeover via auto_login_token, ensure promo group on all purchase paths
- Gate auto_login_token generation behind is_new_account flag in all 3 locations
  (fulfill_purchase PENDING/DELIVERED paths + activate_purchase) — prevents attacker
  from buying cheapest plan with victim's email to get their session token
- Assign default promo group in all _find_or_create_user paths including telegram
  IntegrityError fallbacks (7 return paths total)
- Create transaction records for landing purchases so promo group auto-assignment
  and contest tracking work correctly
- Add _resolve_payment_method helper for enum conversion with sub-option suffix stripping
2026-03-12 05:26:16 +03:00
Fringg a798f1143e refactor: remove estimated price from balance, simplify server sync, fix HTML injection
- Remove estimated renewal price display from balance top-up screen
- Remove country name generation during server/squad sync, use original RemnaWave name as display_name
- Add html.escape() for all display_name/country name values rendered in HTML-parsed Telegram messages
2026-03-12 04:58:49 +03:00
Fringg cb5126aff8 feat: add show_in_gift toggle for tariffs in admin panel
Add a per-tariff visibility flag (show_in_gift) that controls whether
a tariff appears in the /gift section. Enforced server-side in gift
config query, gift purchase endpoint, and landing page gift purchases.

Includes Alembic migration with idempotency guard and server_default.
2026-03-12 04:15:40 +03:00
Fringg 8b35428055 fix: reactivate subscription after traffic top-up when status is EXPIRED
When traffic is exhausted, RemnaWave may send user.expired webhook setting
local status to EXPIRED (not just DISABLED). reactivate_subscription() only
handled DISABLED→ACTIVE, silently ignoring EXPIRED subscriptions. After
purchasing additional GB, the subscription stayed expired and VPN remained
blocked despite payment.

Changes:
- reactivate_subscription() now handles both DISABLED and EXPIRED→ACTIVE
  when end_date is still in the future
- Inverted null end_date guard to block reactivation (defense-in-depth)
- Added enable_remnawave_user() call after update in all traffic/device
  top-up paths to ensure panel exits LIMITED state
- Gated enable call on subscription.status == 'active' to prevent
  enabling when reactivation was a no-op
- Fixed all 12 call sites across bot handlers, cabinet routes,
  miniapp, webapi, and auto-purchase service
2026-03-12 03:57:35 +03:00
Fringg 5424d8c314 fix: add Telegram Stars payment support for gift subscriptions
- Add telegram_stars handler in create_guest_payment() using
  bot.create_invoice_link() with guest_purchase_{token} payload
- Add guest_purchase_ prefix handling in Stars pre-checkout and
  successful_payment handlers with amount tolerance check (±5%)
- Pass Bot instance to PaymentService when payment method is Stars
- Add purchase_token format validation via regex guard
2026-03-12 03:32:08 +03:00
Egor df7411138e Merge pull request #2718 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.30.0
2026-03-11 03:56:12 +03:00
github-actions[bot] 4545bef7ea chore(main): release 3.30.0 2026-03-11 00:55:56 +00:00
Egor d7eb1e776a Merge pull request #2716 from BEDOLAGA-DEV/dev
Dev
2026-03-11 03:55:35 +03:00
Egor f8fc382143 Merge pull request #2717 from BEDOLAGA-DEV/main
w
2026-03-11 03:55:10 +03:00
Fringg e67b8e448e fix: reset subscription for paid users, trial-to-paid tariff conversion, gift purchase MissingGreenlet
- Remove is_active_paid_subscription guard from reset-subscription endpoint
- Add is_trial=False and status=ACTIVE on admin tariff change (guarded by
  tariff.is_trial_available and end_date check)
- Eagerly load user_promo_groups and promo_group in gift purchase locked query
- Move user_promo_groups access inside try/except in get_primary_promo_group()
2026-03-11 03:46:39 +03:00
Fringg bca8bab433 feat: add gifts section to admin user detail API
Add GET /{user_id}/gifts endpoint returning sent and received gift
subscriptions with COUNT queries for true totals, token truncation
for security, and noload() optimization for unused relationships.
2026-03-11 03:30:12 +03:00
Fringg 2fd0f6aa4e feat: add promo group and promo offer discounts to gift subscriptions
Apply promo group and active promo offer discounts to gift purchase flow.
Discounts stack multiplicatively with max(1, price) floor. FOR UPDATE
row lock prevents concurrent promo offer double-spend. Promo offers
consumed after purchase in both balance and gateway modes.
2026-03-11 02:58:34 +03:00
Fringg 864a4ed700 fix: record transactions for free tariff switches and admin tariff changes
- Add transaction records for free tariff switches (downgrade, upgrade_cost=0) in miniapp and cabinet
- Add atomic transaction records for admin tariff changes in bot handler and cabinet API
- Use commit=False for admin flows to ensure subscription change and transaction are committed together
2026-03-11 02:17:35 +03:00
Fringg 2879996455 fix: use keyword args for Path.mkdir in asyncio.to_thread
Positional args caused mode=True(1) and exist_ok=False(default),
raising FileExistsError when directories already existed.

Fixed 8 instances: 2 in log_rotation_service, 6 in backup_service.
2026-03-11 01:22:02 +03:00
Egor 4b4fced442 Merge pull request #2714 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.29.0
2026-03-10 23:01:35 +03:00
github-actions[bot] 8859e73890 chore(main): release 3.29.0 2026-03-10 20:01:13 +00:00
Egor d79866819c Merge pull request #2713 from BEDOLAGA-DEV/dev
Dev
2026-03-10 23:00:25 +03:00
Fringg 5a62f91ca2 chore: ruff format 4 files 2026-03-10 22:57:32 +03:00
Fringg def594bbb5 fix: panel sync now updates end_date in both directions
Previously, both webhook (user.modified) and batch sync paths
only updated end_date if the panel date was LATER than the local date.
This silently blocked any date reduction from the panel, causing
the bot to show stale expiry dates after admin changes in the panel.

Now the panel is treated as authoritative — end_date is synced
in both directions (forward and backward) for ACTIVE subscriptions.
2026-03-10 22:23:39 +03:00
Fringg 641ff86bf6 fix: stars rate rounding + device/traffic purchase stats
- Stars: ceil → round в rubles_to_stars, нормализация kopeks в cabinet invoice
- Устройства/трафик: добавлен transaction_type в subtract_user_balance,
  покупки устройств и трафика теперь создают SUBSCRIPTION_PAYMENT (не WITHDRAWAL),
  что исправляет отображение в статистике продаж
2026-03-10 21:50:20 +03:00
Fringg 2e59330e95 chore: resolve uv.lock merge conflict 2026-03-10 21:19:28 +03:00
Fringg 3c96c2affd fix: 3 bugs — notification type, referral with channel sub, BOT_USERNAME
1. Admin notification showed "renewal" instead of "first purchase" for new
   users because has_had_paid_subscription was set before notification.
   All 21 call sites now pass explicit purchase_type.

2. Partner referral not counted when mandatory channel subscription enabled.
   required_sub_channel_check saved campaign_id but not referrer_id from
   campaign.partner_user_id. Also removed duplicate DB query.

3. BOT_USERNAME auto-detection moved before web server start to close
   race window on /cabinet/branding/telegram-widget endpoint.
2026-03-10 21:19:18 +03:00
c0mrade 541f64d5bc Merge pull request #2711 from FireWookie/dev_nikita
FIX PR Problems
2026-03-10 14:17:12 +03:00
firewookie e82a1ccf6d FIX PR Problems 2026-03-10 16:16:18 +05:00
c0mrade 015be30a27 Merge pull request #2710 from FireWookie/feature/riopay
FIX PR Problems
2026-03-10 14:13:25 +03:00
firewookie 6817b9e256 FIX PR Problems 2026-03-10 16:11:28 +05:00
c0mrade 59248011c2 Merge pull request #2678 from FireWookie/dev_nikita
Add: Рекуренты Юкасса + fix: скрытие нулевых бонусов в реферальной программе
2026-03-10 13:06:35 +03:00
firewookie 39d007ff3e FIX PR Problems 2026-03-10 15:01:28 +05:00
firewookie 94b211e2a7 FIX PR Problems 2026-03-10 15:00:41 +05:00
firewookie c84dbf82fc update 2026-03-10 14:50:26 +05:00
FireWookie 9ad684c8c9 Merge pull request #7 from FireWookie/dev
Dev
2026-03-10 14:42:53 +05:00
FireWookie d147be0316 Merge pull request #6 from FireWookie/dev
Dev
2026-03-10 14:42:05 +05:00
c0mrade 9281523e96 Merge pull request #2690 from FireWookie/feature/riopay
RioPay
2026-03-10 12:41:53 +03:00
firewookie fd3466b75c fix PR problems 2026-03-10 14:29:29 +05:00
FireWookie dcfd54a7cb Merge pull request #5 from FireWookie/dev
Dev
2026-03-10 14:18:49 +05:00
FireWookie 5c2e5dfaab Merge branch 'BEDOLAGA-DEV:main' into feature/riopay 2026-03-10 14:14:23 +05:00
firewookie df112f3659 fix PR problems 2026-03-10 14:08:10 +05:00
Fringg a90d2d9367 fix: 3 critical issues from second-round review
1. Balance-mode gift purchase leaked full 64-char token in response.
   Gateway path truncated to [:12] but balance path didn't. Fixed.

2. retry_stuck_pending_activation referenced GuestPurchase.updated_at
   which doesn't exist on the model. Changed to paid_at (mirrors
   retry_stuck_paid_purchases pattern).

3. clear_notifications() called db.commit() unconditionally, defeating
   commit=False in replace_subscription. Added commit parameter with
   default=True for backward compat, passed through from caller.
2026-03-10 07:09:39 +03:00
Fringg 5c34656476 fix: address review findings from 6-agent audit
1. Truncate tokens to 12 chars in all API responses (SentGift,
   PendingGift, ReceivedGift, PurchaseStatus, PurchaseResponse,
   return URL) — full token no longer leaves the server
2. Status endpoint supports prefix-based token lookup
3. create_paid_subscription/replace_subscription accept commit=False
   — activate_purchase now uses single atomic commit for subscription
   + purchase status update (fixes double-commit gap)
4. Bot handler uses flush() instead of commit() before svc_activate
   — consistent with cabinet endpoint, allows rollback on failure
5. Add retry_stuck_pending_activation() for purchases stuck in
   PENDING_ACTIVATION status (10 min threshold)
6. Add varchar_pattern_ops index for prefix queries on token column
2026-03-10 07:02:14 +03:00
Fringg 8a8337f538 fix: add minimum 8-char length check for gift token in bot deep link 2026-03-10 06:56:08 +03:00
Fringg 42b6c80a48 refactor: rename GIFTCODE_ start parameter prefix to GIFT_ 2026-03-10 06:48:49 +03:00
Fringg b30c73c300 feat: prevent self-activation of gift codes
Buyer cannot activate their own gift, both via cabinet API
(returns 400 "Cannot activate your own gift") and bot deep link
(silently skips activation).
2026-03-10 06:44:58 +03:00
Fringg 363ccce56d fix: refresh user subscription after gift activation in /start
After svc_activate creates a subscription, the user object still
has stale cached data. Refresh the subscription attribute so the
main menu immediately shows the active subscription status.
2026-03-10 06:36:33 +03:00
Fringg 0005d59da1 fix: remove begin_nested that breaks activate_purchase transaction
activate_purchase -> create_paid_subscription calls db.commit()
internally, which closes the savepoint context and causes
InvalidRequestError on subsequent db.refresh(). Replace savepoint
with a plain commit before calling svc_activate.
2026-03-10 06:33:31 +03:00
Fringg 38c6adfdb4 fix: pass full token to svc_activate instead of truncated prefix
Telegram truncates start parameters to 64 chars, so gift_token from
deep link may be a prefix. svc_activate does exact match internally,
so we must pass gift_purchase.token (full token from DB) instead.
2026-03-10 06:23:55 +03:00
Fringg 4fb72ae6e3 fix: support prefix-based gift code lookup for activation
Displayed gift codes (GIFT-XXXXXXXXXXXX) are 12-char prefixes of the
full 64-char token. Activation now accepts prefix match (min 8 chars)
so both the short display code and full token work. Also fixes Telegram
deep link truncation (64-char limit cuts the token).
2026-03-10 06:20:07 +03:00
Fringg 05bcac502e fix: code-only gifts skip fulfillment in gateway webhook + retry service
- Gateway webhook: skip fulfill_purchase() for code-only gifts (is_gift=True, no recipient)
- Retry service: exclude code-only gifts from stuck PAID retry query
- Status endpoint: return is_code_only and purchase_token for code-only gifts
2026-03-10 06:03:26 +03:00
Fringg 769d3a0b30 refactor: deduplicate gift activation in start.py
Replace inline gift activation block (30+ lines) with a call to
_activate_pending_gift_after_registration() helper. Eliminates
code duplication between existing-user and new-user activation paths.
2026-03-10 05:41:05 +03:00
Fringg 5ffce175dc feat: gift subscription code-only purchase + activation via deep link
- Add code-only gift purchase (no recipient required)
- Gift activate endpoint: accept PAID + PENDING_ACTIVATION statuses
- Bot deep link: /start GIFTCODE_{token} auto-activation for new and existing users
- Add _activate_pending_gift_after_registration() helper with savepoint isolation
- Security: FOR UPDATE on activation queries to prevent race conditions
- Security: rate limiting on activate, ownership check before status leak
- Security: uniform 404 responses to prevent token enumeration
- Add selectinload for tariff/user/buyer relationships in all gift queries
- Add .limit(100) to pending gifts query
- Make recipient_type/recipient_value optional in GiftPurchaseRequest schema
2026-03-10 05:37:41 +03:00
Egor 1a2f0fcbe8 Merge pull request #2709 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.28.1
2026-03-10 03:39:48 +03:00
github-actions[bot] fd1e728396 chore(main): release 3.28.1 2026-03-10 00:39:15 +00:00
Egor ec41d65501 Merge pull request #2708 from BEDOLAGA-DEV/dev
Dev
2026-03-10 03:38:54 +03:00
Egor 5212877801 Merge pull request #2707 from BEDOLAGA-DEV/main
w
2026-03-10 03:37:02 +03:00
Fringg bc9003c336 chore: ruff format 2026-03-10 03:36:32 +03:00
Fringg fcdeff1ee5 fix: migrate pricing to days-based proration, fix promo revenue leaks, fix admin panel bugs
- Migrate all addon pricing (devices, traffic, countries) from months-based to days-based proration
- Remove get_remaining_months() utility, use days_left / 30 consistently
- Fix promo/campaign balance bonuses counted as revenue in reports (add REAL_PAYMENT_METHODS filter)
- Fix partner stats, campaign stats, miniapp stats, referral fraud detection promo deposit leaks
- Fix admin balance history showing deductions with + sign (use -abs for expense types)
- Fix promo offer deactivation returning 400 for non-promocode offers
- Fix daily tariff renewal requesting 30-day renewal instead of 1-day purchase
2026-03-10 03:31:07 +03:00
Egor bcc35d6e22 Merge pull request #2706 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.28.0
2026-03-09 23:41:57 +03:00
github-actions[bot] b850e81897 chore(main): release 3.28.0 2026-03-09 20:41:35 +00:00
Egor 4d9e42c3f1 Merge pull request #2705 from BEDOLAGA-DEV/dev
Dev
2026-03-09 23:41:08 +03:00
Egor 834a0478ae Merge pull request #2704 from BEDOLAGA-DEV/main
w
2026-03-09 23:39:56 +03:00
Fringg 0e968987fb style: format guest_purchase_service.py with ruff 2026-03-09 23:39:24 +03:00
Fringg acd2cff9ca style: format inline.py with ruff 2026-03-09 23:38:34 +03:00
Fringg 69dbd6a2df fix: enforce HTTPS for webapp mode, deduplicate keyboard builder, fix long line 2026-03-09 23:37:35 +03:00
Fringg 497a8ee5b5 feat: add open_in setting for custom buttons (external browser / webapp) 2026-03-09 23:33:18 +03:00
Fringg dd8d7f6920 feat: add cabinet menu layout editor with row arrangement, custom URL buttons, and drag-and-drop reordering
- Add menu_layout_cache.py for CABINET_MENU_LAYOUT in-process cache
- Add admin_menu_layout.py routes (GET/PUT/POST reset) with merged view
- Rewrite _build_cabinet_main_menu_keyboard to use cached row layout
- Support custom URL buttons with style, emoji, labels, enabled toggle
- Atomic dual-key DB writes for layout + button styles
- Add language button to default layout and DEFAULT_BUTTON_STYLES
- Pydantic validation with Literal types, max_length, duplicate ID checks
- Register routes and cache loading in bot startup
2026-03-09 23:07:32 +03:00
Fringg b9089e693f fix: normalize threshold 0→NULL in create_promo_group for consistency 2026-03-09 22:16:30 +03:00
Fringg b815abf2b1 fix: loyalty tiers current status based on spending, not assigned group
- current_tier_name and is_current now determined by highest achieved
  tier threshold instead of user's assigned promo group
- Backend update_promo_group converts threshold 0 to NULL for clean state
2026-03-09 22:08:41 +03:00
Fringg 95a32e8574 fix: payment gateway issues — YooKassa polling, PAL24 card 500
- YooKassa: return local_payment_id instead of UUID for frontend polling
  (parseInt on UUID produced wrong ID → eternal spinner)
- PAL24: remove unsupported payment_method param from API call
  (cabinet and miniapp routes — URL selection is client-side)
2026-03-09 21:53:53 +03:00
Fringg cd04f3b622 feat: implement gateway payment for gifts, persist recipient warning
- Replace 501 stub with full gateway payment flow via PaymentService
- Move telegram username pre-check (DB-first) above gateway/balance branch
- Add recipient_warning column to GuestPurchase model + migration 0034
- Return warning in gift purchase status endpoint
- Add db.refresh(purchase) after commit in gateway branch
2026-03-09 21:25:49 +03:00
Fringg 6a4140e3e2 fix: harden gift subscription feature after multi-agent review
- Add self-gift prevention (telegram username + email)
- Unify 404 response on purchase status (eliminate token oracle)
- Add period_days upper bound (le=3650) in schema
- Handle NULL paid_at in retry query with or_()
- Capture purchase_token before fulfill_purchase (session safety)
- Upgrade Bot API pre-check logging to warning level
- Add exc_info=True for monitoring retry errors
- Add database indexes: (user_id, is_gift, status), (status, paid_at), buyer_user_id
- Use datetime instead of str for created_at in PendingGiftResponse
- Align GuestPurchase model __table_args__ with all migrations
2026-03-09 20:34:39 +03:00
Fringg f80b058380 fix: negate GIFT_PAYMENT amounts and remove dead code 2026-03-09 18:47:36 +03:00
Fringg 6a61b09575 feat: add cabinet gift subscription API routes and schemas
Create Pydantic schemas for gift config/purchase/status responses,
FastAPI routes for GET /gift/config, POST /gift/purchase, and
GET /gift/purchase/{token}, update GuestPurchaseService.create_purchase
to accept optional source and buyer_user_id params with nullable landing,
and register the gift router in the cabinet routes.
2026-03-09 18:44:38 +03:00
Fringg 759bfe1bdb feat: add CABINET_GIFT_ENABLED branding toggle 2026-03-09 18:41:07 +03:00
Fringg 0936d4a7f6 feat: add source and buyer_user_id fields to GuestPurchase model
- Add source column (landing/cabinet) to track purchase origin
- Add buyer_user_id FK to link cabinet gift purchases to authenticated users
- Add GIFT_PAYMENT to TransactionType enum for balance deductions
- Add foreign_keys disambiguation to existing user relationship
- Migration 0032: adds columns, index on source, FK constraint
2026-03-09 18:35:52 +03:00
firewookie c7bebae14a back docker 2026-03-09 14:11:50 +05:00
firewookie 8ee287f8cd remove locales from git 2026-03-09 14:11:34 +05:00
firewookie 6f99b83c61 remove locales from git 2026-03-09 14:09:16 +05:00
firewookie 1a3c6fafa3 update saved payment method 2026-03-09 14:07:42 +05:00
firewookie be2ec091a6 Правки по замечаниям 2026-03-09 14:06:00 +05:00
firewookie d4dc0b76ba fix linter 2026-03-09 13:38:46 +05:00
firewookie 2dfd0e6452 Правки по замечаниям 2026-03-09 13:34:25 +05:00
Fringg 680c22c017 fix: support Telegram OIDC id_token in account linking endpoint
Email users couldn't link Telegram when OIDC was enabled because
the link_telegram endpoint only accepted init_data and Login Widget
data. Add id_token field to LinkTelegramRequest with JWKS validation,
replay protection, and rate limiting.
2026-03-09 06:23:02 +03:00
Egor 8c9efd5127 Merge pull request #2703 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.27.0
2026-03-09 05:08:42 +03:00
github-actions[bot] 4663097a24 chore(main): release 3.27.0 2026-03-09 02:07:55 +00:00
Egor dc51a55c98 Merge pull request #2702 from BEDOLAGA-DEV/dev
Dev
2026-03-09 05:07:31 +03:00
Fringg 275f249bbd fix: encode payment status in provider return URLs and wire failed_url
- Add &status=success/failed to cabinet return URLs for instant UX feedback
  without needing API auth in external browser
- Platega: pass cabinet_failed_url (was hardcoded to server URL)
- Heleket: add success_url param, pass cabinet_success_url for url_success
- WATA: add failed_url param, pass cabinet_failed_url to failRedirectUrl
- CloudPayments: add failed_url param, pass cabinet_failed_url
- Strip trailing slash from CABINET_URL for safety
2026-03-09 04:59:56 +03:00
Fringg 7a9264b173 fix: latest-payment endpoint returns all payments, not just pending
The /latest endpoint was using list_recent_pending_payments which only
returns unpaid payments. By the time the user returns from the payment
provider, the webhook has already marked the payment as paid, so the
endpoint returned 404. Now queries the payment table directly without
filtering by is_paid status.
2026-03-09 04:41:25 +03:00
Fringg 32d58b04b9 fix: add method query param to return_url and latest-payment endpoint
Payment providers redirect to external browser where sessionStorage is
unavailable. Now includes method in return_url query params and adds
GET /pending-payments/{method}/latest endpoint so TopUpResult can poll
payment status without sessionStorage data.
2026-03-09 04:34:14 +03:00
Fringg 7ca96195a7 fix: pass cabinet return_url to payment providers for top-up redirects
Payment providers were redirecting users back to the bot after completing
cabinet top-up payments. Now passes CABINET_URL/balance/top-up/result as
return_url to YooKassa, Platega, Heleket, WATA, and CloudPayments.
2026-03-09 04:16:10 +03:00
Fringg 5752b5e7c6 chore: apply ruff formatting to 4 files 2026-03-09 03:02:32 +03:00
Egor e6f577697b Merge pull request #2701 from BEDOLAGA-DEV/main
w
2026-03-09 03:01:02 +03:00
Fringg f4a776319e fix: add table existence guards to migrations for optional payment tables
Migrations 0019, 0022, 0031 crashed with UndefinedTableError when
payment provider tables (e.g. kassa_ai_payments) or contest_templates
did not exist. Added _table_exists() checks before ALTER/DROP operations.
2026-03-09 02:57:01 +03:00
Fringg 2649e12f64 fix: use parsed HTML length for Telegram caption limit checks
Replace hardcoded raw HTML length checks (len(text) <= 900/1000/1024)
with centralized caption_exceeds_telegram_limit() that strips HTML tags
and unescapes entities before measuring against the real 1024-char limit.
Fixes logo disappearing when promo discounts add HTML markup to captions.
2026-03-09 02:43:43 +03:00
Fringg 4a5cacda38 fix: resolve concurrent AsyncSession bug and sanitize error responses
- Fix critical concurrency issue in propagate_tariff_squads: preload
  users/tariffs before asyncio.gather, use single API client, no DB
  operations inside gather, single commit after all API calls
- Replace all str(e) leaks in admin_users.py with sanitized messages
- Fix double callback.answer by using callback.message.answer for
  failure alerts
- Move PropagateSquadsResult to module level, use field(default_factory)
- Compute traffic_strategy once before gather instead of N times
- Add warning logging on tariff refresh failures
- Reset synced counters on commit failure for accurate reporting
2026-03-09 02:26:38 +03:00
Fringg 79161eaae4 refactor: move squad propagation to service layer with parallel Remnawave sync
- Move _propagate_squads_to_subscriptions from handler to
  SubscriptionService.propagate_tariff_squads()
- Use asyncio.gather with semaphore (concurrency=5) for parallel
  Remnawave API calls instead of sequential O(N)
- Track failed subscription IDs for better observability
- Fix get_all_server_squads limit=50 default in admin handlers
  (now limit=10000 to prevent silent truncation)
- Add docstring to force_panel_delete parameter
- Return PropagateSquadsResult dataclass with total/synced/failed_ids
2026-03-09 01:58:23 +03:00
Fringg 289cbe966e fix: conditional log messages and sanitize panel_error in user deletion
- Log disable success/failure separately instead of unconditional success
- Sanitize panel_error to not leak internal exception details to API
- Make fallback disable log conditional on actual result
2026-03-09 01:52:37 +03:00
Fringg 7ccfb66690 fix: propagate tariff squad changes to existing subscriptions and fix user deletion from Remnawave
Squad toggle: when admin changes servers for a tariff, the changes now
propagate to all active/trial subscriptions and sync to Remnawave panel.
Previously only took effect on new purchases.

User deletion: full delete from Cabinet now actually deletes from Remnawave
panel. Previously lied about panel deletion status and skipped deletion
for users with active subscriptions.
2026-03-09 01:46:45 +03:00
Fringg 536525c9c0 fix: admin tariff server selection - 64-byte overflow and callback routing conflicts
1. Shortened squad toggle callback_data from admin_tariff_toggle_squad
   to trf_sq to stay within Telegram's 64-byte callback_data limit
   (was overflowing at tariff_id >= 10)

2. Fixed toggle_tariff handler capturing squad/promo/daily/traffic_topup
   toggle callbacks by adding exclusion filters

3. Fixed admin_tariff_edit_traffic capturing admin_tariff_edit_traffic_topup
   by registering traffic_topup handler before traffic handler

4. Fixed admin_tariff_delete capturing admin_tariff_delete_confirm
   by registering delete_confirm handler before delete handler
2026-03-09 01:23:03 +03:00
Fringg 4186159a61 fix: keep DB session alive in Tribute payment notification handler
The _send_success_notification method was closing the DB session (via
break) before calling send_cart_notification_after_topup, causing all
post-topup auto-renewal logic to silently fail for Tribute payments.

Moved break after all work is done so the session stays open during
auto-renewal operations. Added None guard for user lookup.
2026-03-09 01:07:47 +03:00
Fringg 6349b2f442 fix: align tariff pricing with calculate_renewal_price reference
- balance/main.py: single period discount on combined total (base + devices),
  add promo-offer discount, fix device_limit fallback to tariff_device_limit
- pricing.py: same combined discount + promo-offer, proper device_limit
  fallback matching reference (is not None check)
- admin/users.py: delegate to calculate_renewal_price() which handles both
  tariff and classic modes correctly, removing classic-only calculate_subscription_price
- menu.py: use renewal_service.calculate_pricing() for both price check and
  charge to ensure consistency, add try/except with user-facing error,
  show actual charged amount in success message
2026-03-09 00:39:16 +03:00
Fringg bfbefeb1e2 fix: renewal cost estimate double-counts servers and traffic in tariff mode
In tariff mode, period_prices already includes servers and traffic costs.
But show_payment_methods() and get_subscription_cost() were using the classic
additive formula, adding server and traffic prices on top of the tariff price.

Example: 49₽ tariff + 150₽ server + 150₽ traffic = 349₽ shown, should be 49₽.

Now both functions detect tariff mode and only add extra device costs beyond
the tariff's device_limit. Classic mode formula unchanged.
2026-03-08 23:14:57 +03:00
Fringg f9f07f360c fix: enforce tariff device_price and max_device_limit across all purchase paths
The miniapp, legacy cabinet endpoint, auto-purchase service, and Telegram bot
handlers were using only global settings (PRICE_PER_DEVICE, MAX_DEVICES_LIMIT)
for device purchases, completely ignoring tariff-level device_price_kopeks and
max_device_limit. This allowed users to buy devices when tariff price was 0
(should be blocked) and exceed the tariff's max device limit.

Fixed in all 4 code paths:
- miniapp _build_subscription_settings + update_subscription_devices_endpoint
- cabinet legacy POST /devices (+ added subscription status check, RemnaWave sync)
- subscription_auto_purchase_service._auto_add_devices
- telegram bot handlers confirm_change_devices, execute_change_devices, confirm_add_devices
2026-03-08 23:08:32 +03:00
Fringg 770b31d3d0 feat: auto-resume disabled daily subscriptions on balance topup
- Add try_resume_disabled_daily_after_topup() for instant resume when balance is topped up
- Fix all 5 resume paths to charge daily fee BEFORE activating subscription
- Remove unsafe inline auto-resume from add_user_balance() that bypassed fee charging
- Add NULL-safe is_daily_paused filter in subscription queries
- Use create_remnawave_user() instead of enable_remnawave_user() for full VPN panel sync
- Add DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP localization key (ru, en, fa, zh, ua)
2026-03-08 21:36:17 +03:00
Egor ae710f41fc Merge pull request #2700 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.26.0
2026-03-08 15:47:30 +03:00
github-actions[bot] f86b8614b1 chore(main): release 3.26.0 2026-03-08 12:46:24 +00:00
Egor 61b2fcc2aa Merge pull request #2699 from BEDOLAGA-DEV/dev
Dev
2026-03-08 15:46:01 +03:00
Fringg 928e3e98f8 chore: format balance/main.py and promocode.py 2026-03-08 15:41:59 +03:00
Fringg 7dc5e4ab94 fix: auto-purchase classic extend missing device_limit and traffic_limit_gb
- Add device_limit and traffic_limit_gb to classic extend cart data in
  confirm_extend_subscription handler
- Add classic mode branch in cabinet renew_subscription to save
  device_limit and traffic_limit_gb (previously only saved for tariffs)
- Ensure device_limit >= DEFAULT_DEVICE_LIMIT when converting trial
  subscription to paid via auto-extend
- Add None guards for subscription.device_limit in both trial and
  non-trial branches of _apply_extension_updates
2026-03-08 15:31:58 +03:00
Fringg 5ebe1072c9 fix: quick topup buttons include device/server/traffic costs, broadcast button crash on media messages
- Quick amount buttons now calculate full renewal cost (base + devices + servers + traffic with discounts)
- Tariff mode uses tariff-specific device pricing (device_price_kopeks, device_limit)
- Broadcast inline buttons no longer crash with "no text in message to edit" on photo/video messages
- Media messages are now handled in _edit_with_photo: delete old message + send new text
2026-03-08 14:58:17 +03:00
Fringg 20727b1017 fix: respect send_before_menu flag for pinned messages during new user registration
All 6 registration paths now check pinned_message.send_before_menu
to send pinned message before or after the menu, matching the
existing user flow behavior.
2026-03-08 14:25:54 +03:00
Fringg f4eeb9a503 fix: multiple payment and notification bugs
- CloudPayments: add missing process_referral_topup, has_made_first_topup flag, and admin notification (matching other adapters)
- Promocode: handle TelegramBadRequest for broadcast messages without text (fallback to answer())
- Devices: unify price prorating to day-based calculation (matching cabinet behavior)
- Auth: pass Bot instance to process_referral_registration for campaign referral notifications
- Wata: remove WATA_TERMINAL_PUBLIC_ID from is_wata_enabled() (not used in API calls), make type field conditional, add transactionId webhook fallback
2026-03-08 14:17:44 +03:00
Fringg 1f664a9083 fix: remove is_active_paid_subscription guard from admin deactivation
The guard silently blocked admins from deactivating active paid
subscriptions, returning a generic error with no explanation.
Admin deactivation is intentional (with confirmation step) and
should not be prevented. The guard remains in automated processes
(monitoring, broadcast, user_service) where it makes sense.
2026-03-08 13:05:52 +03:00
Fringg 330d1cb6fe fix: gift purchase notification and activation flow
- Refresh purchase.user relationship after setting user_id to fix
  stale None value that prevented Telegram gift notifications
- Route gift purchases with expired subscriptions through
  PENDING_ACTIVATION instead of auto-activating
- Hide subscription URL from gift buyer in API response
2026-03-08 12:47:25 +03:00
firewookie 8e53b81b3d fix recurrent linter 2026-03-08 09:36:47 +05:00
firewookie 69ca37bc6e fix project 2026-03-08 09:35:10 +05:00
FireWookie 26daf9f6c8 Merge pull request #4 from FireWookie/main
Merge as main project
2026-03-08 09:32:37 +05:00
firewookie 34aae0dd26 fix formatting 2026-03-08 09:30:47 +05:00
firewookie 0551a6e23c fix migrations 2026-03-08 09:28:32 +05:00
firewookie 92cc602892 Merge remote-tracking branch 'origin/feature/riopay' into dev 2026-03-08 09:26:32 +05:00
firewookie 555b887952 Merge remote-tracking branch 'origin/dev' into dev 2026-03-08 09:23:08 +05:00
firewookie 848c9f71a2 reviewers fix 2026-03-08 09:22:44 +05:00
FireWookie 4477e03d83 Merge pull request #3 from FireWookie/main
merge as main project
2026-03-08 09:20:33 +05:00
Fringg 9ba61a0879 feat: add telegram gift notification with inline activation button
- New gift_activation handler for gift_activate:{id} callback buttons
- Send Telegram notification to gift recipients with activate button
- Add skip_notification param to activate_purchase to prevent duplicates
- Fix telegram username regex minimum length (4→5 chars) in landing routes
- Add BOT_TOKEN guard in telegram gift notification sender
- Pre-resolve notification params before commit to avoid DetachedInstanceError
2026-03-07 20:39:04 +03:00
Egor d7f05ae409 Merge pull request #2696 from BEDOLAGA-DEV/main
w
2026-03-07 17:42:20 +03:00
Egor bf2d5e48e5 Merge pull request #2694 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.25.0
2026-03-07 17:22:38 +03:00
github-actions[bot] 93bf21e55b chore(main): release 3.25.0 2026-03-07 14:21:25 +00:00
Egor fea44f5ad4 Merge pull request #2693 from BEDOLAGA-DEV/dev
Dev
2026-03-07 17:20:52 +03:00
Fringg 923b36a8b9 fix: use --frozen instead of --locked in Dockerfile to avoid version mismatch 2026-03-07 17:17:35 +03:00
Fringg a7fea86c99 chore: update uv.lock 2026-03-07 17:15:49 +03:00
Fringg fbe56c15ac style: ruff format 2026-03-07 17:13:56 +03:00
Egor 213f82b9a4 Merge pull request #2692 from BEDOLAGA-DEV/main
w
2026-03-07 17:13:03 +03:00
Fringg d72ea6b7f9 fix: remaining context_vars/SAMPLE_CONTEXTS mismatches found by agents
- traffic_reset: traffic_limit → reset_gb, current_limit_gb
- payment_received: amount → formatted_amount
- guest_activation_required: added missing is_gift to context_vars
- daily_debit SAMPLE_CONTEXTS: added missing amount_rubles, new_balance_rubles
- traffic_reset SAMPLE_CONTEXTS: aligned with runtime keys
- payment_received SAMPLE_CONTEXTS: added amount_rubles
2026-03-07 17:11:57 +03:00
Fringg c507634398 fix: align subscription_renewed/activated context_vars with runtime keys
- subscription_renewed: new_end_date → new_expires_at
- subscription_activated: end_date → expires_at
- Added traffic_limit_gb and device_limit to both types
- Fixed SAMPLE_CONTEXTS keys to match
2026-03-07 17:11:57 +03:00
Fringg c9ea2b15e9 fix: strip newlines from subject substitution, fix subscription notification context
- Defense-in-depth: strip \r\n from context values in subject line
  substitution to prevent email header injection at composition layer
- Add missing tariff_name to classic mode subscription notification context
  (prevents literal {tariff_name} in DB override templates)
- Remove SQLAlchemy model object from notification context dict
  (str() on model produces garbage in DB override templates)
2026-03-07 17:11:57 +03:00
Fringg ab5313a381 fix: align context_vars and SAMPLE_CONTEXTS with actual runtime context keys
TEMPLATE_TYPES context_vars showed wrong placeholder names to admins
(e.g. 'amount' instead of 'formatted_amount', 'balance' instead of
'formatted_balance'). SAMPLE_CONTEXTS had mismatched keys causing
test emails to render with empty values.

Fixed types: balance_topup, balance_change, autopay_success,
autopay_insufficient_funds, daily_debit, daily_insufficient_funds,
referral_bonus.
2026-03-07 17:11:57 +03:00
Fringg 351d714f2d fix: substitute sample context in admin test email for template overrides
Same bug as notification_delivery_service — send_test_email used
get_template_override (raw template) instead of get_rendered_override.
Admins testing custom templates saw literal {days_left} placeholders
instead of sample values.
2026-03-07 16:48:18 +03:00
Fringg d52c87b2b7 fix: substitute context variables in email template overrides
The send_notification method was calling get_template_override which
returns raw template HTML without variable substitution. Placeholders
like {days_left} and {expires_at} were sent to users as literal text.

Switched to get_rendered_override which properly substitutes context
variables via str.replace before wrapping in the base email template.
2026-03-07 16:40:35 +03:00
Fringg d9f9f3dca1 fix: add or [] guard to remaining connected_squads call site in fulfill_purchase
Missed the create_paid_subscription branch (line 275) in the previous
commit — now all 4 call sites consistently use `or []` to guard against
None from tariff.allowed_squads JSON column.
2026-03-07 16:34:15 +03:00
Fringg 44d46feb0a fix: correct device_limit and connected_squads in guest purchase fulfillment
- Remove `or settings.DEFAULT_DEVICE_LIMIT` from fulfill_purchase expired
  subscription branch — tariff.device_limit is NOT NULL so the `or` pattern
  would incorrectly convert 0 (unlimited) to DEFAULT_DEVICE_LIMIT
- Add `or []` guard to connected_squads in activate_purchase to match
  replace_subscription's list[str] type contract (tariff.allowed_squads
  can be None from JSON column)
2026-03-07 16:29:04 +03:00
Fringg 9e78509284 fix: handle expired subscription in guest purchase fulfillment
When a user with an expired subscription makes a landing page purchase,
the code tried to INSERT a new subscription, violating the user_id
unique constraint. Now uses replace_subscription for expired/inactive
subscriptions instead of create_paid_subscription.
2026-03-07 16:02:06 +03:00
Fringg f4ab174d32 fix: support {total_amount} placeholder in cart notification templates
Add total_amount as an alias for cart_total in format() calls so custom
locale overrides using {total_amount} are properly substituted instead
of appearing as literal text in user messages.
2026-03-07 15:50:42 +03:00
Fringg fc65e2de4c fix: use information_schema for constraint existence checks in migrations
Replace pg_class lookup with information_schema.table_constraints query
that is schema-qualified and consistent with migration 0028 pattern.
Fixes constraint detection on fresh installs where create_all() creates
constraints that pg_class lookups could miss.
2026-03-07 15:50:31 +03:00
Fringg ba335fe784 fix: use pg_class lookup for constraint existence checks in migrations
inspector.get_unique_constraints() fails to detect constraints created
by Base.metadata.create_all() on fresh installs, causing
DuplicateTableError. Query pg_class directly for reliable detection.
2026-03-07 15:38:11 +03:00
Fringg 5214f55f46 fix: drop legacy prize_days column from contest_templates
The column was left over from the old schema before prize_type/prize_value
refactoring. Its NOT NULL constraint caused INSERT failures since the
SQLAlchemy model no longer includes it.
2026-03-07 15:34:54 +03:00
firewookie a6849242ff add riopay 2026-03-07 15:35:26 +05:00
Fringg 9d5329d9d1 fix: resolve NameError in YooKassa successful payment processing
event_object was referenced in _process_successful_yookassa_payment
but never passed to the method, causing all YooKassa webhook payments
to fail. Use payment.amount_kopeks from the database model instead.
2026-03-07 13:28:25 +03:00
Fringg bbd353ff38 fix: resolve alembic migration failures on fresh database install
Migration 0001 uses Base.metadata.create_all() which creates ALL tables
from current models.py, causing subsequent migrations (0015+) to fail
with "already exists" errors when they try to re-create constraints,
indexes, columns, and tables.

Three-layer fix:

1. migrations.py: detect fresh DB (no tables) and bootstrap via
   create_all() + stamp head, bypassing all migrations entirely.

2. models.py: add EmailTemplate model, CheckConstraints to LandingPage,
   and indexes to GuestPurchase so create_all() produces a complete
   schema identical to running all 30 migrations sequentially.

3. Idempotency guards in migrations 0015-0030: _has_unique_constraint,
   _has_table, _has_index, _has_column, _has_check_constraint checks
   before DDL operations, protecting against re-runs via make migrate.
2026-03-07 13:17:04 +03:00
Fringg 11d3e637c1 feat: add configurable animated background for landing pages
Add background_config JSON field to LandingPage model, enabling
per-landing animated backgrounds (aurora, sparkles, vortex, etc).

- Add background_config column (JSON, nullable) with Alembic migration
- Add validated background_config to create/update/detail/public schemas
- Reuse ALLOWED_BG_TYPES and _validate_settings from branding module
- Strip unknown keys via whitelist, validate all fields including reducedOnMobile
2026-03-07 12:46:02 +03:00
Fringg 0ba1127469 feat: add paginated purchases list endpoint for landing pages
Add GET /admin/landings/{id}/purchases with offset/limit pagination,
optional status filter (validated against GuestPurchaseStatus enum),
tariff name join, and truncated token display.
2026-03-07 09:49:57 +03:00
Fringg 25478ced20 feat: add landing page statistics endpoint with charts data
Add GET /admin/landings/{id}/stats endpoint returning:
- Summary stats (purchases, revenue, gifts, conversion rate)
- Daily breakdown for last 30 days (purchases, revenue, gifts per day)
- Tariff distribution (purchases and revenue per tariff)

Uses case() expressions for SQLite compatibility, timezone-safe
date grouping via func.timezone('UTC', ...), and composite index
(landing_id, status, paid_at) for query performance.
2026-03-07 09:35:21 +03:00
Egor 57b95671ea Merge pull request #2686 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.24.0
2026-03-07 07:31:21 +03:00
github-actions[bot] eecf2b4183 chore(main): release 3.24.0 2026-03-07 04:30:05 +00:00
Egor 5a97fc2fa1 Merge pull request #2685 from BEDOLAGA-DEV/dev
Dev
2026-03-07 07:26:29 +03:00
Fringg 26b486cdd9 chore: sync uv.lock with pyproject.toml version bump 2026-03-07 07:18:43 +03:00
Egor ba05c5ce92 Merge pull request #2684 from BEDOLAGA-DEV/main
w
2026-03-07 07:16:05 +03:00
Fringg 372d628908 fix: remove executable bit from email_service.py 2026-03-07 07:14:52 +03:00
Fringg ceac29d5e3 style: format admin_landings.py 2026-03-07 07:13:01 +03:00
Egor 3ee108fce8 Merge pull request #2658 from thegrayfoxxx/fix_logo_from_show_qr
fix: reset QR photo when returning to referral
2026-03-07 07:09:38 +03:00
Egor de541ea1c3 Merge pull request #2682 from smediainfo/fix/email-message-id-headers
fix: add Message-ID and Date headers to outgoing emails
2026-03-07 07:08:37 +03:00
Fringg 6d65e15266 fix: read discount overrides from landing model instead of response DTO
After removing overrides from the public LandingDiscountInfo response,
_load_landing_tariffs still referenced discount.overrides which no
longer exists. Read from landing.discount_overrides directly.
2026-03-07 07:05:38 +03:00
Fringg aa7d98630d feat: add discount system for landing pages
Add time-bounded percentage discounts with per-tariff overrides
and countdown timer support for landing pages.

- Add 5 discount columns to LandingPage model (percent, overrides,
  starts_at, ends_at, badge_text) with Alembic migrations 0027-0028
- Add DB CHECK constraints for discount_percent range and date ordering
- Add discount price calculation in validate_and_calculate() and
  public landing config endpoint with consistent formula
- Add admin CRUD with Pydantic validation, cascade-clear on removal,
  merged date validation on partial updates, size bounds
- Remove discount_overrides from public API (baked into prices)
- Add size limits for allowed_tariff_ids and allowed_periods
2026-03-07 07:01:35 +03:00
Fringg 8b77cdae2c feat: add GET /admin/rbac/users endpoint for listing all RBAC users 2026-03-07 06:23:14 +03:00
Fringg c93dbec7a0 feat: add landings to permission registry
Register landings:read, landings:create, landings:edit, landings:delete
permissions so they appear in the role editor permission matrix.
2026-03-07 06:05:03 +03:00
Fringg fa21549cac fix: add activate hint to gift pending activation email link
Append ?activate=1 to success page URL in recipient notification email
for gift purchases with pending_activation status, so the frontend can
distinguish buyer from recipient and show the activate button only to
the recipient.
2026-03-07 06:00:29 +03:00
Fringg c10d6780ba feat: add external squad support for tariffs
- Add external_squad_uuid column to Tariff model with Alembic migration
- Add external_squad_uuid parameter to RemnaWave API create_user/update_user
- Pass external squad from tariff to RemnaWave on subscription creation/update
- Sync external squad in monitoring service, sync service, admin user management
- Clear external squad when tariff has none (consistent across all call sites)
- Add GET /available-external-squads endpoint with UUID validation and response model
- Update tariff schemas with UUID pattern validation
- Fix db.refresh to include tariff relationship for async safety
2026-03-07 05:44:19 +03:00
Fringg 770f19e846 fix: address review findings for guest purchase admin notifications
- Remove double html.escape on payment_method (already escaped in helper)
- Use attribute_names=['landing'] keyword arg in db.refresh for consistency
- Use async with Bot() for guaranteed session cleanup
2026-03-07 04:48:52 +03:00
Fringg dbb9757a3c feat: add admin topic notifications for landing page purchases
- New send_guest_purchase_notification() method in AdminNotificationService
  with blockquote for payment details, landing slug, buyer/recipient info
- Called from fulfill_purchase() for both DELIVERED and PENDING_ACTIVATION
- Called from activate_purchase() when pending purchase is activated
- Different titles: regular purchase, gift purchase, pending activation
- Properly typed GuestPurchase, html.escape on all user data
- Refresh purchase with ['landing'] relationship after commit
- html.escape fallback in _get_payment_method_display
- ruff formatting fixes
2026-03-07 04:44:02 +03:00
Fringg 77456efb75 fix: add X-CSRF-Token and X-Telegram-Init-Data to CORS allow_headers
The security hardening commit changed allow_headers from ['*'] to
['Authorization', 'Content-Type'], but the frontend sends X-CSRF-Token
on all POST/PUT/DELETE/PATCH requests and X-Telegram-Init-Data on all
requests. The missing headers caused preflight OPTIONS requests to fail
with 400 "Disallowed CORS origin".
2026-03-07 04:30:29 +03:00
Fringg c165cca323 fix: use get_rendered_override for proper variable substitution in guest email overrides
Admin-created email template overrides were not substituting {tariff_name},
{period_days}, {cabinet_url} etc. because get_template_override returns raw
body_html. Switched to get_rendered_override which performs variable
substitution with html.escape. Also removed dead is_existing_user from
sample context.
2026-03-07 04:26:09 +03:00
Fringg 6970340e62 feat: add quick purchase email templates to admin panel
- Register 4 guest purchase template types in admin email templates:
  guest_subscription_delivered, guest_activation_required,
  guest_gift_received, guest_cabinet_credentials
- Add sample contexts with placeholders for preview/test
- Add DB override support to send_guest_notification for all 4 types
2026-03-07 04:13:41 +03:00
Fringg 9217352685 fix: remove subscription connection links from guest purchase emails
Replace VPN subscription URLs with cabinet links in all email templates:
- GUEST_SUBSCRIPTION_DELIVERED: unified to always show cabinet link
- GUEST_GIFT_RECEIVED: replaced subscription URL with cabinet link
Both self-purchase and gift flows now only include cabinet links.
2026-03-07 04:10:27 +03:00
Fringg a539d69854 fix: code style and formatting from review
- Format long dict literal in admin_landings.py
- Add blank line after validator in admin_payment_methods.py
- Fix ruff E203 slice spacing in landing.py
- Fix long line wrapping in payment_service.py
2026-03-07 03:50:15 +03:00
Fringg e96fe1ecd8 fix: comprehensive security hardening from 7-agent review
Schema validation:
- Add max_length to init_data (4096), widget fields (first_name 64,
  last_name 64, username 32, photo_url 512, hash 64)
- Add max_length=2048 to all token fields (verify, refresh, reset, auto-login)
- Add max_length=128 to EmailLoginRequest.password (bcrypt DoS prevention)
- Add pattern to OAuthCallbackRequest.referral_code (was missing)
- Add pattern=r'^\d{6}$' to EmailChangeVerifyRequest.code
- Add pattern/max_length to language field (ISO 639-1)

Auth endpoints:
- Add user.status check to auto-login (banned users could authenticate)
- Add exception chaining (from e) to refresh and auto-login endpoints
- Add IP rate limiting to initData, register standalone, verify email,
  forgot password, and reset password endpoints

CORS:
- Add PATCH to allow_methods in both unified_app and webapi (38+ PATCH
  endpoints were blocked for cross-origin requests)

JWKS:
- Fix race condition: move force-refresh cooldown check and cache
  invalidation inside asyncio.Lock via dedicated _force_refresh_jwks()
2026-03-07 03:47:58 +03:00
Fringg 5499ad62dc fix: add referral_code pattern validation, email login rate limiting, and Retry-After headers
- Add pattern=r'^[a-zA-Z0-9_-]+$' to referral_code in TelegramAuthRequest,
  TelegramWidgetAuthRequest, and EmailRegisterStandaloneRequest for consistency
  with TelegramOIDCAuthRequest
- Add IP-based rate limiting (10 req/min) to /email/login endpoint
- Add Retry-After: 60 header to /login/auto 429 response
2026-03-07 03:37:25 +03:00
Fringg 6495384bcf fix: transaction boundary and CORS in webapi
- Revert OIDC flush to commit before _store_refresh_token
  (matches widget/initData pattern, prevents rollback losing user updates)
- Fix CORS wildcard+credentials in webapi/app.py (same as unified_app fix)
2026-03-07 03:33:37 +03:00
Fringg 5c55662e2c fix: comprehensive security and quality fixes from 7-agent review
Security:
- CORS: disable credentials when wildcard origin, restrict methods/headers
- Token replay: Redis-based id_token dedup with TTL matching expiry
- JWT secret: warn when falling back to BOT_TOKEN
- Rate limiting: add to legacy widget endpoint (was missing)
- Retry-After: add header to all 429 responses

Quality:
- Read OIDC CLIENT_ID from DB with env fallback (admin panel works)
- Consolidate db.commit() — flush mid-handler, single commit at end
- Move get_setting_value import to top-level
2026-03-07 03:27:00 +03:00
Fringg b78c01cae9 fix: critical OIDC fixes from 7-agent review
- Fix broken import (system_settings → system_setting) that crashed
  OIDC endpoint on every request
- Extract get_setting_value to shared CRUD module
- Add JWKS force-refresh cooldown (30s) to prevent abuse
- Remove dead _OIDC_TOKEN_URL constant
- Add raise from for exception chaining
- Remove unused _photo_url variable
- Add pattern validation on referral_code field
2026-03-07 03:18:20 +03:00
Fringg 2405dc5c1b fix: read OIDC enabled setting from DB in auth endpoint
Match branding endpoint pattern — check system_settings first,
fall back to env var, ensuring admin panel toggle takes effect.
2026-03-07 03:04:42 +03:00
Fringg da1cc4fe5a fix: address code review findings for Telegram OIDC
- JWKS cache: add asyncio.Lock to prevent thundering herd, extract
  _build_public_keys helper, retry JWKS fetch on kid mismatch (key rotation)
- Remove dead code: exchange_telegram_oidc_code (unused, popup sends id_token directly)
- OIDC auth endpoint: add rate limiting, fix int() parse with try/except,
  extract last_name/photo_url/language from claims, narrow bare Exception
  to (ValueError, LookupError)
- Schema: add max_length=4096 to id_token field
- Branding: read TELEGRAM_OIDC_ENABLED from DB settings with env fallback
2026-03-07 02:54:29 +03:00
Fringg 000b0c0592 feat: expose oidc_enabled and oidc_client_id in telegram-widget config 2026-03-07 02:33:52 +03:00
Fringg 3a400d9f8b feat: add POST /auth/telegram/oidc endpoint for OIDC popup flow 2026-03-07 02:32:40 +03:00
Fringg 2f0a9dc4f3 feat: add Telegram OIDC id_token validation and code exchange 2026-03-07 02:30:33 +03:00
Fringg 3a361628aa feat: register TELEGRAM_OIDC category, hints in admin settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:25:54 +03:00
Fringg 833df518d0 feat: add TELEGRAM_OIDC_* settings for new Telegram Login
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:24:13 +03:00
Fringg 084a3cd16f feat: configurable Telegram Login Widget with admin settings
- Add 4 TELEGRAM_WIDGET_* settings to config (size, radius, userpic, request_access)
- Register TELEGRAM_WIDGET category with choices, hints, and prefix mapping
- Add public GET /branding/telegram-widget endpoint returning widget config
- Use Literal type for size validation, Field(ge=0, le=20) for radius bounds
- Clamp radius values from DB to prevent out-of-range values
2026-03-07 01:48:14 +03:00
Fringg 694aeccc31 fix: remove decorative cloudpayments sub-options
CloudPayments doesn't support programmatic card/sbp routing — the user
selects the payment method on the provider's payment page. Remove
available_sub_options so no misleading choice is shown on landing pages.
2026-03-07 00:04:32 +03:00
Fringg 5f01783dcb fix: validate payment sub-option suffix and harden payment method handling
- Add regex pattern + max_length constraint on payment_method field
- Validate sub-option suffix against known available_sub_options
- Validate sub-option is enabled on the landing (not disabled via config)
- Sort methods by ID length desc to prevent freekassa/freekassa_sbp ambiguity
- Accept yookassa_card and cloudpayments_card/sbp in create_guest_payment
- Send single sub-option to frontend (not just when >1) for correct routing
2026-03-07 00:01:44 +03:00
Fringg c53e9af744 feat: expose payment sub-options with labels in public landing API
Resolve sub-option display names from payment_method_config_service
and return them as a list of {id, name} in the public landing config.
Accept suffixed payment_method IDs (e.g. platega_2, yookassa_sbp)
in the purchase endpoint for sub-option selection.
2026-03-06 23:53:28 +03:00
Fringg 220196fb7a feat: add sub_options support for landing page payment methods
Allow per-landing override of payment method sub-options (e.g. Card/SBP
for Yookassa). Add validated sub_options field to admin and public schemas
with opt-out model (missing keys = enabled, null = all available).
2026-03-06 23:30:44 +03:00
sMedia.tech e9b4d8e444 fix: add Message-ID and Date headers to outgoing emails
Without these RFC 5322 required headers, Postfix sends messages with
empty message-id=<> which triggers spam filters at receiving MTAs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:53:39 +03:00
Fringg d86c29a5d3 fix: preserve connected_squads during subscription replacement cleanup
Root cause: validate_and_clean_subscription() wiped connected_squads=[]
before create_remnawave_user() could send them to the Remnawave panel.
This caused replaced subscriptions to lose squad (server) assignments.

Also: add update_server_counters=True to all guest purchase flows,
commit tariff_id before create_remnawave_user to ensure fresh ORM state.
2026-03-06 22:28:39 +03:00
Fringg 8510597ddb fix: add pending_activation to purchase stats and show total count 2026-03-06 22:16:28 +03:00
Fringg f8edfd7746 feat: guest purchase → cabinet account integration
Create verified cabinet accounts for email-based guest purchasers:
- Auto-generate password for new/existing users without password_hash
- Auto-login JWT token (72h TTL) stored once at fulfillment
- POST /login/auto endpoint with rate limiting (5 req/60s)
- Credentials email template (5 languages)
- Fix forgot_password for guest-created email users
- Clear plaintext password from DB after email delivery
- TTL-capped credential exposure (24h delivered, 72h pending_activation)
- DB cleanup of expired credentials on poll
2026-03-06 21:59:36 +03:00
Fringg 776fc3aadc feat: guest purchase delivery & activation system
- Add PENDING_ACTIVATION status for users with existing subscriptions
- Add activation endpoint POST /landing/activate/{token}
- Send email notifications on delivery and pending activation
- Add 3 email templates (delivered, activation required, gift received) in 5 languages
- Extract purchase status response builder to reusable helper
- Move activation logic to service layer
- Add header injection protection in email service
- Add Literal type guard for contact_type parameter
- Fix _mask_email crash on malformed input
- Pre-resolve notification params before commit to avoid DetachedInstanceError
2026-03-06 19:56:37 +03:00
Fringg b85646af85 fix: pass return_url to all payment providers for guest purchases
Platega, Heleket, WATA, CloudPayments now accept optional return_url
and pass it to their APIs. Guest payments redirect back to cabinet
success page instead of Telegram bot.
2026-03-06 18:18:42 +03:00
Fringg e0f2243f49 fix: make users.promo_group_id nullable — sync DB with model
Migration 0023: ALTER COLUMN promo_group_id SET nullable=True.
Fixes NOT NULL violation when creating guest users during landing purchases.
2026-03-06 18:18:40 +03:00
Fringg ab981dce0d fix: treat empty icon_url as None in payment method validation 2026-03-06 18:00:34 +03:00
Fringg 6f871edc9d fix: CryptoBot guest payment — remove is_paid @property write, use correct status
- Remove `locked.is_paid = True` — is_paid is a read-only @property computed from status
- Change status from 'completed' to 'paid' (matching is_paid property check)
- Use db.commit() instead of db.flush() for guest payment persistence
2026-03-06 16:54:25 +03:00
Fringg 3d3bb3badb fix: миграция 0021 — drop server_default перед сменой типа на JSON
PostgreSQL не может автоматически привести строковый default к типу JSON.
Решение: убрать default, сменить тип, затем поставить новый default через raw SQL.
2026-03-06 16:45:03 +03:00
Fringg 6deab7dd8c feat: мультиязычные лендинги + гостевые платежи для всех провайдеров
Мультиязычность:
- Миграция 0021: текстовые поля лендингов → JSON с ключами локалей
- Утилита resolve_locale_text с fallback-цепочкой (lang → ru → en → first)
- Админ API: dict[str, str] для title/subtitle/footer/meta/features
- Публичный API: ?lang= параметр, резолвит в плоские строки
- Обратная совместимость: plain strings → {"ru": value}

Гостевые платежи:
- Миграция 0022: user_id nullable во всех платёжных таблицах
- Поддержка всех провайдеров кроме Stars
- Общий хелпер try_fulfill_guest_purchase в common.py
- YooKassa переведена на общий хелпер

Исправления по ревью:
- CryptoBot: guest fulfillment после FOR UPDATE lock
- _patch_guest_metadata: commit вместо flush
- Freekassa/KassaAI: metadata как dict вместо JSON-строки
- purchase_token маскирован в логах
- None → "guest" в order_id
- Rate limit на GET /landing/{slug}
- Маскирование contact_value
2026-03-06 16:10:09 +03:00
firewookie 861ffe5424 fix migration 2026-03-06 12:36:32 +05:00
firewookie 06ccf4b275 webhook install bugfix + add info in readme 2026-03-06 11:56:15 +05:00
firewookie 7ed91b13eb - RioPay payment system integration 2026-03-06 11:44:22 +05:00
firewookie 319f49435a - Карточки "Бонус новому пользователю" и "Бонус пригласившему"
скрыты когда значение = 0
- Динамический grid-cols в зависимости от количества видимых карточек
- Добавлено поле max_commission_payments в тип ReferralTerms
- Уведомления бота: строки с бонусом нового пользователя и бонусом
    пригласившего скрываются если соответствующие настройки = 0
- Раздел "Как работают награды": карточки бонусов скрыты при значении 0
- Invite message: строка про бонус за первое пополнение скрыта при 0
- Текст комиссии: "с каждого пополнения" при без лимита,
    "с пополнений" при наличии REFERRAL_MAX_COMMISSION_PAYMENTS- API /terms: добавлено поле max_commission_payments
- Добавлен ключ локали REFERRAL_REWARD_COMMISSION_LIMITED (ru/en/ua/zh/fa)
2026-03-06 11:08:52 +05:00
firewookie 23761a74f2 fix formatting 2026-03-06 09:57:24 +05:00
firewookie 8620aaedb1 fix formatting 2026-03-06 09:54:55 +05:00
firewookie aaffc26a90 - Интеграция рекурентов от Юкассы
- Багфикс личного кабинета
2026-03-06 09:47:58 +05:00
Fringg ef450955e6 fix: безопасность и качество кода лендингов — 16 исправлений
- CRITICAL: блокировка fulfillment при несовпадении суммы
- CRITICAL: верификация суммы webhook перед фулфилментом
- CRITICAL: TTL 24ч на доступ к subscription_url + rate limit статуса
- HIGH: IntegrityError для telegram-пользователей (race condition)
- HIGH: валидация icon_url (HTTPS/relative only)
- HIGH: строгий whitelist setattr для update_purchase_status
- HIGH: SAVEPOINT вместо full rollback в _find_or_create_user
- HIGH: отложенный commit покупки до успеха платежа
- MEDIUM: N+1 запрос в списке лендингов → batch stats
- MEDIUM: лимиты длины текстовых полей в схемах
- MEDIUM: строгий email regex
- MEDIUM: индекс на guest_purchases.landing_id (миграция 0020)
- LOW: token prefix 5 символов, расширенные reserved slugs
2026-03-06 07:22:48 +03:00
Fringg 5e404cc082 feat: публичные лендинг-страницы для быстрой покупки VPN-подписок
- Модели LandingPage и GuestPurchase + миграции 0018/0019
- CRUD для лендингов и гостевых покупок
- Публичные роуты: GET /{slug}, POST /{slug}/purchase, GET /purchase/{token}
- Админ-роуты: CRUD лендингов с RBAC (manage_landings)
- Сервис guest_purchase_service: валидация, создание, фулфилмент
- Интеграция с PaymentService (YooKassa card/SBP) для гостевых платежей
- Webhook-обработка с идемпотентностью и атомарными транзакциями
- Rate limiting на публичных эндпоинтах
- YooKassaPayment.user_id теперь nullable для гостевых платежей
2026-03-06 07:02:42 +03:00
Egor c669c5951a Merge pull request #2676 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.23.2
2026-03-06 05:33:40 +03:00
github-actions[bot] b68c1c751a chore(main): release 3.23.2 2026-03-06 02:33:15 +00:00
Egor 0c0e219691 Merge pull request #2673 from BEDOLAGA-DEV/dev
Dev
2026-03-06 05:32:55 +03:00
Egor 8eb6a8c460 Merge pull request #2675 from BEDOLAGA-DEV/main
fix: sync uv.lock version with pyproject.toml 3.23.1
2026-03-06 05:27:56 +03:00
Fringg bc52fd2711 fix: sync uv.lock version with pyproject.toml 3.23.1
release-please обновляет version в pyproject.toml, но не
перегенерирует uv.lock — Docker build падает на uv sync --locked.
2026-03-06 05:27:05 +03:00
Egor 6da408fe15 Merge pull request #2672 from BEDOLAGA-DEV/main
w
2026-03-06 05:22:22 +03:00
Fringg 15fe45d113 fix: миграция 0016 падает если FK constraint отсутствует в БД
Вместо хардкода имён constraint'ов — ищем реальное имя через
pg_constraint. Пропускаем несуществующие таблицы и FK.
Исправляет краш при обновлении у пользователей с неполной схемой.
2026-03-06 05:17:51 +03:00
Fringg 3e26832e74 fix: device_limit fallback 1→0 для корректного отображения безлимита
Значение 0 означает «без ограничений» — фронтенд показывает ∞.
Старый fallback=1 некорректно ограничивал подписки без лимита.
2026-03-06 05:17:44 +03:00
Egor 4c21e3a2a9 Merge pull request #2671 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.23.1
2026-03-06 04:50:21 +03:00
github-actions[bot] c4ea17507f chore(main): release 3.23.1 2026-03-06 01:50:06 +00:00
Egor 19d30dd292 Merge pull request #2670 from BEDOLAGA-DEV/dev
Dev
2026-03-06 04:49:43 +03:00
Fringg 1e930af7d4 chore: regenerate uv.lock for v3.23.0 2026-03-06 04:46:15 +03:00
Fringg 833227a717 Merge remote-tracking branch 'origin/main' into dev 2026-03-06 04:45:52 +03:00
Fringg 04562fd7e7 fix: кнопка «Назад» в тарифах ведёт в админ панель, а не в настройки
Тарифы доступны напрямую из главного меню админки, но кнопка назад
вела в подменю настроек. Исправлено во всех 4 местах.
2026-03-06 04:23:05 +03:00
Fringg 4984f20e8f fix: устранение race conditions и атомарность платёжной системы
- SELECT FOR UPDATE блокировка во всех 9 провайдерах (кроме YooKassa — свой паттерн)
- create_transaction(commit=False) + единый db.commit() для атомарности
- emit_transaction_side_effects() для отложенных событий после коммита
- Все link_*_payment_to_transaction используют db.flush() вместо db.commit()
- Freekassa/KassaAI: прямое присвоение transaction_id + flush вместо update_status
- MulenPay: прямая мутация balance_kopeks вместо add_user_balance
- Platega: блокировка перед чтением metadata, инлайн обновления полей
- CloudPayments: int(round(amount * 100)) для корректного округления
- Heleket добавлен в SUPPORTED_AUTO_CHECK_METHODS
- Удалены PII из логов yookassa webhook (заголовки, IP)
- UniqueConstraint(external_id, payment_method) на транзакциях + миграция 0017
- Cabinet: PaymentService(bot=bot) внутри try блока
- verify_payment_amount утилита для проверки суммы webhook
2026-03-06 04:20:41 +03:00
Fringg fe393d2ca6 fix: complete FK migration — add 27 missing constraints, fix broadcast_history nullable
- broadcast_history.admin_id: CASCADE→SET NULL (column is nullable, preserve audit trail)
- Added nullable=True to broadcast_history.admin_id in model
- Added 27 missing FK constraints to _FK_CHANGES (were only cleaned for orphans
  but not recreated with ondelete)
- All 53 FK→users.id now consistently handled in both orphan cleanup and constraint recreation
2026-03-06 01:56:36 +03:00
Fringg 34c82c3488 fix: добавить ON DELETE CASCADE/SET NULL на все FK к users.id
27 FK ссылающихся на users.id не имели ondelete — при физическом удалении
юзера или восстановлении бэкапа с сиротами FK constraints не создавались.

Миграция 0016:
1. Чистит сироты во всех 53 child-таблицах (DELETE для non-nullable, SET NULL для nullable)
2. Пересоздаёт 27 FK с ON DELETE CASCADE (user_id) или SET NULL (created_by, processed_by)
2026-03-06 01:47:23 +03:00
Fringg 00a7db2690 fix: дедупликация promocode_uses при мерже аккаунтов
После добавления UniqueConstraint(user_id, promocode_id) на promocode_uses,
простое переназначение user_id при мерже падает с IntegrityError если оба
юзера использовали один промокод. Теперь сначала удаляются дубликаты.
2026-03-06 01:42:09 +03:00
Fringg 7fb839aef6 fix: промокоды — конвертация триалов, race condition, savepoints
- trial подписки теперь конвертируются в платные вместо отказа (ошибка ~20 из 300 юзеров)
- extend_subscription: добавлен переход TRIAL→ACTIVE
- UniqueConstraint на PromoCodeUse(user_id, promocode_id) + миграция 0015 с дедупликацией
- create_promocode_use: begin_nested()+flush() вместо commit/rollback (без коррупции сессии)
- race condition: create_promocode_use вызывается ДО _apply_promocode_effects
- cleanup: удаление зарезервированной записи при ValueError от эффектов
- atomic SQL increment для current_uses (защита от lost-update)
- mark_user_as_had_paid_subscription: savepoint вместо commit/rollback
- удалён мёртвый код: use_promocode(), trial_subscription_not_eligible из маппингов
2026-03-06 01:33:18 +03:00
Fringg 6713b34978 fix: исправления системы реферальных конкурсов
- float precision: int(round(amount * 100)) вместо int(amount * 100) для рублей→копейки
- порядок регистрации callback-хендлеров (специфичные startswith первыми)
- FSM state filter на callback хендлере для предотвращения случайных срабатываний
- upsert паттерн в add_contest_event вместо дубликатов
- расширенный SQL фильтр в get_contests_for_events (все активные конкурсы)
- нормализация end-of-day (23:59:59.999999) для границ конкурсных периодов
- guard is_completed в create_transaction
2026-03-06 01:33:06 +03:00
Fringg 7a7fb71bf5 fix: дубликаты системных ролей при переименовании и сброс permissions
1. Поиск системных ролей по (is_system + level) вместо name —
   переименование через UI больше не создаёт дубликаты
2. Bootstrap только добавляет новые permissions из кода,
   не перезатирая кастомизацию админа
2026-03-05 23:49:07 +03:00
Fringg 1c89bd8b2a fix: UniqueViolation при мерже аккаунтов с общим OAuth/telegram/email ID
SQLAlchemy не гарантирует порядок UPDATE при flush — если primary
обновлялся раньше secondary, unique constraint срабатывал до очистки
старого значения. Теперь: очистка secondary → flush → установка primary.
2026-03-05 23:36:30 +03:00
Egor 050be0fe0e Merge pull request #2669 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.23.0
2026-03-05 11:35:44 +03:00
github-actions[bot] 297240f8ef chore(main): release 3.23.0 2026-03-05 08:35:09 +00:00
Egor 2b16a00464 Merge pull request #2668 from BEDOLAGA-DEV/dev
Dev
2026-03-05 11:34:32 +03:00
Fringg b31a893b13 fix: синхронизация версии pyproject.toml с main и обновление uv в Dockerfile
- pyproject.toml: 3.18.0 → 3.22.0 (соответствие main)
- Dockerfile: uv 0.10.7 → 0.10.8
- uv.lock: перегенерирован с новой версией
2026-03-05 11:29:35 +03:00
Fringg b9e17be855 fix: реактивация DISABLED подписок при покупке устройств и в REST API
Добавлен вызов reactivate_subscription перед update_remnawave_user в 4 пропущенных местах:
- handlers/subscription/devices.py (2 пути покупки устройств)
- webapi/routes/subscriptions.py (эндпоинты добавления трафика и устройств)
2026-03-05 11:29:23 +03:00
Fringg 900be65617 fix: добавить пробелы в формат тарифов (1000 ГБ / 2 📱) 2026-03-05 11:16:53 +03:00
Fringg 53a67d7573 chore: автоформатирование ruff 2026-03-05 10:56:17 +03:00
Fringg 7d28f5516a fix: реактивация DISABLED подписок при покупке трафика для LIMITED пользователей
Когда RemnaWave ставит пользователю статус LIMITED (трафик исчерпан),
webhook бота устанавливает локальный статус подписки в DISABLED. При
покупке дополнительного трафика update_remnawave_user() видел DISABLED
и отправлял status=EXPIRED, что RemnaWave отвергал с ошибкой 400.

Добавлен вызов reactivate_subscription() перед синхронизацией с RemnaWave
во всех 8 потоках покупки/переключения трафика:
- handlers/subscription/traffic.py (add_traffic, execute_switch_traffic)
- cabinet/routes/subscription.py (purchase_traffic)
- cabinet/routes/admin_users.py (admin add_traffic)
- handlers/admin/users.py (_add_subscription_traffic)
- webapi/routes/miniapp.py (purchase_traffic_topup)
- subscription_auto_purchase_service.py (_auto_add_traffic, _auto_add_devices)

Также разрешён статус DISABLED в guard автопокупки трафика и устройств,
чтобы LIMITED пользователи могли автоматически докупать ресурсы.
2026-03-05 10:56:10 +03:00
Fringg 849b3a7034 fix: убрать избыточный минус в amount_kopeks для create_transaction
amount_kopeks=-X → amount_kopeks=X в 10 местах:
- tariff_purchase.py (8 локаций)
- miniapp.py (1 локация)
- admin/users.py (1 локация)

create_transaction автоматически негирует для SUBSCRIPTION_PAYMENT,
поэтому передача положительного значения — правильная конвенция.
2026-03-05 10:15:39 +03:00
Fringg 374907b607 fix: добавить create_transaction для 6 потоков оплаты с баланса
- trial_activation_service: create_transaction после списания за триал
- purchase.py: create_transaction для платного триала через бот
- cabinet/subscription.py: create_transaction для продления и триала,
  исправлены transaction=None → реальный объект в 5 уведомлениях
- simple_subscription.py: create_transaction в обоих обработчиках,
  transaction передаётся в admin-уведомление вместо None
2026-03-05 10:15:32 +03:00
Fringg 9f35088788 fix: добавить create_transaction и admin-уведомления для автопродлений
- monitoring_service: добавлен create_transaction(SUBSCRIPTION_PAYMENT, BALANCE)
  и admin-уведомление через with_admin_notification_service
- daily_subscription_service: исправлен PaymentMethod.MANUAL → BALANCE,
  добавлено admin-уведомление через with_admin_notification_service
- subscription_auto_purchase_service: admin-уведомления вынесены из блока
  if bot: и используют with_admin_notification_service (3 локации)
2026-03-05 10:15:25 +03:00
Fringg fd139b28a2 fix: abs() for transaction amounts in admin notifications and subscription events
- send_subscription_purchase_notification: abs(transaction.amount_kopeks) when no explicit amount_kopeks passed
- send_subscription_renewal_notification: abs(transaction.amount_kopeks) for SubscriptionEvent storage
- Prevents negative amounts in admin Telegram messages and SubscriptionEvent records
2026-03-05 09:30:27 +03:00
Fringg de6f80694b fix: add abs() to expenses query, display flip, contest stats, and recent payments
- expenses_kopeks: func.abs() handles WITHDRAWAL stored as negative by approve_request
- admin_users.py: abs() in display instead of sign flip for mixed-sign WITHDRAWAL/SUBSCRIPTION_PAYMENT
- referral_contest.py: func.abs() on get_contest_payment_stats total_amount sum
- admin_stats.py: abs() on RecentPaymentItem to prevent negative amounts in API
2026-03-05 09:12:01 +03:00
Fringg b87535ad48 fix: изолировать stored_amount от downstream consumers в create_transaction
- stored_amount используется только для БД записи, оригинальный
  amount_kopeks передаётся в event emitter и contest service через abs()
- Добавлен func.abs() в leaderboard конкурсов (referral_contest.py)
- Предотвращает негативные суммы в событиях и рейтинге конкурсов
2026-03-05 09:05:55 +03:00
Fringg 6da61d7951 fix: убрать WITHDRAWAL из автонегации, добавить abs() в агрегации, исправить all_time_stats
- Убран WITHDRAWAL из автонегации в create_transaction (ломал profit,
  expenses и display flip в admin_users)
- Добавлен func.abs() в by_type агрегацию (transaction.py)
- Добавлен func.abs() в total_spent user.py (_build_spending_stats_select)
- Исправлен all_time_stats в боте и webapi: передаём явный диапазон дат
  вместо дефолтного текущего месяца
2026-03-05 09:05:02 +03:00
Fringg 968d147046 fix: передать явный диапазон дат для all_time_stats в дашборде
get_transactions_statistics() без аргументов по умолчанию возвращает
текущий месяц, а не все время. Передаём явный start_date=2020-01-01
для корректного расчёта общего дохода и дохода от подписок.
2026-03-05 09:02:05 +03:00
Fringg 93a55df4c0 fix: гарантировать положительный доход от подписок и исправить общий доход
- Добавлен abs() на уровне API-ответов для subscription_income (защита от
  негативных значений при несогласованных знаках SUBSCRIPTION_PAYMENT)
- Нормализация знаков в create_transaction: SUBSCRIPTION_PAYMENT и WITHDRAWAL
  всегда сохраняются как отрицательные (дебет)
- Исправлен income_total в дашборде: показывал месячный доход вместо общего
  (теперь используется отдельный запрос all_time_stats)
2026-03-05 08:58:49 +03:00
Fringg 82592784d0 fix: устранение каскадного PendingRollbackError при восстановлении бэкапа
TRUNCATE 83 таблиц таймаутился из-за command_timeout=30s в asyncpg.
После таймаута в fallback-цикле PendingRollbackError каскадировал
на все остальные таблицы и восстановление данных.

Исправление:
- Выделенный engine с command_timeout=300s и statement_timeout=5min
  для TRUNCATE операций (NullPool, без overhead)
- Каждая таблица в fallback очищается в отдельном соединении,
  что предотвращает каскад PendingRollbackError
- lock_timeout=2min для ограничения ожидания блокировок
  (бот продолжает обрабатывать сообщения во время восстановления)
2026-03-05 08:32:50 +03:00
Fringg acfa4b3c2e fix: показывать кнопку покупки тарифа вместо ошибки для триальных подписок
При нажатии «Продлить подписку» из webhook-уведомления триальный
пользователь получал ошибку «Продление доступно только для платных
подписок». Теперь вместо этого показывается сообщение с кнопкой
«Купить подписку», которая ведёт к выбору тарифа.
2026-03-05 08:25:16 +03:00
Fringg a7a18dd0d1 fix: устранение race condition при покупке устройств через re-lock после коммита
subtract_user_balance() делает внутренний коммит, что освобождает
SELECT FOR UPDATE блокировки. Добавлен паттерн re-lock + re-validate +
refund после вызова subtract_user_balance во всех 8 путях мутации
device_limit:

- cabinet: /devices, /devices/purchase, /devices/reduce
- miniapp: /subscription/devices
- bot handlers: execute_change_devices, confirm_add_devices
- auto-purchase: _auto_add_devices
- CRUD: add_subscription_devices

Также добавлен populate_existing=True ко всем SELECT FOR UPDATE запросам
для корректного обновления SQLAlchemy identity map.
2026-03-05 08:15:00 +03:00
Fringg 1cfede28b7 fix: prevent concurrent device purchases exceeding max device limit
Add SELECT FOR UPDATE row lock on subscription before checking device
limit in all 3 device purchase endpoints (cabinet new, cabinet legacy,
miniapp). Without the lock, two concurrent requests both read the old
device_limit, both pass validation, and both increment — resulting in
device count exceeding max_device_limit (e.g., 5 devices when limit is 3).

Also moved max-devices check before balance check in legacy endpoint
to fail fast under lock.
2026-03-05 07:33:09 +03:00
Fringg c8ef808539 fix: consume promo offer in tariff_purchase.py, fix negative transaction amount
- Add consume_promo_offer to 5 call sites in tariff_purchase.py where
  _get_user_period_discount() stacks promo_offer into blended discount
  (lines 823, 1134, 1706, 2238, 2971)
- Fix negative amount_kopeks in miniapp.py:5351 transaction record
  (was -final_total, all other SUBSCRIPTION_PAYMENT use positive)
- Replace duplicate _get_user_promo_offer_discount_percent in
  monitoring_service with shared get_user_active_promo_discount_percent
2026-03-05 07:25:27 +03:00
Fringg b8857e789e fix: consume promo offer in miniapp tariff-mode renewal path
The tariff-mode renewal in miniapp applied promo_offer_discount_percent
to final_total but never passed consume_promo_offer to subtract_user_balance,
allowing infinite reuse of first-purchase-only promo discounts via miniapp.
2026-03-05 07:15:41 +03:00
Fringg 5f2d855702 fix: add missing mark_as_paid_subscription, fix operation order, remove dead code
- Add mark_as_paid_subscription=True to cabinet trial activation
- Reorder menu.py: charge balance BEFORE creating subscription (prevents orphaned subscription)
- Remove dead _consume_user_promo_offer_discount method from monitoring_service (55 lines)
- Remove unused imports (get_latest_claimed_offer_for_user, log_promo_offer_action)
- Fix inconsistent _get_promo import alias to use full function name
2026-03-05 07:02:12 +03:00
Fringg 0466528925 fix: centralize balance deduction and fix unchecked return values
- Replace inline SELECT FOR UPDATE in renew_subscription with subtract_user_balance
- Replace direct balance_kopeks -= in trial activation with subtract_user_balance
- Add success checks to 4 unchecked subtract_user_balance calls (devices x2, menu, tariff switch)
- Add consume_promo_offer to monitoring_service autopay (was non-atomic)
- Add mark_as_paid_subscription=True to trial_activation_service, daily_subscription_service, admin purchase paths
- Remove 3 redundant has_had_paid_subscription assignments in auto_purchase_service
- Fix stale cart consume_promo_offer: compute from live user state instead of cart data
2026-03-05 06:45:57 +03:00
Fringg e4a6aad621 fix: centralize has_had_paid_subscription into subtract_user_balance
Add mark_as_paid_subscription parameter to subtract_user_balance() that
atomically sets has_had_paid_subscription=True within the same FOR UPDATE
transaction as the balance deduction. This closes ALL purchase paths:

- Cabinet renew: add SELECT FOR UPDATE row lock (fix race condition),
  set has_had_paid_subscription atomically, remove standalone call
- Cabinet purchase_tariff: pass consume_promo_offer to subtract_user_balance
  (fix: inline clearing was wiped by db.refresh), remove standalone call
- Cabinet switch_tariff: add mark_as_paid_subscription=True
- Auto-extend: add mark_as_paid_subscription, remove standalone call
- Auto-purchase tariff: add consume_promo_offer + mark_as_paid_subscription
- Auto-purchase daily: add mark_as_paid_subscription
- Bot purchase/extend/trial handlers: add mark_as_paid_subscription
- All 8 tariff_purchase.py handlers: add mark_as_paid_subscription
- Both simple_subscription.py handlers: add mark_as_paid_subscription
- Menu smart activation: add mark_as_paid_subscription
- Monitoring autopay: add mark_as_paid_subscription
- Renewal service finalize: add mark_as_paid_subscription
- MiniApp purchase service: add mark_as_paid_subscription, remove standalone
- MiniApp renewal/tariff/switch: add mark_as_paid_subscription
2026-03-05 06:29:34 +03:00
Fringg 2cec8dc4a4 fix: prevent infinite reuse of first_purchase_only promo code discounts
- Add consume_promo_offer flag to all cabinet cart_data dicts (renew, daily tariff, non-daily tariff) so auto-purchase service clears discount fields after purchase
- Add mark_user_as_had_paid_subscription calls after cabinet renew and tariff purchase to prevent re-activation of first_purchase_only promo codes
- Add mark_user_as_had_paid_subscription to auto-purchase service for non-trial purchases
2026-03-05 06:14:41 +03:00
Fringg 667291a2dc fix: redis cache uses sync client due to import shadowing
import redis.exceptions overwrites the redis name binding from
import redis.asyncio as redis, causing from_url() to create a
sync client. ping() then returns bool instead of coroutine.

Fix: from redis.exceptions import NoScriptError
2026-03-05 06:00:30 +03:00
Fringg eff74bed5b fix: auto-update permissions for system roles on bootstrap
Previously preset roles were only seeded on first run. Now if a
system role's permissions differ from the preset definition, they
are updated automatically on startup.
2026-03-05 05:53:07 +03:00
Fringg 8f29e2eee2 feat: add dedicated sales_stats RBAC permission section
Separate sales statistics permissions from general stats:
- Add sales_stats section to PERMISSION_REGISTRY (read, export)
- Update all 6 sales-stats endpoints to require sales_stats:read
- Add sales_stats:* to Admin preset, sales_stats:read to Marketer preset
2026-03-05 05:46:01 +03:00
Fringg 9d7a557ef0 fix: показывать только активные провайдеры на странице /profile/accounts
Заменён хардкод _ALL_PROVIDERS на _get_active_providers():
- Telegram — всегда
- Email — только при CABINET_EMAIL_AUTH_ENABLED
- OAuth — только включённые в настройках (OAUTH_*_ENABLED)
2026-03-05 05:34:01 +03:00
Fringg 2664b4956d feat: account merge system — atomic user merge with full FK coverage
- Реализован execute_merge: атомарное слияние двух аккаунтов (primary поглощает secondary)
- Покрыты все 54 FK на users.id (38 таблиц): платежи, подписки, реферралы, тикеты, аудит
- Admin-actor FK (created_by, processed_by, admin_id, assigned_by, actor_user_id) — SET NULL
- User-ownership FK — переназначение на primary
- Dedup-then-reassign для таблиц с unique constraints
- Cross-referral deletion для ReferralEarning и ReferralContestEvent
- UserRole secondary удаляются (защита от эскалации привилегий)
- Merge token: Redis GETDEL (атомарное потребление), restore при ошибке
- Preview endpoint с rate limiting по IP
- Перенос баланса, email, telegram_id, OAuth провайдеров, партнёрского статуса
2026-03-05 05:23:39 +03:00
Fringg f7caf0de70 refactor: extract shared OAuth linking logic, add Literal types for providers
- Extract _exchange_and_link_oauth() helper to deduplicate link_provider_callback
  and link_server_complete (exchange code, fetch user info, check conflict, link)
- Use Literal['google','yandex','discord','vk'] for provider path parameters
  (FastAPI validates automatically, removes manual checks)
- Add safe int parsing for user_id from state with proper error handling
- Remove redundant provider validation checks (handled by Literal type)
2026-03-05 03:12:17 +03:00
Fringg 0c1dc580c6 fix: add IntegrityError handling on link commit and format fixes
- Wrap db.commit() in try/except IntegrityError for both
  link_provider_callback and link_server_complete (race condition guard)
- Fix ruff format issues (line wrapping)
2026-03-05 02:33:24 +03:00
Fringg f867989557 feat: add server-complete OAuth linking endpoint for Mini App flow
- Add POST /link/server-complete endpoint (no JWT, auth via state token)
- Make provider optional in ServerCompleteRequest (resolved from Redis)
- Make provider optional in validate_oauth_state (skip check if None)
- Endpoint validates linking state, exchanges code, links or creates merge
2026-03-05 02:24:32 +03:00
Fringg 467dea1315 fix: review findings — exception chaining, redundant unquote, validator tightening
- Add `from exc` to IntegrityError raise for consistent exception chaining
- Remove redundant unquote() in validate_telegram_init_data (parse_qsl already decodes)
- Tighten model_validator to require all 3 widget fields (id, auth_date, hash) together
- Extract _MAX_CLOCK_SKEW_SECONDS constant replacing magic number -300
- Use logger.exception() instead of logger.error(exc_info=True) in 2 places
2026-03-04 17:21:14 +03:00
Fringg da40d5662d feat: add Telegram account linking endpoint with security hardening
- POST /cabinet/auth/account/link/telegram supporting both initData (Mini App) and Login Widget flows
- Pydantic model_validator enforces mutual exclusivity of init_data vs widget fields
- IntegrityError handling for TOCTOU race on telegram_id UNIQUE constraint
- Username guard: only set if user has no existing username
- Max-length constraints on all string fields
- Future auth_date rejection (< -300s) in both validation functions
2026-03-04 17:03:40 +03:00
Fringg 7b4e9488f6 fix: clean email verification and password fields from secondary user during merge 2026-03-04 16:21:00 +03:00
Fringg d7a9d2bfba fix: reassign orphaned records on merge, eliminate TOCTOU race
- Reassign SubscriptionConversion, SubscriptionEvent, DiscountOffer
  from secondary to primary during merge (previously orphaned)
- Consume-first pattern: atomically GETDEL merge token before
  validation, restore on invalid input (eliminates TOCTOU window)
2026-03-04 16:05:23 +03:00
Fringg 531d5cff30 fix: negative balance transfer, linking state validation, referrer migration
- Transfer negative balances on merge (debt must not vanish)
- Validate OAuth state was initiated for account linking flow
- Transfer secondary's referrer to primary when primary has none
- Type MergePreviewSubscription schema (replace dict[str, Any])
- Cap restore_merge_token TTL to prevent clock-skew extension
- Add 4 new tests (negative balance, referrer transfer scenarios)
2026-03-04 15:57:03 +03:00
Fringg 8ee97ba1ba test: relax hardcoded execute count, add telegram_id conflict assertion
- Change == 17 to >= 17 so test doesn't break on new bulk updates
- Assert secondary.telegram_id is None in conflict test
2026-03-04 15:46:51 +03:00
Fringg 0e8c61a776 fix: use short TTL fallback in restore_merge_token on parse error
Fail closed with 60s instead of full 30min TTL when created_at cannot
be parsed, preventing accidental token lifetime extension.
2026-03-04 15:35:20 +03:00
Fringg 9582758d1c fix: restore merge token on DB failure, fix partner_status priority
- Add restore_merge_token() to re-store consumed token if execute_merge
  or db.commit fails, allowing the user to retry instead of being stuck
- Fix partner_status priority: PENDING (2) now beats REJECTED (1), so
  an active application is not lost during merge
- Add tests for pending-vs-rejected edge cases (47 tests total)
2026-03-04 15:29:50 +03:00
Fringg f204b67880 fix: delete cross-referral earnings before bulk reassignment, clear secondary.referred_by_id
Prevents data corruption when merging accounts that have mutual referral
relationships. Cross-referral ReferralEarning rows are now deleted before
any bulk UPDATE to avoid self-referral records. Secondary's referred_by_id
is cleared during cleanup to prevent orphaned FK references.
2026-03-04 15:22:23 +03:00
Fringg db61365e11 fix: prevent self-referral loops, invalidate all sessions on merge
- Add User.id != primary.id filter to referred_by_id reassignment to
  prevent self-referral loops when primary was referred by secondary
- Clear primary.referred_by_id if it pointed to secondary
- Add exclusion filter to ReferralEarning.referral_id reassignment to
  prevent user_id == referral_id rows
- Invalidate refresh tokens for BOTH primary and secondary during merge
  (primary gets a fresh session after merge)
- Fix duplicate step 4 comment numbering in execute_merge_endpoint
- Add referred_by_id field to test fixture _make_user
2026-03-04 15:01:51 +03:00
Fringg bc1e6fb22c fix(merge): validate before consuming token, add flush, defensive balance
- Validate keep_subscription_from BEFORE consuming merge token (read
  first with get_merge_token_data, then consume) — prevents token loss
  on validation failure
- Add missing await db.flush() after db.delete(secondary_sub) in
  keep_subscription_from='primary' branch (consistency with 'secondary')
- Capture transferred_kopeks in local var before zeroing secondary
  balance (defensive against log reordering)
2026-03-04 08:12:10 +03:00
Fringg 64ee0459e4 fix: second round review fixes for account merge
- Rename _compute_auth_methods to compute_auth_methods (public API)
- Add Literal type to _handle_subscription_merge param
- Add Literal type to keep_from in route handler
- Add Path(min_length=32, max_length=64) on merge_token params
- Import Path and Literal in account_linking routes
2026-03-04 07:55:26 +03:00
Fringg d855e9e47f fix: harden account merge security and correctness
- Clear ALL unique constraint fields on secondary user after merge
  (telegram_id, OAuth IDs, email, referral_code, remnawave_uuid)
- Add Literal type + runtime validation for keep_subscription_from
- Reject merge when primary user is deleted
- Validate OAuth state user_id matches authenticated user in link callback
- Replace leaked ValueError messages with generic error detail
- Fix exc_info usage for idiomatic structlog
- Fix _get_remnawave_api return type to AsyncIterator
- Remove unnecessary from __future__ import annotations
- Add 3 new tests (42 total, all passing)
2026-03-04 07:46:07 +03:00
Fringg dc7b8dc72a feat: account linking and merge system for cabinet
Add OAuth provider linking/unlinking endpoints, merge token service
(Redis-backed, 30-min TTL), and atomic account merge executor that
transfers OAuth IDs, telegram_id, email, balance, subscriptions,
transactions, payments, referral data, and partner status between
two user accounts. Unchosen subscription is deleted from RemnaWave
with disable as fallback.

Includes 39 unit tests covering all merge scenarios.
2026-03-04 07:24:15 +03:00
Egor 57b5216306 Merge pull request #2663 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.22.0
2026-03-04 06:14:51 +03:00
github-actions[bot] e249bff5d6 chore(main): release 3.22.0 2026-03-04 03:14:27 +00:00
Egor 85489cff3f Merge pull request #2662 from BEDOLAGA-DEV/dev
Dev
2026-03-04 06:14:02 +03:00
Fringg 57aaca82f5 fix: empty JSONB values exported as None in backup
`if value` treated [] and {} as falsy, losing empty JSONB arrays/dicts
during backup export. Changed to `if value is not None`.
2026-03-04 06:11:15 +03:00
Fringg ff1c8722c9 fix: backup restore fails on FK constraints and transaction poisoning
Root cause: 7 tables (admin_audit_log, admin_roles, user_roles,
access_policies, partner_applications, required_channels,
user_channel_subscriptions) were missing from backup/restore.
DELETE FROM users hit FK constraint from admin_audit_log, poisoning
the entire PostgreSQL transaction — all subsequent operations failed.

Fixes:
- Add 8 missing models to backup (+ CabinetRefreshToken)
- Replace individual DELETE FROM with TRUNCATE ... CASCADE
  (handles FK deps automatically, resets sequences)
- Fallback: per-table TRUNCATE with savepoints if batch fails
- Fix _restore_users_without_referrals: wrap flush in savepoint
  instead of db.rollback() which killed entire transaction
- Add sync_postgres_sequences() after ORM restore to prevent
  PK conflicts before bot restart
2026-03-04 06:03:14 +03:00
Fringg 018f18fa0c fix: MissingGreenlet on campaign registrations access
Access registrations from instance dict instead of ORM descriptor
to avoid lazy load triggering MissingGreenlet in async context.
2026-03-04 05:55:44 +03:00
Fringg eaeee7a765 fix: handle duplicate remnawave_uuid on email sync
- Check if another user already owns the panel UUID before assigning
- Rollback + refresh user on sync failure instead of leaving session dirty
- Save user.email before try block for safe error logging
2026-03-04 05:49:38 +03:00
Fringg 618c936ac9 fix: close remaining daily subscription expire paths
- get_all_subscriptions: add selectinload(tariff) so validate_and_fix guard works
- get_subscriptions_batch: add selectinload(tariff) for sync flows
- get_expiring_subscriptions: exclude active daily subs (prevents spurious notifications)
- update_remnawave_user: add daily guard to prevent expire during panel sync
- _handle_user_disabled webhook: add daily guard to prevent deactivation
2026-03-04 05:46:37 +03:00
Fringg 0ed6397fa9 fix: prevent daily subscriptions from being expired by middleware/CRUD/webhook
Daily subscriptions have end_date = +24h, and between 30-min check cycles
they would get expired by 5 different code paths before DailySubscriptionService
could charge and renew them. Users saw "subscription expired" while having balance.

Root cause fixes (6 paths protected):
- subscription_checker middleware: skip active daily subscriptions
- check_and_update_subscription_status CRUD: skip active daily subscriptions
- monitoring_service: run autopay BEFORE expired check, expand query to
  include recently-expired subscriptions (2h window)
- remnawave_webhook_service: _handle_user_expired skips daily tariffs
- remnawave_service: validate_and_fix_subscriptions skips daily tariffs

Recovery mechanisms:
- New get_expired_daily_subscriptions_for_recovery() CRUD function
- DailySubscriptionService.process_auto_resume() restores DISABLED (balance
  topped up) and EXPIRED (incorrectly expired) daily subscriptions
- Runs before daily charges in each monitoring cycle
2026-03-04 05:12:19 +03:00
Fringg dce9eaa597 fix: reset traffic purchases on expired subscription renewal + pricing fixes
- Reset TrafficPurchase records and purchased_traffic_gb when renewing
  expired subscriptions (was incorrectly preserving stale purchases,
  inflating traffic limit e.g. 100GB+20GB=120GB instead of fresh 100GB)
- Fix in extend_subscription() CRUD, cabinet /renew, bot handlers,
  simple_subscription handlers
- Add RemnaWave sync to cabinet /renew endpoint after subscription changes
- Fix device_price_kopeks=0 falsy-zero bug (11+ instances, or → is not None)
- Fix double-increment of purchased_traffic_gb in cabinet traffic purchase
- Fix orphaned TrafficPurchase records in 5 locations (replace_subscription,
  extend_subscription fixed_with_topup, classic mode, switch_tariff,
  purchase.py is_traffic_fixed)
- Fix admin_users.py UnboundLocalError from TrafficPurchase inline import
  shadowing module-level import
- Standardize pricing order: base + devices → promo_group → promo_offer
  across all 5+ pricing paths (cabinet, miniapp, autopay, auto-purchase,
  monitoring)
- Fix exception handlers in calculate_renewal_price (raise instead of
  returning fallback 0)
- Fix monitoring_service double-discount (promo_offer applied twice)
- Fix auto-purchase _get_tariff_price_for_period return type to tuple
  (base_price, discount_percent) so callers add devices before discount
- Pass traffic_limit_gb/device_limit to extend_subscription in
  simple_subscription.py instead of manual overwrites
2026-03-04 04:46:29 +03:00
Fringg 628a99e7aa fix: classic mode prices overridden by active tariff prices
Root cause: ensure_tariffs_synced runs BEFORE bot_configuration_service.initialize(),
so SALES_MODE from system_settings is not yet applied. If SALES_MODE=classic is set
via cabinet (not .env), load_period_prices_from_db sees tariffs mode and loads tariff
prices into _DB_PERIOD_PRICES. Then refresh_period_prices() always prefers
_DB_PERIOD_PRICES over settings.PRICE_*_DAYS, even in classic mode.

Three fixes:
1. refresh_period_prices() now checks settings.is_tariffs_mode() before using
   _DB_PERIOD_PRICES — classic mode always uses settings.PRICE_*_DAYS
2. initialize() calls refresh_period_prices() after all DB overrides are applied,
   so SALES_MODE is correct when prices are recalculated
3. Switching SALES_MODE to classic via cabinet now clears _DB_PERIOD_PRICES
2026-03-04 03:12:34 +03:00
Fringg 4d74afd711 fix: add selectinload for campaign registrations in list query
MissingGreenlet error when accessing campaign.registrations
in show_campaigns_list handler — lazy load not supported in async.
2026-03-04 02:53:29 +03:00
Fringg e2c9aab7ba chore: sync uv.lock with pyproject.toml version 2026-03-03 01:57:13 +03:00
Fringg e23d69fcec feat: replace pip with uv in Dockerfile
- Use uv 0.10.7 with pyproject.toml + uv.lock instead of pip + requirements.txt
- Bind mounts for pyproject.toml/uv.lock with BuildKit cache for faster rebuilds
- UV_COMPILE_BYTECODE=1 for pre-compiled .pyc, UV_LINK_MODE=copy for multi-stage
- UV_PYTHON_DOWNLOADS=never to prevent uv from downloading its own Python
- Replace wget healthcheck with Python stdlib (removes apt layer from runtime)
- Increase start-period to 60s for migration headroom
- Fix redundant chown -R on entire /app
- Add .venv, tests, .mypy_cache, .ruff_cache to .dockerignore
2026-03-03 01:55:39 +03:00
anatoliy 1afcd84e0e fix: photo handling in QR messages
Add check to ensure photo is only used for non-QR messages
2026-03-02 23:57:34 +03:00
Egor e850419f10 Merge pull request #2656 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.21.0
2026-03-02 22:28:11 +03:00
github-actions[bot] 360d579415 chore(main): release 3.21.0 2026-03-02 19:27:36 +00:00
Egor c67f55fe0d Merge pull request #2654 from BEDOLAGA-DEV/dev
Dev
2026-03-02 22:26:56 +03:00
Fringg 310edae013 fix: use float instead of int | float (PYI041) 2026-03-02 22:25:06 +03:00
Fringg 8fb97d9359 chore: ruff format 3 files 2026-03-02 22:23:43 +03:00
Fringg d33c5d6c07 feat: add daily deposits by payment method breakdown
Add daily_by_method field to deposits endpoint with GROUP BY
(date, payment_method) query. Uses raw column instead of coalesce
since base_filter already excludes NULLs via .in_(REAL_PAYMENT_METHODS).
2026-03-02 22:05:57 +03:00
Fringg 2449a5cbbe feat: add daily device purchases chart to addons stats
- Add DailyDeviceItem schema and daily_devices field to AddonsStatsResponse
- Query device transactions grouped by date reusing existing device_filter
2026-03-02 21:54:34 +03:00
Fringg e5f29eb041 fix: resolve GROUP BY mismatch for daily_by_tariff query
Use a single coalesce expression object shared across SELECT, GROUP BY,
and ORDER BY clauses so PostgreSQL sees the same expression reference
instead of separately parameterized literals.
2026-03-02 21:45:55 +03:00
Fringg 31c7e2e9c1 feat: enhance sales stats with device purchases, per-tariff daily breakdown, and registration tracking
- Add device purchase count and revenue to addons endpoint (filter by 'устройств' in transaction descriptions)
- Add daily_by_tariff series to sales endpoint (group subscriptions by date and tariff name)
- Split trials daily data into separate registrations and trials series with date union merge
- Add total_registrations count to trials stats response
2026-03-02 21:41:50 +03:00
Fringg e25fcfc6ef fix: renewals stats empty on all-time filter
For "all time" period, define renewals as users with >1 subscription
payment (repeat customers) instead of filtering by created_at < 2020
which always yields empty results.
2026-03-02 21:15:04 +03:00
Fringg b2cf4aaa91 fix: eliminate double panel API call on tariff change, harden cart notification
Bug 1 improvement: Replaced double API call pattern (sync + update_remnawave_user)
with single _sync_subscription_to_panel call that accepts reset_traffic parameter.
This prevents TRIAL status being overwritten to EXPIRED by the second call's
different status computation logic.

Bug 2 improvement: Moved keyboard construction inside try block to prevent
AttributeError crash if locale keys are missing. Switched button text from
attribute access (texts.KEY) to defensive texts.get('KEY', fallback).
Added empty template guard to prevent sending empty messages to Telegram API.
2026-03-02 20:53:59 +03:00
Fringg 1256ddcd1a fix: restore panel user discovery on admin tariff change, localize cart reminder
Bug 1: Admin tariff change used update_remnawave_user() which returns
early when user has no remnawave_uuid. Restored _sync_subscription_to_panel()
which discovers/creates panel users via telegram_id/email fallback, then
applies traffic reset if RESET_TRAFFIC_ON_TARIFF_SWITCH is enabled.

Bug 2: Post-topup cart reminder in payment/common.py had hardcoded Russian
text sent to all users regardless of language. Replaced with localized
BALANCE_TOPPED_UP_CART_SUFFICIENT/INSUFFICIENT keys and used existing
MY_BALANCE_BUTTON/MAIN_MENU_BUTTON for inline keyboard buttons.
Added new i18n keys to all 5 locales (ru, en, ua, zh, fa).
2026-03-02 20:48:02 +03:00
Fringg 58faf9eaec feat: add admin sales statistics API with 6 analytics endpoints
- Add /cabinet/admin/stats/sales/* endpoints: summary, trials,
  subscriptions, renewals, addons, deposits
- Period params: days preset or custom start_date/end_date range
- MAX_PERIOD_DAYS=730 validation with proper date parsing
- Conversion rate capped at 100% to handle cross-period conversions
- Use EXTRACT(epoch)/86400 for accurate interval day calculation
- Consolidated subscription queries with CASE expressions
- Renewals with period-over-period comparison and trend detection
- Permission-gated with require_permission('stats:read')
- Shared link utilities in cabinet/utils/links.py
2026-03-02 20:35:09 +03:00
Fringg ded5c899f7 fix: improve campaign routes, schemas, and add database indexes
- Use PartnerStatus.APPROVED.value instead of hardcoded 'approved'
- Extract shared deep_link/web_link helpers to cabinet/utils/links.py
- Add _safe_div() helper for None-safe division
- Add try/except error handling on campaign endpoints
- Use model_fields_set for PATCH-style field detection
- Replace deprecated class Config with ConfigDict(from_attributes=True)
- Remove unnecessary selectinload(registrations) from campaign list
- Extract _calc_change to module-level in partner_stats_service
- Add composite indexes for stats queries on Subscription, Transaction,
  SubscriptionConversion, and TrafficPurchase models
2026-03-02 20:34:57 +03:00
Fringg fa7de589c1 feat: add admin campaign chart data endpoint with deposits/spending split
- Add get_admin_campaign_chart_data() to PartnerStatsService with daily registrations, revenue trends, period comparison, and top registrations
- Add total_deposits_kopeks and total_spending_kopeks as separate aggregates
- Add 6 Pydantic schemas for admin chart data response
- Add GET /{campaign_id}/chart-data endpoint with campaigns:stats permission
- Add partner application endpoints and schemas for campaign detailed stats
2026-03-02 06:10:30 +03:00
Fringg 69868418e5 style: format 6 files with ruff 2026-03-02 04:35:16 +03:00
Fringg 062c4865db fix: add min_length to state field, use exc_info for referral warning 2026-03-02 04:34:18 +03:00
Fringg 1dfa78013c fix: migrate VK OAuth to VK ID OAuth 2.1 with PKCE
VK deprecated oauth.vk.com on Sep 30, 2025. Migrate to VK ID (id.vk.ru)
with mandatory PKCE S256 and device_id support.

- Rewrite VKProvider: new endpoints, PKCE code_verifier/challenge, user_info format
- Add prepare_auth_state() hook for provider-specific state (PKCE)
- Use atomic Redis GETDEL for OAuth state validation (prevent TOCTOU race)
- Add CacheService.getdel() method
- Check cache.set() result in generate_oauth_state
- Filter ephemeral keys (_prefix) from Redis storage
- Fix garbled log messages, use exc_info for tracebacks
- Add input validation (min_length, max_length on code/state)
- Generic error messages (no provider name leakage)
2026-03-02 04:10:01 +03:00
Fringg 60c97f778b fix: eliminate referral system inconsistencies
- Fix balance history display: referral_reward, refund, poll_reward now
  shown as credits (💰 +amount) instead of expenses
- Fix double-counting: remove all Transaction-based REFERRAL_REWARD sum
  queries from crud/referral.py, admin_stats.py, admin_users.py —
  ReferralEarning is now the single source of truth
- Unify "active referrals" definition across cabinet, bot, and admin:
  JOIN Subscription WHERE status=ACTIVE AND end_date > now()
- Add payment_method IS NOT NULL guard to get_user_own_deposits() to
  exclude referral rewards historically mistyped as deposits
- Replace hardcoded transaction type strings with TransactionType enum
  values in referral_withdrawal_service.py
- Add Alembic data migration (0014) to fix historical transactions:
  UPDATE deposit → referral_reward WHERE payment_method IS NULL and
  description matches referral patterns
2026-03-02 02:25:32 +03:00
Fringg 83c6db4834 fix: correct referral withdrawal balance formula and commission transaction type
The available_referral formula incorrectly treated all post-earning spending
as spent from referral balance, making withdrawable balance stay at 0 even
as earnings increased. Changed to min(wallet_balance, earned - withdrawn - pending).

- Fix available_referral in withdrawal service and referral info endpoint
- Use TransactionType.REFERRAL_REWARD for all commission/bonus balance additions
- Gate create_referral_earning behind add_user_balance success check
- Move notifications inside balance_ok guards to prevent false confirmations
2026-03-02 01:35:24 +03:00
Fringg ed3ae14d0c fix: partner system — CRUD nullable fields, per-campaign stats, atomic unassign, diagnostic logging
- Fix update_campaign() CRUD to allow setting nullable fields (partner_user_id, tariff_id, etc.) to None
- Add per-campaign statistics (registrations, referrals, earnings) to partner detail page
- Scope registrations_count to partner-referred users only (JOIN with User.referred_by_id)
- Make unassign_campaign atomic (UPDATE...WHERE) to prevent TOCTOU race condition
- Add audit logging to campaign assign/unassign with admin_id
- Add diagnostic logging to process_referral_topup and commission resolution
- Document process_referral_purchase as intentionally unused (no double-commission)
2026-03-02 01:09:47 +03:00
Fringg 69a9899d40 fix: use direct is_trial access, add missing error codes to promo APIs
- Use subscription.is_trial instead of getattr for reliable check
- Fix structlog key typo: format_user_log → _format_user_log
- Add missing error codes (active_discount_exists, not_first_purchase,
  daily_limit) to miniapp and cabinet promo code endpoints
2026-03-01 23:43:04 +03:00
Fringg e32e2f779d fix: reject promo codes for days when user has no subscription or trial
SUBSCRIPTION_DAYS promo codes now require an active or expired non-trial
subscription. Users without any subscription or with a trial subscription
get a clear error message instead of silently creating/extending.
2026-03-01 23:39:02 +03:00
Fringg ccb61d6473 chore: remove dead BALANCE_TOPUP_CART_REMINDER_DETAILED keys and unused cryptobot cart payload 2026-03-01 23:28:26 +03:00
Fringg 2fab50c340 fix: correct cart notification after balance top-up
- Remove misleading "Важно" and "При наличии корзины" warnings from all
  payment success notifications
- Fix cart total bug: show actual cart price from Redis instead of top-up
  amount, and suppress "insufficient funds" when balance is enough
- Extract shared send_cart_notification_after_topup() in common.py to
  replace duplicated code across all 10 payment providers
2026-03-01 23:09:49 +03:00
Fringg 69b5ca0670 fix: use .is_(True) and add or 0 guards per code review 2026-03-01 21:24:32 +03:00
Fringg 06c3996da4 fix: count sales from completed payment transactions instead of subscription created_at
Previously 'Продажи' stats counted by Subscription.created_at which only
reflects initial creation date. Renewals update end_date on existing record
without changing created_at, so renewals were never counted as sales.

Now counts completed SUBSCRIPTION_PAYMENT transactions which are created
for every purchase and renewal. Also standardized date boundaries to use
explicit midnight UTC datetime instead of date objects.
2026-03-01 21:17:05 +03:00
Fringg faba3a8ed6 fix: enforce user restrictions in cabinet API and fix poll history crash
- Add restriction_topup check to POST /cabinet/balance/topup
- Add restriction_subscription check to 6 subscription endpoints:
  /renew, /purchase, /purchase-tariff, /traffic, /devices/purchase, /devices (legacy)
- All restricted endpoints return 403 Forbidden
- Fix TypeError in broadcast history when message_text is None (polls)
2026-03-01 20:59:06 +03:00
Fringg 4c72058d4a fix: generate missing crypto link on the fly and skip unresolved templates
Root cause: sync uses enrich_happ_links=False so subscription_crypto_link
is empty for 31k+ synced users. RemnaWave config buttons use
{{HAPP_CRYPT4_LINK}} template which stays unresolved, and since
the unresolved template is truthy it prevents the subscriptionUrl fallback
in the frontend — isValidDeepLink fails (no ://) and button is not rendered.

Fixes:
- /app-config endpoint: generate crypto link via encrypt API when missing,
  persist to DB so it's only generated once per user
- Template enrichment: skip setting resolvedUrl when templates remain
  unresolved, allowing frontend to fall through to subscriptionUrl
2026-02-27 23:04:58 +03:00
Fringg 9c004791f2 fix: prevent sync from overwriting subscription URLs with empty strings
- Guard sync update to only overwrite subscription_url when panel_url is non-empty
- Add fallback in /app-config and /subscription endpoints to fetch subscription URL
  from RemnaWave panel when missing in local DB (auto-heals synced users on access)
2026-02-27 22:42:04 +03:00
Fringg cdcabee80d fix: handle NULL used_promocodes for migrated users
Migrated EvoVPN users have NULL used_promocodes in DB.
Pydantic v2 doesn't apply field default when None is passed explicitly.
2026-02-27 22:06:42 +03:00
Fringg 9ae5d7bb60 fix: handle expired ORM attributes in sync UUID mutation
Two fixes for MissingGreenlet during panel user synchronization:

1. _capture_user_state: catch exceptions when reading potentially
   expired attributes (updated_at, remnawave_uuid). SQLAlchemy throws
   MissingGreenlet, not AttributeError, so getattr default doesn't help.
   Use sentinel to skip restoring uncaptured attrs on rollback.

2. Update branch: refresh db_user before sync _ensure_user_remnawave_uuid
   call if any attributes are expired (detected via sa_inspect).
2026-02-27 21:53:54 +03:00
Fringg efdf2a3189 fix: add exc_info traceback to sync user error log
Helps pinpoint exact location of MissingGreenlet errors during
panel user synchronization.
2026-02-27 21:42:57 +03:00
Fringg 2a90f871b9 fix: use SAVEPOINT instead of full rollback in sync user creation
Full db.rollback() in _get_or_create_bot_user_from_panel expires ALL
ORM objects in the session, causing MissingGreenlet errors when
subsequent sync iterations access user attributes from synchronous code.

Replace with begin_nested() (SAVEPOINT) so only the failed INSERT is
rolled back while the parent transaction and all cached objects remain
valid.
2026-02-27 21:32:10 +03:00
Fringg b47678cfb0 fix: remove premature tariff_id assignment in _apply_extension_updates
_apply_extension_updates was setting subscription.tariff_id before
extend_subscription() ran, causing the CRUD's is_tariff_change
detection to always return False. This skipped TrafficPurchase
cleanup and purchased_traffic_gb reset on auto-purchase tariff changes.

extend_subscription() already handles tariff_id assignment internally.
2026-02-27 10:19:15 +03:00
Fringg d708365aca fix: sync traffic reset across all tariff switch code paths
- cabinet admin change_tariff: add full reset logic (traffic_used_gb,
  purchased_traffic_gb, TrafficPurchase deletion, RemnaWave sync)
- cabinet switch_tariff: add local traffic_used_gb reset
- miniapp switch_tariff: add local traffic_used_gb reset + TrafficPurchase deletion
- auto_purchase_service: fix or→if/else branching for reset_traffic logic
2026-02-27 10:10:21 +03:00
Fringg 2cdbbc09ba fix: add local traffic_used_gb reset in all tariff switch handlers
- admin users handler: add reset_traffic param + local traffic_used_gb reset
- confirm_daily_tariff_switch: add local traffic_used_gb reset before commit
- confirm_instant_switch: add local traffic_used_gb reset before commit

Ensures DB traffic counter stays in sync with RemnaWave panel reset.
2026-02-27 10:03:46 +03:00
Fringg 4eaedd33bf feat: add RESET_TRAFFIC_ON_TARIFF_SWITCH admin setting
New boolean setting (default: True) controls whether user traffic
is reset when switching between tariff plans.

Changes:
- config.py: add RESET_TRAFFIC_ON_TARIFF_SWITCH setting
- system_settings_service.py: category override (TRAFFIC) + hints
- pricing.py: admin bot handler toggle entry
- cabinet/routes/subscription.py: pass reset_traffic to RemnaWave on switch
- webapi/routes/miniapp.py: same for miniapp tariff switch
- tariff_purchase.py: use setting in 3 switch handlers (was hardcoded)
- subscription_auto_purchase_service.py: separate tariff switch vs payment logic
- crud/subscription.py: conditional traffic_used_gb reset on tariff change
2026-02-27 09:57:37 +03:00
Fringg f605d8a39c chore: ruff format 4 files 2026-02-27 06:48:35 +03:00
Fringg cc5be7059f fix: address review findings from agent verification
Throttling:
- Init _last_cleanup with time.monotonic() instead of 0.0
- Use split(maxsplit=1) to avoid unnecessary list allocation
- Downgrade general throttle log from warning to debug

ChannelChecker:
- Guard from_user None in Update branch (lines 98-101)
- Widen TelegramBadRequest → TelegramAPIError to catch 403 Forbidden

Renewal pricing:
- Fix double-charging when base_traffic <= 0: pass purchased_traffic
  as sole traffic_limit and clear purchased_traffic flag to prevent
  the add-on block from adding it again
2026-02-27 05:43:04 +03:00
Fringg 739ba2986f fix: separate base and purchased traffic in renewal pricing
When a user has 25GB base + 100GB purchased = 125GB total,
the renewal priced it at the 250GB tier (nearest tier >= 125GB)
instead of pricing each component separately at its own tier:
base 25GB + purchased 100GB.

- Split traffic_limit_gb into base and purchased components
- Price each component at its own tier via get_traffic_price()
- Apply same discount percentage to purchased portion
- Log warning when purchased >= total (data corruption)
- Fix in both subscription_renewal_service and subscription CRUD
2026-02-27 05:32:08 +03:00
Fringg f52e6aedac fix: handle expired callback queries and harden middleware error handling
- Throttling: catch TelegramAPIError instead of bare Exception on .answer()
- Throttling: share single instance across message/callback dispatchers
- Throttling: fix from_user None crash, memory leak (cleanup on timer now)
- Throttling: use time.monotonic(), fix /start matching, fix log messages
- ChannelChecker: wrap .answer() in try/except for expired queries
- ChannelChecker: guard from_user None access
- DisplayNameRestriction: wrap .answer() in try/except TelegramAPIError
2026-02-27 05:21:26 +03:00
Fringg 256cbfcadf fix: email verification bypass, ban-notifications size limit, referral balance API
- Fix CABINET_EMAIL_VERIFICATION_ENABLED=false not working: auto-verify
  users on registration, allow login without verification when disabled
- Fix ban-notifications/send 400 error: paginate get_all_users (size<=1000)
- Add available_balance_kopeks and withdrawn_kopeks to referral info endpoint
2026-02-27 04:53:40 +03:00
Fringg dc3d22f52d fix: include desired_commission_percent in admin notification
Add the field to the notification data dict and render it in the
Telegram message sent to admins on new partner applications.
2026-02-27 04:08:10 +03:00
Fringg 7ea8fbd584 feat: add desired commission percent to partner application
Allow partners to specify their desired commission percentage (1-100%)
when applying. Field is optional and shown to admins during review.

Includes DB model, Alembic migration 0013, schema, route, and service changes.
2026-02-27 04:02:17 +03:00
Fringg b96e819da4 fix: add missing subscription columns migration
Adds last_webhook_update_at, is_daily_paused, last_daily_charge_at,
remnawave_short_uuid to subscriptions table for databases where
these columns were not created by the initial schema migration.
2026-02-27 03:03:43 +03:00
Fringg 399ca86561 fix: hide traffic topup button when tariff doesn't support it
In tariffs mode, check tariff.can_topup_traffic() instead of just
checking tariff_id existence. Prevents showing a button that leads
to an error when the tariff has traffic limits but no topup packages.
2026-02-27 01:01:55 +03:00
Fringg 200f91ef17 fix: freekassa OP-SP-7 error and missing telegram notification
- Replace test@example.com fallback with pool of 20 random emails
  to avoid OP-SP-7 duplicate email errors from payment provider
- Fix metadata_json parsing: handle both dict (SQLAlchemy JSON column)
  and string cases to prevent json.loads crash on dict input
- Add TypeError to exception handler for robustness
2026-02-27 01:00:50 +03:00
Fringg 59f0e42be7 fix: prevent squad drop on admin subscription type change, require subscription for wheel spins
- Fix active_internal_squads sent unconditionally as [] clearing Remnawave squads
- Fix dead code in _change_subscription_type (was_trial saved before mutation)
- Block wheel spins for users without active subscription (API + bot handler)
- Add has_subscription field to wheel config response
- Refund Stars to balance if spin payment arrives without subscription
- Fix SQL injection in promocode lookup (f-string → parameterized query)
- Remove redundant get_or_create_wheel_config call in stars handler
2026-02-27 00:53:46 +03:00
Egor 2044cecc6e Merge pull request #2650 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.20.1
2026-02-25 15:29:42 +03:00
github-actions[bot] ffffccb389 chore(main): release 3.20.1 2026-02-25 12:29:17 +00:00
Egor 28d263fc8d Merge pull request #2649 from BEDOLAGA-DEV/dev
Dev
2026-02-25 15:28:51 +03:00
Fringg bfef7cc629 fix: prevent race condition expiring active daily subscriptions
MonitoringService._check_expired_subscriptions() was marking daily
subscriptions as expired before DailySubscriptionService could charge
and extend them. Now get_expired_subscriptions() excludes active
(non-paused) daily subs — they are managed by DailySubscriptionService.

Also fix cabinet "0m until next charge" display: return None when
next_daily_charge_at is in the past instead of a stale datetime.
2026-02-25 15:07:24 +03:00
Fringg a696896d2c fix: make migrations 0010/0011 idempotent, escape HTML in crash notification
- 0010: add _has_column() guard before adding disable_trial/paid_on_leave
  (columns already exist from 0001 create_all on fresh DB)
- 0011: add _has_table() guard — skip if admin_roles already exists
- startup_notification_service: html.escape() error_type and error_message
  to prevent TelegramBadRequest when error contains <class ...>
2026-02-25 13:48:39 +03:00
Egor fd2e419e8e Merge pull request #2648 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.20.0
2026-02-25 12:49:20 +03:00
github-actions[bot] aaf0263fda chore(main): release 3.20.0 2026-02-25 09:48:22 +00:00
Egor d4d5031cc2 Merge pull request #2647 from BEDOLAGA-DEV/dev
Dev
2026-02-25 12:47:57 +03:00
Fringg b2d7abf5bd fix: resolve ruff lint errors (import sorting, unused variable) 2026-02-25 12:42:26 +03:00
Fringg 0f9f843236 style: format branding routes 2026-02-25 12:40:49 +03:00
Fringg cab425cfac style: format freekassa handler and keyboard files 2026-02-25 12:39:21 +03:00
Fringg 0da0c5547d feat: add separate Freekassa SBP and card payment methods
Split Freekassa into sub-methods: СБП/QR (i=44) and Карты РФ (i=36).
Each method has independent enable/display_name settings, dedicated
handlers, keyboard buttons, and correct payment_system_id routing.
Webhook notifications resolve display name from payment metadata.
2026-02-25 12:32:05 +03:00
Fringg 988d0e5c2f fix: initialize logger in bot_configuration.py
Add missing structlog import and logger initialization.
Without this, any code path hitting logger.info/warning/error
would raise NameError at runtime.
2026-02-25 11:55:07 +03:00
Fringg 1ce91749aa fix: resolve sync 404 errors, user deletion FK constraint, and device limit not sent to RemnaWave
1. Remove pointless HWID reset during auto-sync deactivation — user
   doesn't exist in panel, API returns 404, UUID is cleaned up below.

2. Clean up RESTRICT FK references (AdminAuditLog, WithdrawalRequest,
   AdminRole, UserRole, AccessPolicy) before deleting user to prevent
   IntegrityError on admin_audit_log_user_id_fkey.

3. Fix device limit not being sent to RemnaWave when
   DEVICES_SELECTION_DISABLED_AMOUNT=0: treat 0 as "no forced override"
   instead of sending hwidDeviceLimit:0 (which Remnawave interprets as
   unlimited). Now falls through to subscription.device_limit from tariff.

4. Add info-level logging to POST /api/users (was debug) to match
   existing PATCH logging for device limit diagnostics.
2026-02-25 11:53:49 +03:00
Fringg 731eb24364 fix: remove gemini-effect and noise from allowed background types 2026-02-25 07:43:46 +03:00
Fringg a15403b8b6 feat: add validation to animation config API
- Add Literal type whitelist for background type field
- Add settings dict validation (max 20 keys, no nested objects, bounded values)
- Add opacity (0-1) and blur (0-100) bounds with Pydantic Field constraints
- Fix mutable default dict with Field(default_factory=dict)
2026-02-25 07:13:07 +03:00
Egor ff8f3d02cf Merge pull request #2646 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.19.0
2026-02-25 06:37:01 +03:00
github-actions[bot] 69f57eddd6 chore(main): release 3.19.0 2026-02-25 03:36:23 +00:00
Egor fe567fffa8 Merge pull request #2645 from BEDOLAGA-DEV/dev
Dev
2026-02-25 06:35:59 +03:00
Fringg f300e07ce2 chore: ruff format 2026-02-25 06:34:22 +03:00
Fringg 628997fb48 fix: stack promo group + promo offer discounts in bot (matching cabinet) 2026-02-25 05:49:09 +03:00
Fringg 3dc0b93bdf fix: always include details in successful audit log entries 2026-02-25 05:31:32 +03:00
Fringg bea9da96d4 feat: capture query params in audit log details for all requests 2026-02-25 05:24:16 +03:00
Fringg 388fc7ee67 feat: add resource_type and request body to audit log entries 2026-02-25 05:11:43 +03:00
Fringg f6b6e22a95 feat: allow editing system roles 2026-02-25 04:45:41 +03:00
Fringg 60c4fe2e23 feat: add granular user permissions (balance, subscription, promo_group, referral, send_offer)
Split users:edit into fine-grained permissions for balance management,
subscription actions, promo group editing, referral commission, and
sending promo offers.
2026-02-25 04:42:32 +03:00
Fringg c1da8a4dba fix: RBAC audit log action filter and legacy admin level
- Change audit log action filter from exact match to ILIKE substring
  search so admins can search by partial action names
- Return level 1000 (not 999) for legacy config-based admins in
  /me/permissions so frontend correctly enables role management buttons
2026-02-25 04:07:09 +03:00
Fringg af6686ccfa fix: extract real client IP from X-Forwarded-For/X-Real-IP headers
Behind Docker reverse proxy, request.client.host always returns
the proxy container IP (172.20.0.2). Now reads X-Forwarded-For
first, then X-Real-IP, falling back to request.client.host.
2026-02-25 03:49:33 +03:00
Fringg 8893fc128e fix: grant legacy config-based admins full RBAC access
Legacy admins (ADMIN_IDS/ADMIN_EMAILS) had no RBAC roles in DB,
so check_permission returned 'No active roles assigned' and
role_level was 0, disabling all role management UI.

- check_permission: bypass RBAC for legacy admins
- get_user_permissions: return *:* and level 999 for legacy admins
- _get_admin_level: legacy admins get level 1000 (above superadmin)
2026-02-25 03:47:37 +03:00
Fringg 4598c2785a fix: RBAC API response format fixes and audit log user info
- Simplify permission registry to return flat list[PermissionSection] with actions as list[str]
- Add user_first_name and user_email to audit log entries via selectinload
- Fix unused import and naming convention lint warnings
2026-02-25 03:40:40 +03:00
Fringg 5a7dd3f164 fix: align RBAC route prefixes with frontend API paths
Frontend expects /admin/rbac/* namespace but backend used /admin/roles,
/admin/policies, /admin/audit-log. Updated:
- admin_roles.py: prefix /admin/roles → /admin/rbac, endpoints get /roles prefix
- admin_policies.py: prefix /admin/policies → /admin/rbac/policies
- admin_audit_log.py: prefix /admin/audit-log → /admin/rbac/audit-log
- assignments endpoints: /assign → /assignments
- role users endpoint: GET /roles/{role_id}/users with per-role filtering
2026-02-25 03:28:14 +03:00
Fringg bc7d0612f1 fix: specify foreign_keys on User.admin_roles_rel to resolve ambiguous join
UserRole has two FKs to users (user_id and assigned_by), causing
SQLAlchemy AmbiguousForeignKeysError on mapper initialization.
2026-02-25 03:23:41 +03:00
Fringg 1646f04bde fix: address RBAC review findings (CRITICAL + HIGH)
- stats:read → remnawave:manage for node restart/toggle (CRITICAL)
- add is_system guard on role update endpoint
- add Query bounds on /users limit/offset (ge/le)
- add db.rollback() in bootstrap exception handler
- migration: default=0 → server_default for level/priority columns
- CSV export: add formula injection sanitization
2026-02-25 03:17:06 +03:00
Fringg 3fee54f657 feat: add RBAC + ABAC permission system for admin cabinet
Backend:
- 4 new models: AdminRole, UserRole, AccessPolicy, AdminAuditLog
- Permission engine with RBAC wildcard matching + ABAC policy evaluation
- 26 permission sections (78 unique permissions) covering all admin routes
- require_permission() FastAPI dependency for route-level access control
- JWT tokens carry permissions, roles, role_level for frontend checks
- Admin roles CRUD with level-based hierarchy (viewers → superadmin)
- ABAC policies with time ranges and IP whitelist conditions
- Full audit log with CSV export
- Bootstrap service seeds 5 preset roles and assigns superadmins at startup
- Alembic migration 0011 for all RBAC tables
2026-02-25 03:02:40 +03:00
Fringg a594a0f79f fix: improve campaign notifications and ticket media in admin topics
- Campaign notifications: add tariff bonus display, hide empty promo group,
  compact format matching purchase notification style
- Ticket notifications: send media (photos) in the same topic as the text
  notification instead of separately. Uses caption for short texts, sequential
  messages for long texts with correct message_thread_id routing
2026-02-25 00:44:44 +03:00
Fringg 3642462670 feat: add per-channel disable settings and fix CHANNEL_REQUIRED_FOR_ALL bug
- Fix critical bug: is_active_paid_subscription() guard was blocking
  CHANNEL_REQUIRED_FOR_ALL from disabling paid subscriptions
- Add disable_trial_on_leave and disable_paid_on_leave columns to
  RequiredChannel model with Alembic migration 0010
- Refactor enforcement logic in channel_member.py and channel_checker.py
  to use per-channel settings instead of global env vars
- Update CRUD, Pydantic schemas, and admin API routes for new fields
- Add should_disable_subscription() and get_channel_settings() to
  channel_subscription_service for per-channel decision logic
2026-02-25 00:24:31 +03:00
Fringg 26efb157e4 fix: restore subscription_url and crypto_link after panel sync
_sync_subscription_to_panel() discarded the update_user() return value,
leaving subscription_url and subscription_crypto_link as None when
updating existing panel users. This caused "Connect devices" button
and HAPP_CRYPT4_LINK to disappear after admin subscription reset.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Changes in all registration paths:
- cmd_start: store referrer_id from campaign.partner_user_id
- language/rules/privacy handlers: skip referral code prompt when
  referrer_id already set from campaign
- channel check: pick up referrer_id from state instead of hardcoding None
2026-02-17 11:22:38 +03:00
Fringg d39063b22f fix: unassign all campaigns when revoking partner status
Previously revoke_partner only changed partner_status and commission,
leaving campaigns orphaned with invalid partner_user_id. Now sets
partner_user_id=NULL on all campaigns belonging to the revoked partner.
2026-02-17 11:11:25 +03:00
Fringg ea5d932476 feat: include partner campaigns in /partner/status response
Return assigned active campaigns with bonus info, deep_link and
web_link so the partner's referral page can display shareable links.
2026-02-17 10:45:11 +03:00
Fringg acc1323a54 fix: move PartnerStatus enum before User class to fix NameError
PartnerStatus was defined after the User class that references it,
causing a NameError on startup.
2026-02-17 09:56:11 +03:00
Fringg 58bfaeaddb feat: add partner system and withdrawal management to cabinet
- Partner application flow: user applies, admin reviews/approves/rejects
- Individual commission % per partner with admin management
- Campaign assignment/unassignment to partners
- Withdrawal system: balance check, create request, cancel
- Admin withdrawal management with risk scoring and fraud analysis
- Database migration: partner_applications table, user partner fields, campaign partner_user_id
- Pydantic schemas with proper validation bounds
- Batch user fetching to prevent N+1 queries
- Row locking on cancel to prevent race conditions
2026-02-17 09:51:36 +03:00
Fringg df5415f30b fix: reorder button_click_logs migration to nullify before ALTER TYPE
ALTER COLUMN user_id TYPE INTEGER failed with "integer out of range"
because the column contained telegram_id values (BIGINT) exceeding
INTEGER max. Swapped order: SET NULL first, then ALTER TYPE.
2026-02-17 08:19:21 +03:00
Egor 330d670f3f Merge pull request #2621 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.15.1
2026-02-17 07:55:52 +03:00
github-actions[bot] 41cb122a2f chore(main): release 3.15.1 2026-02-17 04:54:40 +00:00
Egor 1b3e6f2f11 Merge pull request #2620 from BEDOLAGA-DEV/dev
fix: add naive datetime guards to fromisoformat() in Redis cache readers
2026-02-17 07:54:20 +03:00
Fringg 6fa49485d9 fix: add naive datetime guards to fromisoformat() in Redis cache readers
Old Redis entries saved before utcnow→now(UTC) migration lack timezone
info, causing TypeError on subtraction with aware datetimes.
2026-02-17 07:52:26 +03:00
556 changed files with 97315 additions and 36482 deletions
+4
View File
@@ -16,6 +16,10 @@ __pycache__/
.pytest_cache/
.coverage
htmlcov/
.venv/
tests/
.mypy_cache/
.ruff_cache/
# Environment files
.env
+125 -17
View File
@@ -13,6 +13,15 @@ SUPPORT_USERNAME=@support
# Имя пользователя бота (опционально, автоопределяется)
# BOT_USERNAME=
# ===== СЕТЬ И ПРОКСИ =====
# URL SOCKS5 прокси-сервера для маршрутизации трафика бота к Telegram API
# Формат: socks5://user:password@host:port или socks5://host:port
# PROXY_URL=socks5://127.0.0.1:1080
# Альтернативный URL сервера Telegram Bot API (для регионов где api.telegram.org заблокирован)
# Примеры: Cloudflare Worker, self-hosted telegram-bot-api (tdlib), любой совместимый прокси
# TELEGRAM_API_URL=https://your-telegram-proxy.workers.dev
# ===== СИСТЕМА ПОДДЕРЖКИ =====
# Включить меню поддержки в интерфейсе
SUPPORT_MENU_ENABLED=true
@@ -116,10 +125,8 @@ BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновле
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
# Обязательная подписка на канал
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
# Channel subscription settings (channels are managed via admin panel)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
@@ -196,6 +203,9 @@ REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
# Сгенерируйте: openssl rand -hex 32
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
REMNAWAVE_WEBHOOK_SECRET=
# Уведомления администраторам о потере/восстановлении связи с нодами
# false = не отправлять события node.connection_lost / node.connection_restored
REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS=true
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
# Глобальный переключатель уведомлений пользователям от вебхуков
@@ -244,6 +254,18 @@ WEBHOOK_NOTIFY_DEVICES=true
# - Подходит для продажи готовых пакетов услуг
SALES_MODE=tariffs
# Управление сменой тарифа (для SALES_MODE=tariffs)
# UPGRADE / DOWNGRADE:
# true / true = все направления разрешены
# true / false = только повышение (на более дорогой тариф)
# false / true = только понижение (на более дешёвый тариф)
# false / false = смена тарифа полностью отключена
TARIFF_SWITCH_UPGRADE_ENABLED=true
TARIFF_SWITCH_DOWNGRADE_ENABLED=true
# Сброс привязанных устройств при продлении подписки (однократно при каждом продлении)
RESET_DEVICES_ON_RENEWAL=false
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
@@ -371,7 +393,10 @@ REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# Макс. кол-во платежей реферала, с которых начисляется комиссия (0 = без лимита)
REFERRAL_MAX_COMMISSION_PAYMENTS=0
# Показывать раздел партнёрки в кабинете
REFERRAL_PARTNER_SECTION_VISIBLE=true
# Уведомления
REFERRAL_NOTIFICATIONS_ENABLED=true
@@ -384,6 +409,8 @@ REFERRAL_WITHDRAWAL_ENABLED=false
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS=50000
# Интервал между запросами на вывод (дни)
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS=30
# Текст-подсказка для поля реквизитов при выводе (пустая строка = стандартный текст)
REFERRAL_WITHDRAWAL_REQUISITES_TEXT=
# Выводить только реферальный баланс (true) или весь баланс (false)
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE=true
# ID топика для уведомлений о заявках на вывод (0 = основной чат)
@@ -488,11 +515,11 @@ YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Рекуррентные платежи YooKassa (автосохранение карты для автоплатежей)
YOOKASSA_RECURRENT_ENABLED=false
# true = карта сохраняется обязательно, false = пользователь решает (чекбокс на стороне YooKassa)
YOOKASSA_RECURRENT_REQUIRED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# Отключить пополнение баланса через поддержку
SUPPORT_TOPUP_ENABLED=true
@@ -511,6 +538,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)
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
@@ -602,7 +630,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
@@ -629,6 +657,13 @@ FREEKASSA_WEBHOOK_PORT=8088
FREEKASSA_PAYMENT_SYSTEM_ID=
# Использовать API для создания заказов (обязательно для NSPK СБП)
FREEKASSA_USE_API=false
# Раздельные методы оплаты (отображаются как отдельные кнопки)
# СБП (QR код) — i=44
FREEKASSA_SBP_ENABLED=false
FREEKASSA_SBP_DISPLAY_NAME=СБП (QR код)
# Карты РФ — i=36
FREEKASSA_CARD_ENABLED=false
FREEKASSA_CARD_DISPLAY_NAME=Карта РФ
# ===== KASSA AI (api.fk.life) =====
# Отдельная платёжная система, работает параллельно с Freekassa
@@ -647,6 +682,84 @@ KASSA_AI_WEBHOOK_PORT=8089
# Способ оплаты: 44 = СБП (QR), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID=44
# ===== RIOPAY (api.riopay.online) =====
RIOPAY_ENABLED=false
RIOPAY_API_TOKEN=
# Ключ для HMAC-SHA512 верификации вебхуков (если не указан, используется RIOPAY_API_TOKEN)
RIOPAY_WEBHOOK_SECRET=
RIOPAY_DISPLAY_NAME=RioPay
RIOPAY_CURRENCY=RUB
RIOPAY_MIN_AMOUNT_KOPEKS=10000
RIOPAY_MAX_AMOUNT_KOPEKS=100000000
RIOPAY_WEBHOOK_PATH=/riopay-webhook
# URL для редиректа после оплаты (опционально)
RIOPAY_SUCCESS_URL=
RIOPAY_FAIL_URL=
# ===== SEVERPAY (severpay.io) =====
SEVERPAY_ENABLED=false
# Merchant ID
SEVERPAY_MID=
# Секретный токен для HMAC-SHA256
SEVERPAY_TOKEN=
SEVERPAY_DISPLAY_NAME=SeverPay
SEVERPAY_CURRENCY=RUB
SEVERPAY_MIN_AMOUNT_KOPEKS=10000
SEVERPAY_MAX_AMOUNT_KOPEKS=10000000
SEVERPAY_WEBHOOK_PATH=/severpay-webhook
# URL возврата после оплаты
# SEVERPAY_RETURN_URL=
# Время жизни платежа в минутах (30-4320)
SEVERPAY_LIFETIME=1440
# ===== PAYPEAR (api.paypear.ru) =====
PAYPEAR_ENABLED=false
# Shop ID для HTTP Basic Auth
PAYPEAR_SHOP_ID=
# Secret Key для HTTP Basic Auth
PAYPEAR_SECRET_KEY=
PAYPEAR_DISPLAY_NAME=PayPear
PAYPEAR_CURRENCY=RUB
PAYPEAR_MIN_AMOUNT_KOPEKS=10000
PAYPEAR_MAX_AMOUNT_KOPEKS=10000000
PAYPEAR_WEBHOOK_PATH=/paypear-webhook
# URL возврата после оплаты
# PAYPEAR_RETURN_URL=
# Время жизни платежа в минутах
PAYPEAR_PAYMENT_LIFETIME_MINUTES=60
# ===== ROLLYPAY (rollypay.io) =====
ROLLYPAY_ENABLED=false
# API ключ (X-API-Key header)
ROLLYPAY_API_KEY=
# Секрет для HMAC-SHA256 верификации вебхуков
ROLLYPAY_SIGNING_SECRET=
ROLLYPAY_DISPLAY_NAME=RollyPay
ROLLYPAY_CURRENCY=RUB
ROLLYPAY_MIN_AMOUNT_KOPEKS=10000
ROLLYPAY_MAX_AMOUNT_KOPEKS=10000000
ROLLYPAY_WEBHOOK_PATH=/rollypay-webhook
# URL возврата после оплаты
# ROLLYPAY_RETURN_URL=
# ===== AURAPAY (aurapay.tech) =====
AURAPAY_ENABLED=false
# API ключ (X-ApiKey header)
AURAPAY_API_KEY=
# UUID магазина (X-ShopId header)
AURAPAY_SHOP_ID=
# Секретный ключ #2 для HMAC-SHA256 верификации вебхуков
AURAPAY_SECRET_KEY=
AURAPAY_DISPLAY_NAME=AuraPay
AURAPAY_CURRENCY=RUB
AURAPAY_MIN_AMOUNT_KOPEKS=10000
AURAPAY_MAX_AMOUNT_KOPEKS=10000000
AURAPAY_WEBHOOK_PATH=/aurapay-webhook
# URL возврата после оплаты
# AURAPAY_RETURN_URL=
# Время жизни инвойса в минутах
AURAPAY_PAYMENT_LIFETIME_MINUTES=60
# ===== WATA =====
WATA_ENABLED=false
WATA_BASE_URL=https://api.wata.pro
@@ -802,8 +915,6 @@ PRICE_ROUNDING_ENABLED=true
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
APP_CONFIG_PATH=app-config.json
ENABLE_DEEP_LINKS=true
APP_CONFIG_CACHE_TTL=3600
@@ -875,6 +986,9 @@ DEBUG=false
WEBHOOK_URL=
WEBHOOK_PATH=/webhook
WEBHOOK_SECRET_TOKEN=
# IP адрес сервера для setWebhook — Telegram будет использовать его напрямую без DNS резолва домена
# Необходимо в регионах где Telegram не может резолвить домены (РФ и др.)
# WEBHOOK_IP=
WEBHOOK_DROP_PENDING_UPDATES=true
WEBHOOK_MAX_QUEUE_SIZE=1024
WEBHOOK_WORKERS=4
@@ -915,10 +1029,4 @@ WEB_API_TOKEN_HASH_ALGORITHM=sha256
# Логирование запросов
WEB_API_REQUEST_LOGGING=true
# Внешний админ-токен (для интеграции с другими ботами/системами)
# Токен для доступа через API другого бота
# EXTERNAL_ADMIN_TOKEN=
# ID бота, от которого принимается токен
# EXTERNAL_ADMIN_TOKEN_BOT_ID=
MINIAPP_STATIC_PATH=miniapp
Binary file not shown.

After

Width:  |  Height:  |  Size: 850 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+13 -11
View File
@@ -26,36 +26,38 @@ jobs:
- name: Get version info
id: version
run: |
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
SHORT_SHA=$(git rev-parse --short HEAD)
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
# Определяем версию и теги
# Read base version from release-please manifest (single source of truth)
BASE_VERSION=$(jq -r '."."' .release-please-manifest.json)
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-${SHORT_SHA}"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-dev-${SHORT_SHA}"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
VERSION="v${BASE_VERSION}-pr-${SHORT_SHA}"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-${SHORT_SHA}"
echo "🔀 Собираем PR версию: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "should_push=${{ github.event_name != 'pull_request' }}" >> $GITHUB_OUTPUT
echo "=== Информация о сборке ==="
echo "Версия: $VERSION"
echo "Коммит: $(git rev-parse --short HEAD)"
echo "Коммит: $SHORT_SHA"
echo "Теги: $TAGS"
echo "Push: ${{ github.event_name != 'pull_request' }}"
echo "==========================="
+10 -7
View File
@@ -42,25 +42,28 @@ jobs:
- name: Get version info
id: version
run: |
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
SHORT_SHA=$(git rev-parse --short HEAD)
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
# Read base version from release-please manifest (single source of truth)
BASE_VERSION=$(jq -r '."."' .release-please-manifest.json)
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-${SHORT_SHA}"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-dev-${SHORT_SHA}"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v${BASE_VERSION}-pr-${SHORT_SHA}"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Определяем, нужно ли пушить образ
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "should_push=false" >> $GITHUB_OUTPUT
echo "⚠️ PR - only build without push"
-1
View File
@@ -16,7 +16,6 @@
!uv.lock
!requirements.txt
!alembic.ini
!app-config.json
!release-please-config.json
!.release-please-manifest.json
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.15.0"
".": "3.54.1"
}
+1801
View File
File diff suppressed because it is too large Load Diff
+19 -32
View File
@@ -197,28 +197,17 @@ async def create_subscription(
### Документация кода
```python
async def calculate_subscription_price(
period_days: int,
traffic_gb: int,
devices_count: int,
servers_count: int
) -> int:
"""
Рассчитывает стоимость подписки.
Args:
period_days: Период подписки в днях
traffic_gb: Лимит трафика в ГБ (0 = безлимит)
devices_count: Количество устройств
servers_count: Количество серверов
Returns:
Стоимость в копейках
Raises:
ValueError: Если переданы некорректные параметры
"""
# implementation
from app.services.pricing_engine import PricingEngine
pricing = PricingEngine.calculate_renewal_price(
subscription=subscription,
period_days=30,
user=user,
)
# pricing.final_total — стоимость в копейках
# pricing.original_total — цена до скидок
# pricing.promo_group_discount — скидка промогруппы
# pricing.promo_offer_discount — скидка промо-оффера
```
### Обработка ошибок
@@ -341,20 +330,18 @@ python main.py
### Тестирование компонентов
```python
# tests/test_subscription_service.py
# tests/services/test_pricing_engine.py
import pytest
from app.services.subscription_service import SubscriptionService
from app.services.pricing_engine import PricingEngine
@pytest.mark.asyncio
async def test_calculate_price():
price = await SubscriptionService.calculate_subscription_price(
def test_calculate_renewal_price():
pricing = PricingEngine.calculate_renewal_price(
subscription=mock_subscription,
period_days=30,
traffic_gb=100,
devices_count=3,
servers_count=1
user=mock_user,
)
assert price > 0
assert isinstance(price, int)
assert pricing.final_total > 0
assert isinstance(pricing.final_total, int)
```
### Integration тесты
+17 -17
View File
@@ -4,27 +4,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY --from=ghcr.io/astral-sh/uv:0.10.8 /uv /uvx /bin/
COPY requirements.txt .
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=uv.lock,target=uv.lock \
uv sync --frozen --no-dev
FROM python:3.13-slim
ARG VERSION="v3.15.0" # x-release-please-version
ARG VERSION="v3.54.1" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
RUN groupadd -g 1000 app && \
useradd -u 1000 -g 1000 -m -s /bin/bash app
@@ -33,8 +33,8 @@ WORKDIR /app
COPY --chown=app:app . .
RUN mkdir -p logs data && \
chown -R app:app /app logs data
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails locales && \
chown -R app:app logs data uploads locales
USER app
@@ -56,7 +56,7 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.url="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
org.opencontainers.image.vendor="fr1ngg"
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" || exit 1
CMD ["python", "main.py"]
+16
View File
@@ -40,6 +40,22 @@ fix: ## Исправить код (ruff check --fix + format)
uv run ruff check . --fix
uv run ruff format .
.PHONY: migrate
migrate: ## Применить миграции (alembic upgrade head)
uv run alembic upgrade head
.PHONY: migration
migration: ## Создать миграцию (usage: make migration m="description")
uv run alembic revision --autogenerate -m "$(m)"
.PHONY: migrate-stamp
migrate-stamp: ## Пометить БД как актуальную (для существующих БД)
uv run alembic stamp head
.PHONY: migrate-history
migrate-history: ## Показать историю миграций
uv run alembic history --verbose
.PHONY: help
help: ## Показать список доступных команд
@echo ""
+295 -2126
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
script_location = migrations/alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
-658
View File
@@ -1,658 +0,0 @@
{
"config": {
"additionalLocales": [
"ru",
"zh",
"fa"
],
"branding": {
"name": "Subscription",
"logoUrl": "https://raw.githubusercontent.com/Fr1ngg/remnawave-bedolaga-telegram-bot/bf0c1ce711a26fa2f24559e7e4443820e68d758b/assets/bedolaga_app3.svg",
"supportUrl": "https://t.me"
}
},
"platforms": {
"ios": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "Open in App Store [EU]",
"fa": "باز کردن در App Store [EU]",
"ru": "Открыть в App Store [EU]",
"zh": "在 App Store 中打开 [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "Open in App Store [RU]",
"fa": "باز کردن در App Store [RU]",
"ru": "Открыть в App Store [RU]",
"zh": "在 App Store 中打开 [RU]"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "streisand",
"name": "Streisand",
"isFeatured": false,
"urlScheme": "streisand://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/streisand/id6450534064",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "shadowrocket",
"name": "Shadowrocket",
"isFeatured": false,
"urlScheme": "sub://",
"isNeedBase64Encoding": true,
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/shadowrocket/id932747118",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"android": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
"buttonText": {
"en": "Open in Google Play",
"fa": "باز کردن در Google Play",
"ru": "Открыть в Google Play",
"zh": "在 Google Play 中打开"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"fa": "برنامه را باز کنید و به سرور متصل شوید",
"ru": "Откройте приложение и подключитесь к серверу",
"zh": "打开应用并连接到服务器"
}
}
},
{
"id": "clash-meta",
"name": "Clash Meta",
"isFeatured": false,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/MetaCubeX/ClashMetaForAndroid/releases/download/v2.11.7/cmfa-2.11.7-meta-universal-release.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
},
{
"buttonLink": "https://f-droid.org/packages/com.github.metacubex.clash.meta/",
"buttonText": {
"en": "Open in F-Droid",
"fa": "در F-Droid باز کنید",
"ru": "Открыть в F-Droid",
"zh": "在 F-Droid 中打开"
}
}
],
"description": {
"en": "Download and install Clash Meta APK",
"fa": "دانلود و نصب Clash Meta APK",
"ru": "Скачайте и установите Clash Meta APK",
"zh": "下载并安装 Clash Meta APK"
}
},
"addSubscriptionStep": {
"description": {
"en": "Tap the button to import configuration",
"fa": "برای وارد کردن پیکربندی روی دکمه ضربه بزنید",
"ru": "Нажмите кнопку, чтобы импортировать конфигурацию",
"zh": "点击按钮导入配置"
}
},
"connectAndUseStep": {
"description": {
"en": "Open Clash Meta and tap on Connect",
"fa": "Clash Meta را باز کنید و روی اتصال ضربه بزنید",
"ru": "Откройте Clash Meta и нажмите Подключиться",
"zh": "打开 Clash Meta 并点击连接"
}
}
}
],
"macos": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выбрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
}
},
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"windows": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выبрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
}
},
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"linux": [],
"androidTV": [
{
"id": "new-app-androidtv-1760203310792",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Button TextGoogle Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
],
"appleTV": [
{
"id": "new-app-appletv-1760203488851",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
]
}
}
+62 -16
View File
@@ -45,6 +45,7 @@ from app.handlers.admin import (
referrals as admin_referrals,
remnawave as admin_remnawave,
reports as admin_reports,
required_channels as admin_required_channels,
rules as admin_rules,
servers as admin_servers,
statistics as admin_statistics,
@@ -58,11 +59,15 @@ from app.handlers.admin import (
users as admin_users,
welcome_text as admin_welcome_text,
)
from app.handlers.channel_member import register_handlers as register_channel_member_handlers
from app.handlers.gift_activation import register_handlers as register_gift_activation_handlers
from app.handlers.stars_payments import register_stars_handlers
from app.middlewares.auth import AuthMiddleware
from app.middlewares.blacklist import BlacklistMiddleware
from app.middlewares.button_stats import ButtonStatsMiddleware
from app.middlewares.chat_type_filter import ChatTypeFilterMiddleware
from app.middlewares.context_binding import ContextVarsMiddleware
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
from app.middlewares.global_error import GlobalErrorMiddleware
from app.middlewares.logging import LoggingMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
@@ -92,10 +97,21 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
except Exception as e:
logger.warning('Кеш не инициализирован', error=e)
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.bot_factory import create_bot
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
bot = create_bot()
proxy_url = settings.get_proxy_url()
nalogo_proxy_url = settings.get_nalogo_proxy_url()
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')
@@ -115,19 +131,23 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.message.middleware(ContextVarsMiddleware())
dp.callback_query.middleware(ContextVarsMiddleware())
dp.pre_checkout_query.middleware(ContextVarsMiddleware())
chat_type_filter = ChatTypeFilterMiddleware()
dp.message.middleware(chat_type_filter)
dp.callback_query.middleware(chat_type_filter)
dp.message.middleware(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()
dp.message.middleware(blacklist_middleware)
dp.callback_query.middleware(blacklist_middleware)
dp.pre_checkout_query.middleware(blacklist_middleware)
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
throttling_middleware = ThrottlingMiddleware()
dp.message.middleware(throttling_middleware)
dp.callback_query.middleware(throttling_middleware)
# Middleware для автоматического логирования кликов по кнопкам
if settings.MENU_LAYOUT_ENABLED:
@@ -135,20 +155,20 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(button_stats_middleware)
logger.info('📊 ButtonStatsMiddleware активирован')
if settings.CHANNEL_IS_REQUIRED_SUB:
from app.middlewares.channel_checker import ChannelCheckerMiddleware
from app.middlewares.channel_checker import ChannelCheckerMiddleware
channel_checker_middleware = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker_middleware)
dp.callback_query.middleware(channel_checker_middleware)
logger.info('🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован')
else:
logger.info('🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован')
channel_checker = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker)
dp.callback_query.middleware(channel_checker)
dp.message.middleware(AuthMiddleware())
dp.callback_query.middleware(AuthMiddleware())
dp.pre_checkout_query.middleware(AuthMiddleware())
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)
@@ -194,6 +214,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_bulk_ban.register_bulk_ban_handlers(dp)
admin_blacklist.register_blacklist_handlers(dp)
admin_blocked_users.register_handlers(dp)
admin_required_channels.register_handlers(dp)
register_channel_member_handlers(dp)
register_gift_activation_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_contests.register_handlers(dp)
@@ -241,7 +264,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
elif settings.is_cabinet_mode():
logger.info('🏠 Режим Cabinet активен, базовый URL', MINIAPP_CUSTOM_URL=settings.MINIAPP_CUSTOM_URL)
# Load per-section button styles cache
# Load per-section button styles cache and menu layout cache
if settings.is_cabinet_mode():
try:
from app.utils.button_styles_cache import load_button_styles_cache
@@ -250,12 +273,35 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
except Exception as e:
logger.warning('Failed to load button styles cache', error=e)
try:
from app.utils.menu_layout_cache import load_menu_layout_cache
await load_menu_layout_cache()
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('Мониторинг техработ остановлен')
+28
View File
@@ -0,0 +1,28 @@
"""Factory for creating Bot instances with proxy and custom API server support."""
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.config import settings
def create_bot(token: str | None = None, **kwargs) -> Bot:
"""Create a Bot instance with SOCKS5 proxy and/or custom Telegram API server."""
proxy_url = settings.get_proxy_url()
telegram_api_url = settings.get_telegram_api_url()
session = None
if proxy_url or telegram_api_url:
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
session_kwargs: dict = {}
if proxy_url:
session_kwargs['proxy'] = proxy_url
if telegram_api_url:
session_kwargs['api'] = TelegramAPIServer.from_base(telegram_api_url)
session = AiohttpSession(**session_kwargs)
kwargs.setdefault('default', DefaultBotProperties(parse_mode=ParseMode.HTML))
return Bot(token=token or settings.BOT_TOKEN, session=session, **kwargs)
+4 -1
View File
@@ -2,21 +2,24 @@
from .jwt_handler import (
create_access_token,
create_auto_login_token,
create_refresh_token,
decode_token,
get_token_payload,
)
from .password_utils import hash_password, verify_password
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget, validate_telegram_oidc_token
__all__ = [
'create_access_token',
'create_auto_login_token',
'create_refresh_token',
'decode_token',
'get_token_payload',
'hash_password',
'validate_telegram_init_data',
'validate_telegram_login_widget',
'validate_telegram_oidc_token',
'verify_password',
]
+31 -1
View File
@@ -11,13 +11,23 @@ from app.config import settings
JWT_ALGORITHM = 'HS256'
def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
def create_access_token(
user_id: int,
telegram_id: int | None = None,
*,
permissions: list[str] | None = None,
roles: list[str] | None = None,
role_level: int = 0,
) -> str:
"""
Create a short-lived access token.
Args:
user_id: Database user ID
telegram_id: Telegram user ID (optional for email-only users)
permissions: RBAC permission strings to embed in token
roles: Role names to embed in token
role_level: Maximum role level (0 = no special level)
Returns:
Encoded JWT access token
@@ -36,6 +46,14 @@ def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
if telegram_id is not None:
payload['telegram_id'] = telegram_id
# RBAC data — only include when provided to keep token compact
if permissions is not None:
payload['permissions'] = permissions
if roles is not None:
payload['roles'] = roles
if role_level > 0:
payload['role_level'] = role_level
secret = settings.get_cabinet_jwt_secret()
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
@@ -105,6 +123,18 @@ def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, An
return payload
def create_auto_login_token(user_id: int, ttl_hours: int = 72) -> str:
"""Short-lived JWT for auto-login from guest purchase success page."""
expires = datetime.now(UTC) + timedelta(hours=ttl_hours)
payload = {
'sub': str(user_id),
'type': 'auto_login',
'exp': expires,
'iat': datetime.now(UTC),
}
return jwt.encode(payload, settings.get_cabinet_jwt_secret(), algorithm=JWT_ALGORITHM)
def get_refresh_token_expires_at() -> datetime:
"""Get the expiration datetime for a new refresh token."""
expire_days = settings.get_cabinet_refresh_token_expire_days()
+154
View File
@@ -0,0 +1,154 @@
"""Temporary merge token management for account linking.
Stores short-lived tokens in Redis so the user can confirm merging
two cabinet accounts (primary absorbs secondary) via a separate
confirmation endpoint.
"""
import secrets
from datetime import UTC, datetime
from typing import Any
import structlog
from app.utils.cache import cache, cache_key
logger = structlog.get_logger(__name__)
MERGE_TOKEN_TTL_SECONDS = 1800 # 30 minutes
MERGE_TOKEN_PREFIX = 'account_merge'
async def create_merge_token(
primary_user_id: int,
secondary_user_id: int,
provider: str,
provider_id: str,
) -> str:
"""Generate a merge token and store its payload in Redis.
The token is a one-time confirmation handle: whoever presents it
within ``MERGE_TOKEN_TTL_SECONDS`` can execute the account merge.
Returns the raw token string (URL-safe base64, 32 bytes of entropy).
Raises ``RuntimeError`` if Redis write fails.
"""
token = secrets.token_urlsafe(32)
value: dict[str, Any] = {
'primary_user_id': primary_user_id,
'secondary_user_id': secondary_user_id,
'provider': provider,
'provider_id': provider_id,
'created_at': datetime.now(UTC).isoformat(),
}
key = cache_key(MERGE_TOKEN_PREFIX, token)
stored = await cache.set(key, value, expire=MERGE_TOKEN_TTL_SECONDS)
if not stored:
logger.error(
'Failed to store merge token in Redis',
primary_user_id=primary_user_id,
secondary_user_id=secondary_user_id,
provider=provider,
)
raise RuntimeError('Failed to store merge token')
logger.info(
'Merge token created',
primary_user_id=primary_user_id,
secondary_user_id=secondary_user_id,
provider=provider,
provider_id=provider_id,
)
return token
async def get_merge_token_data(token: str) -> dict[str, Any] | None:
"""Read merge token payload *without* consuming it.
Intended for preview / confirmation screens where the user sees
what will happen before they press "Confirm".
Returns ``None`` when the token is expired, missing, or malformed.
"""
key = cache_key(MERGE_TOKEN_PREFIX, token)
data: Any = await cache.get(key)
if data is None or not isinstance(data, dict):
return None
return data
async def consume_merge_token(token: str) -> dict[str, Any] | None:
"""Atomically read and delete a merge token (GETDEL).
This prevents double-merge race conditions: only the first caller
that reaches Redis will get the payload; every subsequent attempt
receives ``None``.
Returns the stored dict or ``None`` if already consumed / expired.
"""
key = cache_key(MERGE_TOKEN_PREFIX, token)
data: Any = await cache.getdel(key)
if data is None or not isinstance(data, dict):
return None
logger.info(
'Merge token consumed',
primary_user_id=data.get('primary_user_id'),
secondary_user_id=data.get('secondary_user_id'),
provider=data.get('provider'),
)
return data
_MAX_MERGE_RESTORE_ATTEMPTS = 3
async def restore_merge_token(token: str, data: dict[str, Any]) -> bool:
"""Re-store a consumed merge token so the user can retry after a DB failure.
Uses the remaining TTL based on the original ``created_at``.
Uses SETNX to avoid overwriting a fresh token.
Caps restore attempts to prevent infinite retry cycles.
Returns ``True`` if restored, ``False`` if exhausted or Redis write failed.
"""
restore_count = data.get('_restore_count', 0) + 1
if restore_count > _MAX_MERGE_RESTORE_ATTEMPTS:
logger.warning(
'Merge token exhausted restore attempts',
primary_user_id=data.get('primary_user_id'),
secondary_user_id=data.get('secondary_user_id'),
restore_count=restore_count,
)
return False
# Shallow copy to avoid mutating the caller's dict
data = {**data, '_restore_count': restore_count}
created_at_str: str = data.get('created_at', '')
try:
created_at = datetime.fromisoformat(created_at_str)
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=UTC)
elapsed = (datetime.now(UTC) - created_at).total_seconds()
remaining_ttl = max(1, min(int(MERGE_TOKEN_TTL_SECONDS - elapsed), MERGE_TOKEN_TTL_SECONDS))
except (ValueError, TypeError):
remaining_ttl = 60 # brief retry window — fail closed
key = cache_key(MERGE_TOKEN_PREFIX, token)
stored = await cache.setnx(key, data, expire=remaining_ttl)
if stored:
logger.info(
'Merge token restored after failed merge',
primary_user_id=data.get('primary_user_id'),
secondary_user_id=data.get('secondary_user_id'),
remaining_ttl=remaining_ttl,
restore_count=restore_count,
)
else:
logger.error(
'Failed to restore merge token to Redis (key may already exist)',
primary_user_id=data.get('primary_user_id'),
secondary_user_id=data.get('secondary_user_id'),
)
return bool(stored)
+137 -53
View File
@@ -1,5 +1,7 @@
"""OAuth 2.0 provider implementations for cabinet authentication."""
import base64
import hashlib
import secrets
from abc import ABC, abstractmethod
from typing import Any, TypedDict
@@ -33,7 +35,7 @@ class OAuthTokenResponse(TypedDict, total=False):
expires_in: int
refresh_token: str
scope: str
# VK-specific: email and user_id come in token response
# Provider-specific extra fields (optional)
email: str
user_id: int
@@ -67,15 +69,19 @@ class DiscordUserInfoResponse(TypedDict, total=False):
avatar: str
class VKUserInfoItem(TypedDict, total=False):
id: int
class VKIDUserData(TypedDict, total=False):
"""VK ID /oauth2/user_info response user object."""
user_id: str
first_name: str
last_name: str
photo_200: str
phone: str
avatar: str
email: str
class VKUserInfoResponse(TypedDict, total=False):
response: list[VKUserInfoItem]
class VKIDUserInfoResponse(TypedDict, total=False):
user: VKIDUserData
# --- Models ---
@@ -97,23 +103,45 @@ class OAuthUserInfo(BaseModel):
# --- CSRF state management (Redis) ---
async def generate_oauth_state(provider: str) -> str:
"""Generate a CSRF state token for OAuth flow. Stored in Redis with TTL."""
async def generate_oauth_state(provider: str, extra_data: dict[str, str] | None = None) -> str:
"""Generate a CSRF state token for OAuth flow.
Stores provider name and optional extra data (e.g., PKCE code_verifier) in Redis with TTL.
Keys prefixed with '_' are ephemeral and NOT stored in Redis (e.g., _code_challenge).
CacheService handles JSON serialization internally.
"""
state = secrets.token_urlsafe(32)
await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS)
value: dict[str, Any] = {'provider': provider}
if extra_data:
# Filter out ephemeral keys (prefixed with '_') — they're only needed for the URL
value.update({k: v for k, v in extra_data.items() if not k.startswith('_')})
stored = await cache.set(cache_key('oauth_state', state), value, expire=STATE_TTL_SECONDS)
if not stored:
logger.error('Failed to store OAuth state in Redis')
raise RuntimeError('Failed to store OAuth state')
return state
async def validate_oauth_state(state: str, provider: str) -> bool:
"""Validate and consume a CSRF state token from Redis."""
async def validate_oauth_state(state: str, provider: str | None = None) -> dict[str, Any] | None:
"""Validate and consume a CSRF state token from Redis.
Uses atomic GETDEL to prevent TOCTOU race conditions.
Returns the stored data dict (with 'provider' key + any extra data) or None if invalid.
Args:
state: The state token to validate.
provider: If provided, verifies it matches the stored provider.
If None, skips provider check (used for server-complete flow).
"""
key = cache_key('oauth_state', state)
stored_provider: str | None = await cache.get(key)
if stored_provider is None:
return False
await cache.delete(key)
if stored_provider != provider:
return False
return True
data: Any = await cache.getdel(key)
if data is None:
return None
if not isinstance(data, dict):
return None
if provider is not None and data.get('provider') != provider:
return None
return data
# --- Provider implementations ---
@@ -130,13 +158,28 @@ class OAuthProvider(ABC):
self.client_secret = client_secret
self.redirect_uri = redirect_uri
@abstractmethod
def get_authorization_url(self, state: str) -> str:
"""Build the authorization URL for the provider."""
def prepare_auth_state(self) -> dict[str, str]:
"""Return extra data to store with OAuth state (e.g., PKCE code_verifier).
Override in providers that need PKCE or other state-stored data.
The returned dict is stored in Redis alongside the state token
and passed back via validate_oauth_state().
"""
return {}
@abstractmethod
async def exchange_code(self, code: str) -> OAuthTokenResponse:
"""Exchange authorization code for tokens."""
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
"""Build the authorization URL for the provider.
kwargs may contain extra data from prepare_auth_state() (e.g., code_challenge).
"""
@abstractmethod
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
"""Exchange authorization code for tokens.
kwargs may contain provider-specific params (e.g., device_id, code_verifier for VK).
"""
@abstractmethod
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
@@ -151,7 +194,7 @@ class GoogleProvider(OAuthProvider):
TOKEN_URL = 'https://oauth2.googleapis.com/token'
USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
def get_authorization_url(self, state: str) -> str:
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
@@ -164,7 +207,7 @@ class GoogleProvider(OAuthProvider):
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
@@ -209,7 +252,7 @@ class YandexProvider(OAuthProvider):
TOKEN_URL = 'https://oauth.yandex.com/token'
USERINFO_URL = 'https://login.yandex.ru/info'
def get_authorization_url(self, state: str) -> str:
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
@@ -221,7 +264,7 @@ class YandexProvider(OAuthProvider):
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
@@ -275,7 +318,7 @@ class DiscordProvider(OAuthProvider):
TOKEN_URL = 'https://discord.com/api/oauth2/token'
USERINFO_URL = 'https://discord.com/api/v10/users/@me'
def get_authorization_url(self, state: str) -> str:
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
@@ -287,7 +330,7 @@ class DiscordProvider(OAuthProvider):
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
@@ -329,35 +372,72 @@ class DiscordProvider(OAuthProvider):
class VKProvider(OAuthProvider):
"""VK ID OAuth 2.1 provider (id.vk.ru).
Uses OAuth 2.1 with mandatory PKCE (S256).
Old oauth.vk.com endpoints deprecated since September 30, 2025.
"""
name = 'vk'
display_name = 'VK'
AUTHORIZE_URL = 'https://oauth.vk.com/authorize'
TOKEN_URL = 'https://oauth.vk.com/access_token'
USERINFO_URL = 'https://api.vk.com/method/users.get'
API_VERSION = '5.131'
AUTHORIZE_URL = 'https://id.vk.ru/authorize'
TOKEN_URL = 'https://id.vk.ru/oauth2/auth'
USERINFO_URL = 'https://id.vk.ru/oauth2/user_info'
def get_authorization_url(self, state: str) -> str:
@staticmethod
def _generate_pkce() -> tuple[str, str]:
"""Generate PKCE code_verifier and code_challenge (S256)."""
code_verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(code_verifier.encode('ascii')).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
return code_verifier, code_challenge
def prepare_auth_state(self) -> dict[str, str]:
"""Generate PKCE pair. code_verifier stored in Redis, code_challenge only goes to URL."""
code_verifier, code_challenge = self._generate_pkce()
# code_challenge is ephemeral — only needed for the authorization URL,
# not stored in Redis (code_verifier is the secret used during token exchange)
return {
'code_verifier': code_verifier,
'_code_challenge': code_challenge,
}
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
code_challenge: str = kwargs.get('_code_challenge', '')
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'email',
'scope': 'vkid.personal_info email',
'state': state,
'v': self.API_VERSION,
'code_challenge': code_challenge,
'code_challenge_method': 'S256',
}
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
device_id: str = kwargs.get('device_id', '')
code_verifier: str = kwargs.get('code_verifier', '')
state: str = kwargs.get('state', '')
if not device_id:
raise ValueError('device_id is required for VK ID token exchange')
if not code_verifier:
raise ValueError('code_verifier is required for VK ID token exchange')
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
response = await client.post(
self.TOKEN_URL,
params={
'client_id': self.client_id,
'client_secret': self.client_secret,
data={
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.redirect_uri,
'client_id': self.client_id,
'device_id': device_id,
'code_verifier': code_verifier,
'state': state,
},
)
response.raise_for_status()
@@ -366,33 +446,37 @@ class VKProvider(OAuthProvider):
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
access_token = token_data['access_token']
user_id: int | None = token_data.get('user_id')
# VK returns email in token response, not in userinfo
email: str | None = token_data.get('email')
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
response = await client.post(
self.USERINFO_URL,
params={
data={
'access_token': access_token,
'fields': 'photo_200',
'v': self.API_VERSION,
'client_id': self.client_id,
},
)
response.raise_for_status()
data: VKUserInfoResponse = response.json()
data: VKIDUserInfoResponse = response.json()
users: list[Any] = data.get('response', [])
user_data: VKUserInfoItem = users[0] if users else {} # type: ignore[assignment]
user_data = data.get('user')
if not user_data:
raise ValueError('VK ID response missing user data')
user_id = user_data.get('user_id')
if not user_id:
raise ValueError('VK ID response missing user_id')
# VK ID returns email only if 'email' scope was granted and user has a verified email
email: str | None = user_data.get('email') or None
return OAuthUserInfo(
provider='vk',
provider_id=str(user_id or user_data.get('id', '')),
provider_id=str(user_id),
email=email,
email_verified=bool(email),
first_name=user_data.get('first_name'),
last_name=user_data.get('last_name'),
avatar_url=user_data.get('photo_200'),
avatar_url=user_data.get('avatar'),
)
+168 -21
View File
@@ -1,15 +1,27 @@
"""Telegram authentication validation for cabinet."""
import asyncio
import hashlib
import hmac
import json
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from typing import Any
from urllib.parse import parse_qsl, unquote
from urllib.parse import parse_qsl
import httpx
import jwt as pyjwt
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
# Maximum allowed clock skew (seconds) for auth_date — tolerates minor drift between Telegram servers and ours.
_MAX_CLOCK_SKEW_SECONDS = 300
def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int = 86400) -> bool:
"""
Validate Telegram Login Widget data.
@@ -29,17 +41,27 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
if not check_hash:
return False
# Check auth_date is not too old
# Check auth_date is present and within valid range
auth_date = auth_data.get('auth_date')
if auth_date:
try:
# Use UTC timestamp to avoid timezone issues
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
age = (datetime.now(UTC) - auth_time).total_seconds()
if age > max_age_seconds:
return False
except (ValueError, TypeError, OSError):
if not auth_date:
return False
try:
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
# Build data-check-string (sorted key=value pairs, newline-separated)
data_check_arr = [f'{k}={v}' for k, v in sorted(auth_data.items()) if v is not None]
@@ -76,17 +98,27 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
if not received_hash:
return None
# Check auth_date is not too old
# Check auth_date is present and within valid range
auth_date = parsed.get('auth_date')
if auth_date:
try:
# Use UTC timestamp to avoid timezone issues
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
age = (datetime.now(UTC) - auth_time).total_seconds()
if age > max_age_seconds:
return None
except (ValueError, TypeError, OSError):
if not auth_date:
return None
try:
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
# Build data-check-string
data_check_arr = [f'{k}={v}' for k, v in sorted(parsed.items())]
@@ -105,7 +137,7 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
# Parse user data from the validated data
user_data_str = parsed.get('user')
if user_data_str:
user_data = json.loads(unquote(user_data_str))
user_data = json.loads(user_data_str)
return user_data
return parsed
@@ -125,3 +157,118 @@ def extract_telegram_user_from_init_data(init_data: str) -> dict[str, Any] | Non
User data dict with id, first_name, last_name, username, etc. or None if invalid
"""
return validate_telegram_init_data(init_data)
# JWKS cache (module-level, refreshed periodically)
_jwks_cache: dict[str, Any] = {}
_jwks_cache_expiry: datetime | None = None
_JWKS_CACHE_TTL_SECONDS = 3600 # 1 hour
_JWKS_URL = 'https://oauth.telegram.org/.well-known/jwks.json'
_OIDC_ISSUER = 'https://oauth.telegram.org'
_jwks_lock = asyncio.Lock()
_jwks_last_force_refresh: datetime | None = None
_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS = 30
def _build_public_keys(jwks_data: dict[str, Any]) -> dict[str, Any]:
"""Build public key mapping from JWKS data."""
public_keys: dict[str, Any] = {}
for key_data in jwks_data.get('keys', []):
kid = key_data.get('kid')
if kid:
public_keys[kid] = pyjwt.algorithms.RSAAlgorithm.from_jwk(key_data)
return public_keys
async def _get_jwks(force: bool = False) -> dict[str, Any]:
"""Fetch and cache Telegram OIDC JWKS keys."""
global _jwks_cache, _jwks_cache_expiry
now = datetime.now(UTC)
if not force and _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
return _jwks_cache
async with _jwks_lock:
# Double-check after acquiring lock
now = datetime.now(UTC)
if not force and _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
return _jwks_cache
proxy = settings.PROXY_URL if hasattr(settings, 'PROXY_URL') and settings.PROXY_URL else None
async with httpx.AsyncClient(timeout=10, proxy=proxy) as client:
response = await client.get(_JWKS_URL)
response.raise_for_status()
_jwks_cache = response.json()
_jwks_cache_expiry = now + timedelta(seconds=_JWKS_CACHE_TTL_SECONDS)
return _jwks_cache
async def _force_refresh_jwks(kid: str) -> dict[str, Any] | None:
"""Force JWKS refresh with cooldown protection. Returns refreshed JWKS or None if on cooldown."""
global _jwks_cache_expiry, _jwks_last_force_refresh
async with _jwks_lock:
now = datetime.now(UTC)
if (
_jwks_last_force_refresh
and (now - _jwks_last_force_refresh).total_seconds() < _JWKS_FORCE_REFRESH_COOLDOWN_SECONDS
):
logger.warning('Telegram OIDC: JWKS force refresh on cooldown', kid=kid)
return None
_jwks_last_force_refresh = now
_jwks_cache_expiry = None
return await _get_jwks(force=True)
async def validate_telegram_oidc_token(id_token: str, client_id: str) -> dict[str, Any] | None:
"""
Validate a Telegram OIDC id_token using JWKS.
Args:
id_token: JWT id_token from Telegram OIDC flow
client_id: Expected audience (bot's numeric ID as string)
Returns:
Decoded claims dict if valid, None otherwise.
Claims include: sub, id, name, preferred_username, picture, iss, aud, exp, iat
"""
try:
# Build public keys from JWKS
jwks_data = await _get_jwks()
public_keys = _build_public_keys(jwks_data)
# Decode header to get kid
unverified_header = pyjwt.get_unverified_header(id_token)
kid = unverified_header.get('kid')
# If kid not found, force JWKS refresh (key rotation) with cooldown
if kid and kid not in public_keys:
refreshed = await _force_refresh_jwks(kid)
if refreshed:
public_keys = _build_public_keys(refreshed)
if not kid or kid not in public_keys:
logger.warning('Telegram OIDC: unknown kid in id_token', kid=kid)
return None
claims = pyjwt.decode(
id_token,
key=public_keys[kid],
algorithms=['RS256'],
audience=client_id,
issuer=_OIDC_ISSUER,
options={'require': ['exp', 'iat', 'iss', 'aud', 'sub']},
)
return claims
except pyjwt.ExpiredSignatureError:
logger.warning('Telegram OIDC: id_token expired')
return None
except pyjwt.InvalidTokenError as e:
logger.warning('Telegram OIDC: invalid id_token', error=str(e))
return None
except httpx.HTTPError as e:
logger.error('Telegram OIDC: failed to fetch JWKS', error=str(e))
return None
+200 -52
View File
@@ -1,10 +1,9 @@
"""FastAPI dependencies for cabinet module."""
import asyncio
from datetime import UTC, datetime
import structlog
from aiogram import Bot
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,23 +15,14 @@ from app.services.blacklist_service import blacklist_service
from app.services.maintenance_service import maintenance_service
from .auth.jwt_handler import get_token_payload
from .auth.telegram_auth import validate_telegram_init_data
from .ip_utils import get_client_ip
logger = structlog.get_logger(__name__)
security = HTTPBearer(auto_error=False)
# Кешированный Bot для проверки подписки на канал
_channel_check_bot: Bot | None = None
def _get_channel_check_bot() -> Bot:
"""Получить или создать Bot для проверки подписки на канал."""
global _channel_check_bot
if _channel_check_bot is None:
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
return _channel_check_bot
async def get_cabinet_db() -> AsyncSession:
"""Get database session for cabinet operations."""
@@ -44,6 +34,7 @@ async def get_cabinet_db() -> AsyncSession:
async def get_current_cabinet_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
@@ -51,6 +42,7 @@ async def get_current_cabinet_user(
Get current authenticated cabinet user from JWT token.
Args:
request: FastAPI request object (for reading X-Telegram-Init-Data header)
credentials: HTTP Bearer credentials
db: Database session
@@ -105,6 +97,34 @@ async def get_current_cabinet_user(
detail='User account is not active',
)
# Defense in depth: cross-validate Telegram identity.
# The frontend sends X-Telegram-Init-Data on every request.
# If the header is present and cryptographically valid, verify that
# the Telegram user ID matches the JWT user's telegram_id.
# This prevents cross-account token reuse when Telegram WebView
# shares localStorage across accounts on the same device.
init_data_raw = request.headers.get('X-Telegram-Init-Data')
if init_data_raw and user.telegram_id is not None:
# Use generous max_age: Telegram Desktop caches initData
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
if tg_user is None:
logger.warning(
'Telegram initData validation failed but header was present',
jwt_user_id=user.id,
)
elif tg_user.get('id') != user.telegram_id:
logger.warning(
'Telegram identity mismatch: JWT belongs to different user than current Telegram account',
jwt_user_id=user.id,
jwt_telegram_id=user.telegram_id,
init_data_telegram_id=tg_user.get('id'),
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Session belongs to a different Telegram account. Please restart the app.',
headers={'WWW-Authenticate': 'Bearer'},
)
# Check blacklist
if user.telegram_id is not None:
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
@@ -132,47 +152,46 @@ async def get_current_cabinet_user(
},
)
# Check required channel subscription - ТОЛЬКО для Telegram юзеров
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
# Пропускаем проверку для email-only юзеров (нет telegram_id)
# Check required channel subscription - Telegram users only
if settings.CHANNEL_IS_REQUIRED_SUB:
# Skip for email-only users (no telegram_id)
if user.telegram_id is not None:
# Проверяем админа по telegram_id ИЛИ email
# Skip admin check
is_admin = settings.is_admin(
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
)
if not is_admin:
try:
bot = _get_channel_check_bot()
chat_member = await asyncio.wait_for(
bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=user.telegram_id),
timeout=10.0,
)
# Не закрываем сессию - бот переиспользуется
from app.services.channel_subscription_service import channel_subscription_service
if chat_member.status not in ['member', 'administrator', 'creator']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to our channel to continue',
'channel_link': settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except TimeoutError:
logger.warning('Timeout checking channel subscription for user', telegram_id=user.telegram_id)
# Don't block user if check times out
except Exception as e:
logger.warning(
'Failed to check channel subscription for user', telegram_id=user.telegram_id, error=e
channels_with_status = await channel_subscription_service.get_channels_with_status(user.telegram_id)
is_subscribed = (
all(ch['is_subscribed'] for ch in channels_with_status) if channels_with_status else True
)
if not is_subscribed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to the required channels to continue',
'channels': channels_with_status,
},
)
# Don't block user if check fails
# Throttled update of cabinet_last_login (at most every 5 minutes)
now = datetime.now(UTC)
if not user.cabinet_last_login or (now - user.cabinet_last_login).total_seconds() > 300:
try:
user.cabinet_last_login = now
await db.commit()
except Exception:
pass
return user
async def get_optional_cabinet_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User | None:
@@ -200,31 +219,160 @@ async def get_optional_cabinet_user(
if not user or user.status != 'active':
return None
# Cross-validate Telegram identity (same as get_current_cabinet_user)
init_data_raw = request.headers.get('X-Telegram-Init-Data')
if init_data_raw and user.telegram_id is not None:
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
if tg_user and tg_user.get('id') != user.telegram_id:
logger.warning(
'Telegram identity mismatch in optional auth',
jwt_user_id=user.id,
jwt_telegram_id=user.telegram_id,
init_data_telegram_id=tg_user.get('id'),
)
return None
return user
async def get_current_admin_user(
request: Request,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
"""
Get current authenticated admin user.
Checks if the user is admin by telegram_id or email.
Checks if the user is admin by legacy config (ADMIN_IDS / ADMIN_EMAILS)
**or** by RBAC role assignment (any role with level > 0).
Args:
request: FastAPI request object
user: Authenticated User object
db: Database session
Returns:
Authenticated admin User object
Raises:
HTTPException: If user is not an admin
HTTPException: If user is not an admin by either mechanism
"""
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Admin access required',
)
# Legacy check: config-based admin list
is_legacy_admin = settings.is_admin(
telegram_id=user.telegram_id,
email=user.email if user.email_verified else None,
)
if is_legacy_admin:
return user
return user
# RBAC check: user has any active role with level > 0
from app.database.crud.rbac import UserRoleCRUD
_permissions, _role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
if max_level > 0:
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Admin access required',
)
def require_permission(*permissions: str):
"""
FastAPI dependency factory for RBAC permission checks.
Usage::
@router.get("/users", dependencies=[Depends(require_permission("users:read"))])
async def list_users(...): ...
# Or inject the user:
@router.get("/users")
async def list_users(user: User = Depends(require_permission("users:read"))): ...
"""
if not permissions:
raise ValueError('require_permission() requires at least one permission argument')
async def dependency(
request: Request,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
from app.services.permission_service import PermissionService
try:
client_ip = get_client_ip(request)
except HTTPException:
logger.warning('Unable to determine client IP in require_permission')
client_ip = 'unknown'
user_agent = request.headers.get('user-agent', '')
# Extract resource_type from the first permission (section before ':')
resource_type = None
if permissions:
first_perm = permissions[0]
if ':' in first_perm:
resource_type = first_perm.split(':', maxsplit=1)[0]
for perm in permissions:
allowed, reason = await PermissionService.check_permission(
db,
user,
perm,
ip_address=client_ip,
)
if not allowed:
await PermissionService.log_action(
db,
user_id=user.id,
action=perm,
resource_type=resource_type,
status='denied',
ip_address=client_ip,
user_agent=user_agent,
request_method=request.method,
request_path=str(request.url.path),
details={'reason': reason},
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Permission denied: {reason}',
)
# Capture request details
details: dict = {
'method': request.method,
'path': str(request.url.path),
}
query_params = dict(request.query_params)
if query_params:
details['query_params'] = query_params
if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'):
try:
body = await request.body()
if body:
import json
details['request_body'] = json.loads(body)
except Exception:
pass
# Log successful access with all requested permissions
await PermissionService.log_action(
db,
user_id=user.id,
action=','.join(permissions),
resource_type=resource_type,
status='success',
ip_address=client_ip,
user_agent=user_agent,
request_method=request.method,
request_path=str(request.url.path),
details=details,
)
await db.commit()
return user
return dependency
+60
View File
@@ -0,0 +1,60 @@
"""Shared IP extraction utilities for cabinet module."""
from ipaddress import ip_address, ip_network
from fastapi import HTTPException, Request, status
from app.config import settings
def _is_trusted_proxy(peer_ip: str, trusted: set[str]) -> bool:
"""Check if peer IP matches any trusted proxy entry (IP or CIDR)."""
if not trusted:
return False
try:
addr = ip_address(peer_ip)
except ValueError:
return False
for entry in trusted:
try:
if '/' in entry:
if addr in ip_network(entry, strict=False):
return True
elif addr == ip_address(entry):
return True
except ValueError:
continue
return False
def get_client_ip(request: Request) -> str:
"""Extract real client IP, trusting proxy headers only from known proxies.
Raises HTTPException 400 if the peer IP cannot be determined
(request.client is None — e.g., test harness or broken transport).
"""
if not request.client:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Unable to determine client IP',
)
peer_ip = request.client.host
trusted_proxies = settings.get_cabinet_trusted_proxies()
if trusted_proxies and _is_trusted_proxy(peer_ip, trusted_proxies):
forwarded = request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
if forwarded:
try:
ip_address(forwarded)
return forwarded
except ValueError:
pass # invalid IP in header — fall through to peer_ip
real_ip = request.headers.get('X-Real-IP', '').strip()
if real_ip:
try:
ip_address(real_ip)
return real_ip
except ValueError:
pass
return peer_ip
+67 -2
View File
@@ -2,18 +2,34 @@
from fastapi import APIRouter
from .account_linking import merge_router as merge_router, router as account_linking_router
from .admin_apps import router as admin_apps_router
from .admin_audit_log import router as admin_audit_log_router
from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router
from .admin_bulk_actions import router as admin_bulk_actions_router
from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_channels import router as admin_channels_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_info_pages import router as admin_info_pages_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
from .admin_pinned_messages import router as admin_pinned_messages_router
from .admin_policies import router as admin_policies_router
from .admin_promo_offers import router as admin_promo_offers_router
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
from .admin_referral_network import router as admin_referral_network_router
from .admin_remnawave import router as admin_remnawave_router
from .admin_roles import router as admin_roles_router
from .admin_sales_stats import router as admin_sales_stats_router
from .admin_servers import router as admin_servers_router
from .admin_settings import router as admin_settings_router
from .admin_stats import router as admin_stats_router
@@ -23,19 +39,26 @@ from .admin_traffic import router as admin_traffic_router
from .admin_updates import router as admin_updates_router
from .admin_users import router as admin_users_router
from .admin_wheel import router as admin_wheel_router
from .admin_withdrawals import router as admin_withdrawals_router
from .auth import router as auth_router
from .balance import router as balance_router
from .branding import router as branding_router
from .contests import router as contests_router
from .gift import router as gift_router
from .info import router as info_router
from .info_pages import router as info_pages_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
from .polls import router as polls_router
from .promo import router as promo_router
from .promocode import router as promocode_router
from .referral import router as referral_router
from .subscription import router as subscription_router
from .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,
@@ -43,17 +66,35 @@ from .ticket_notifications import (
from .tickets import router as tickets_router
from .websocket import router as websocket_router
from .wheel import router as wheel_router
from .withdrawal import router as withdrawal_router
# Conditional imports
try:
from .apple_iap import router as apple_iap_router
except ImportError:
apple_iap_router = None
# 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)
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)
# Apple IAP routes
if apple_iap_router is not None:
router.include_router(apple_iap_router)
router.include_router(partner_application_router)
router.include_router(withdrawal_router)
# Notifications router MUST be before tickets router to avoid route conflict
router.include_router(ticket_notifications_router)
router.include_router(tickets_router)
@@ -64,27 +105,38 @@ router.include_router(promo_router)
router.include_router(notifications_router)
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)
router.include_router(info_pages_router)
# Wheel routes
router.include_router(wheel_router)
# Gift routes
router.include_router(gift_router)
# Admin routes (notifications router MUST be before tickets router to avoid route conflict)
router.include_router(admin_ticket_notifications_router)
router.include_router(admin_tickets_router)
router.include_router(admin_settings_router)
router.include_router(admin_apps_router)
router.include_router(admin_wheel_router)
router.include_router(admin_tariffs_router)
router.include_router(admin_servers_router)
router.include_router(admin_stats_router)
router.include_router(admin_referral_network_router)
router.include_router(admin_sales_stats_router)
router.include_router(admin_ban_system_router)
router.include_router(admin_broadcasts_router)
router.include_router(admin_promocodes_router)
router.include_router(admin_promo_groups_router)
router.include_router(admin_campaigns_router)
router.include_router(admin_partners_router)
router.include_router(admin_withdrawals_router)
router.include_router(admin_users_router)
router.include_router(admin_bulk_actions_router)
router.include_router(admin_payment_methods_router)
router.include_router(admin_landings_router)
router.include_router(admin_payments_router)
router.include_router(admin_promo_offers_router)
router.include_router(admin_remnawave_router)
@@ -93,6 +145,19 @@ router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
router.include_router(admin_pinned_messages_router)
router.include_router(admin_button_styles_router)
router.include_router(admin_menu_layout_router)
router.include_router(admin_channels_router)
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)
router.include_router(admin_info_pages_router)
# WebSocket route
router.include_router(websocket_router)
+930
View File
@@ -0,0 +1,930 @@
"""Account linking and merge routes for cabinet.
Router 1 (`router`): JWT-protected endpoints for linking/unlinking OAuth providers.
Exception: `link/server-complete` uses state-token auth instead of JWT (for Mini App external browser flow).
Router 2 (`merge_router`): Public endpoints for merge preview and execution.
"""
import hashlib
from datetime import UTC, datetime
from typing import Literal, NotRequired, TypedDict
import structlog
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.system_setting import get_setting_value
from app.database.crud.user import (
OAUTH_PROVIDER_COLUMNS,
clear_user_oauth_provider_id,
get_user_by_id,
get_user_by_oauth_provider,
get_user_by_telegram_id,
set_user_oauth_provider_id,
)
from app.database.models import User
from app.services.account_merge_service import compute_auth_methods, execute_merge, get_merge_preview
from app.utils.cache import RateLimitCache, TokenReplayCache
from ..auth.merge_service import (
MERGE_TOKEN_TTL_SECONDS,
consume_merge_token,
create_merge_token,
get_merge_token_data,
restore_merge_token,
)
from ..auth.oauth_providers import (
generate_oauth_state,
get_provider,
validate_oauth_state,
)
from ..auth.telegram_auth import (
validate_telegram_init_data,
validate_telegram_login_widget,
validate_telegram_oidc_token,
)
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..ip_utils import get_client_ip
from ..schemas.auth import UserResponse
from .auth import _create_auth_response, _store_refresh_token, _user_to_response
logger = structlog.get_logger(__name__)
OAuthProviderName = Literal['google', 'yandex', 'discord', 'vk']
# Ensure OAuthProviderName Literal stays in sync with OAUTH_PROVIDER_COLUMNS
_EXPECTED_PROVIDERS = {'google', 'yandex', 'discord', 'vk'}
if set(OAUTH_PROVIDER_COLUMNS.keys()) != _EXPECTED_PROVIDERS:
raise RuntimeError(
f'OAuthProviderName Literal is out of sync with OAUTH_PROVIDER_COLUMNS: '
f'{set(OAUTH_PROVIDER_COLUMNS.keys())} != {_EXPECTED_PROVIDERS}'
)
class OAuthStateData(TypedDict):
"""Typed dict for Redis-stored OAuth state data."""
provider: str # Always present
linking: NotRequired[str] # 'true' if account linking flow
user_id: NotRequired[str] # ID of user who initiated linking
code_verifier: NotRequired[str] # PKCE code verifier (VK)
def _get_active_providers() -> list[str]:
"""Вернуть список активных провайдеров аутентификации (только включённые)."""
providers: list[str] = ['telegram']
if settings.is_cabinet_email_auth_enabled():
providers.append('email')
providers.extend(settings.get_enabled_oauth_provider_names())
return providers
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class LinkedProvider(BaseModel):
provider: str
linked: bool
identifier: str | None = None
class LinkedProvidersResponse(BaseModel):
providers: list[LinkedProvider]
class LinkInitResponse(BaseModel):
authorize_url: str
state: str
class LinkCallbackRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=2048, description='Authorization code from provider')
state: str = Field(..., min_length=1, max_length=128, description='CSRF state token')
device_id: str | None = Field(None, max_length=256, description='Device ID from VK ID callback')
class LinkCallbackResponse(BaseModel):
success: bool
message: str | None = None
merge_required: bool = False
merge_token: str | None = None
class UnlinkResponse(BaseModel):
success: bool
class LinkTelegramRequest(BaseModel):
"""Request for linking Telegram account. Supply EITHER init_data, id_token, OR widget fields."""
# Mini App: Telegram WebApp initData
init_data: str | None = Field(None, max_length=4096, description='Telegram WebApp initData string')
# OIDC: id_token from Telegram Login popup
id_token: str | None = Field(None, max_length=4096, description='Telegram OIDC id_token (JWT)')
# Login Widget fields
id: int | None = Field(None, description='Telegram user ID from Login Widget')
first_name: str | None = Field(None, max_length=256, description="User's first name")
last_name: str | None = Field(None, max_length=256, description="User's last name")
username: str | None = Field(None, max_length=256, description="User's username")
photo_url: str | None = Field(None, max_length=2048, description="User's photo URL")
auth_date: int | None = Field(None, description='Unix timestamp of authentication')
hash: str | None = Field(None, min_length=64, max_length=64, description='Authentication hash (SHA-256 hex)')
@model_validator(mode='after')
def check_exclusive(self) -> 'LinkTelegramRequest':
has_init = self.init_data is not None
has_oidc = self.id_token is not None
has_widget = self.id is not None or self.hash is not None or self.auth_date is not None
modes = sum([has_init, has_oidc, has_widget])
if modes > 1:
raise ValueError('Provide exactly one of: init_data, id_token, or Login Widget fields')
if modes == 0:
raise ValueError('Provide one of: init_data, id_token, or Login Widget fields (id, auth_date, hash)')
if has_widget and not (self.id is not None and self.auth_date is not None and self.hash is not None):
raise ValueError('Login Widget mode requires id, auth_date, and hash fields')
return self
class MergePreviewSubscription(BaseModel):
status: str
is_trial: bool
end_date: datetime | None = None
traffic_limit_gb: float
traffic_used_gb: float
device_limit: int
tariff_name: str | None = None
autopay_enabled: bool
class MergePreviewUser(BaseModel):
id: int
username: str | None = None
first_name: str | None = None
email: str | None = None
auth_methods: list[str]
balance_kopeks: int = 0
subscription: MergePreviewSubscription | None = None
created_at: datetime | None = None
class MergePreviewResponse(BaseModel):
primary: MergePreviewUser
secondary: MergePreviewUser
expires_in_seconds: int
class MergeRequest(BaseModel):
keep_subscription_from: int = Field(..., description='User ID whose subscription to keep')
class MergeResponse(BaseModel):
success: bool
access_token: str | None = None
refresh_token: str | None = None
user: UserResponse | None = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_provider_identifier(user: User, provider: str) -> str | None:
"""Return the identifier (provider_id or email) for a given provider, or None."""
match provider:
case 'telegram':
return str(user.telegram_id) if user.telegram_id else None
case 'email':
return user.email if user.email and user.password_hash else None
case _:
column = OAUTH_PROVIDER_COLUMNS.get(provider)
if not column:
return None
value = getattr(user, column, None)
return str(value) if value else None
def _count_auth_methods(user: User) -> int:
"""Count how many auth methods the user has linked."""
return len(compute_auth_methods(user))
async def _exchange_and_link_oauth(
*,
db: AsyncSession,
user: User,
provider: str,
code: str,
state: str,
state_data: OAuthStateData,
device_id: str | None,
log_context: str,
) -> LinkCallbackResponse:
"""Shared OAuth linking logic: exchange code, fetch user info, link or merge.
Used by both link_provider_callback (JWT-authed) and link_server_complete (state-authed).
"""
oauth_provider = get_provider(provider)
if not oauth_provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Requested OAuth provider is not available',
)
# Exchange code for tokens
exchange_kwargs: dict[str, str] = {'state': state}
code_verifier = state_data.get('code_verifier')
if code_verifier:
exchange_kwargs['code_verifier'] = code_verifier
if device_id:
exchange_kwargs['device_id'] = device_id
try:
token_data = await oauth_provider.exchange_code(code, **exchange_kwargs)
except Exception as exc:
logger.error('OAuth code exchange failed', context=log_context, provider=provider, exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to exchange authorization code',
) from exc
# Fetch user info from provider
try:
user_info = await oauth_provider.get_user_info(token_data)
except Exception as exc:
logger.error('OAuth user info fetch failed', context=log_context, provider=provider, exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to fetch user information from provider',
) from exc
# Check if provider_id is already linked to THIS user
column = OAUTH_PROVIDER_COLUMNS[provider]
current_value = getattr(user, column, None)
if current_value and str(current_value) == user_info.provider_id:
return LinkCallbackResponse(success=True, message='already_linked')
# Check if provider_id is linked to ANOTHER user
existing_user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
if existing_user and existing_user.id != user.id:
logger.info(
'Account linking conflict: provider already linked to another user',
context=log_context,
provider=provider,
provider_id=user_info.provider_id,
current_user_id=user.id,
existing_user_id=existing_user.id,
)
merge_token = await create_merge_token(
primary_user_id=user.id,
secondary_user_id=existing_user.id,
provider=provider,
provider_id=user_info.provider_id,
)
return LinkCallbackResponse(
success=False,
merge_required=True,
merge_token=merge_token,
)
# Link the provider to current user
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='This provider account was just linked to another user',
) from exc
logger.info(
'OAuth provider linked to account',
context=log_context,
provider=provider,
provider_id=user_info.provider_id,
user_id=user.id,
)
return LinkCallbackResponse(success=True, message='linked')
# ---------------------------------------------------------------------------
# Router 1: Account linking (JWT required)
# ---------------------------------------------------------------------------
router = APIRouter(prefix='/auth/account', tags=['Cabinet Account Linking'])
@router.get('/linked-providers', response_model=LinkedProvidersResponse)
async def get_linked_providers(
user: User = Depends(get_current_cabinet_user),
) -> LinkedProvidersResponse:
"""Return all auth methods with their link status for the current user."""
providers: list[LinkedProvider] = []
for provider in _get_active_providers():
identifier = _get_provider_identifier(user, provider)
providers.append(
LinkedProvider(
provider=provider,
linked=identifier is not None,
identifier=identifier,
)
)
return LinkedProvidersResponse(providers=providers)
@router.get('/link/{provider}/init', response_model=LinkInitResponse)
async def link_provider_init(
provider: OAuthProviderName,
user: User = Depends(get_current_cabinet_user),
) -> LinkInitResponse:
"""Start OAuth flow for linking a new provider to the current account."""
# Check if already linked
column = OAUTH_PROVIDER_COLUMNS[provider]
if getattr(user, column, None):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Provider is already linked to your account',
)
oauth_provider = get_provider(provider)
if not oauth_provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Requested OAuth provider is not available',
)
# Generate PKCE data for VK (and potentially future providers)
auth_extra = oauth_provider.prepare_auth_state()
extra_data: dict[str, str] = {
'linking': 'true',
'user_id': str(user.id),
}
if auth_extra:
extra_data.update(auth_extra)
state = await generate_oauth_state(provider, extra_data=extra_data)
# Only pass URL-safe params (prefixed with _) to authorize URL; exclude secrets like code_verifier
url_params = {k: v for k, v in auth_extra.items() if k.startswith('_')} if auth_extra else {}
authorize_url = oauth_provider.get_authorization_url(state, **url_params)
return LinkInitResponse(authorize_url=authorize_url, state=state)
@router.post('/link/{provider}/callback', response_model=LinkCallbackResponse)
async def link_provider_callback(
provider: OAuthProviderName,
request: LinkCallbackRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> LinkCallbackResponse:
"""Handle OAuth callback for linking a provider to the current account."""
# 1. Validate CSRF state
state_data = await validate_oauth_state(request.state, provider)
if not state_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid or expired OAuth state',
)
# 1b. Validate that this state was created for account linking (not login)
if state_data.get('linking') != 'true' or not state_data.get('user_id'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='OAuth state was not initiated for account linking',
)
# 1c. Validate that the user who initiated the link flow is the same user completing it
state_user_id = state_data['user_id']
if str(user.id) != state_user_id:
logger.warning(
'OAuth state user_id mismatch in link callback',
state_user_id=state_user_id,
current_user_id=user.id,
provider=provider,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='OAuth state was initiated by a different user',
)
# 2-7. Exchange code, fetch user info, link or merge
return await _exchange_and_link_oauth(
db=db,
user=user,
provider=provider,
code=request.code,
state=request.state,
state_data=state_data,
device_id=request.device_id,
log_context='link-callback',
)
@router.post('/unlink/{provider}', response_model=UnlinkResponse)
async def unlink_provider(
provider: OAuthProviderName,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> UnlinkResponse:
"""Unlink an OAuth provider from the current account."""
column = OAUTH_PROVIDER_COLUMNS[provider]
if not getattr(user, column, None):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Provider is not linked to your account',
)
# Ensure at least one auth method remains
if _count_auth_methods(user) <= 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot unlink last authentication method',
)
await clear_user_oauth_provider_id(db, user, provider)
await db.commit()
return UnlinkResponse(success=True)
@router.post('/link/telegram', response_model=LinkCallbackResponse)
async def link_telegram(
request: LinkTelegramRequest,
raw_request: Request,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> LinkCallbackResponse:
"""Link Telegram account via WebApp initData, OIDC id_token, or Login Widget."""
# Rate limit
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'link_telegram', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
# 1. Already has Telegram linked?
if user.telegram_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Telegram is already linked to your account',
)
# 2. Validate and extract telegram_id
telegram_id: int | None = None
telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
if request.init_data:
# Mini App flow: validate initData
# 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,
detail='Invalid or expired Telegram initData',
)
telegram_id = int(user_data['id'])
telegram_username = user_data.get('username')
telegram_first_name = user_data.get('first_name')
telegram_last_name = user_data.get('last_name')
elif request.id_token:
# OIDC flow: validate id_token via JWKS
oidc_enabled_val = await get_setting_value(db, 'TELEGRAM_OIDC_ENABLED')
oidc_client_id_val = await get_setting_value(db, 'TELEGRAM_OIDC_CLIENT_ID')
oidc_client_id = oidc_client_id_val or settings.TELEGRAM_OIDC_CLIENT_ID
oidc_enabled = (
oidc_enabled_val.lower() == 'true' if oidc_enabled_val is not None else settings.TELEGRAM_OIDC_ENABLED
) and bool(oidc_client_id)
if not oidc_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Telegram OIDC is not configured',
)
claims = await validate_telegram_oidc_token(request.id_token, oidc_client_id)
if not claims:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired Telegram OIDC token',
)
# Replay detection
token_hash = hashlib.sha256(request.id_token.encode()).hexdigest()
token_ttl = max(int(claims.get('exp', 0) - datetime.now(UTC).timestamp()), 60)
if await TokenReplayCache.is_token_replayed(token_hash, ttl=min(token_ttl, 600)):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired Telegram OIDC token',
)
try:
telegram_id = int(claims.get('id', claims.get('sub', 0)))
except (ValueError, TypeError) as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid user ID in OIDC claims',
) from exc
if not telegram_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Missing user ID in OIDC claims',
)
telegram_username = claims.get('preferred_username')
telegram_first_name = claims.get('name', claims.get('given_name', ''))
telegram_last_name = claims.get('family_name')
elif request.id is not None and request.hash is not None and request.auth_date is not None:
# Login Widget flow: validate widget hash
widget_data = {
'id': request.id,
'auth_date': request.auth_date,
'hash': request.hash,
}
if request.first_name is not None:
widget_data['first_name'] = request.first_name
if request.last_name is not None:
widget_data['last_name'] = request.last_name
if request.username is not None:
widget_data['username'] = request.username
if request.photo_url is not None:
widget_data['photo_url'] = request.photo_url
# 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',
)
telegram_id = request.id
telegram_username = request.username
telegram_first_name = request.first_name
telegram_last_name = request.last_name
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Provide init_data (Mini App), id_token (OIDC), or Login Widget fields (id, auth_date, hash)',
)
# 3. Check if telegram_id is linked to ANOTHER user
existing_user = await get_user_by_telegram_id(db, telegram_id)
if existing_user and existing_user.id != user.id:
logger.info(
'Telegram linking conflict: telegram_id already linked to another user',
telegram_id=telegram_id,
current_user_id=user.id,
existing_user_id=existing_user.id,
)
merge_token = await create_merge_token(
primary_user_id=user.id,
secondary_user_id=existing_user.id,
provider='telegram',
provider_id=str(telegram_id),
)
return LinkCallbackResponse(
success=False,
merge_required=True,
merge_token=merge_token,
)
# 4. Link Telegram to current user
user.telegram_id = telegram_id
if telegram_username and not user.username:
user.username = telegram_username
if telegram_first_name and not user.first_name:
user.first_name = telegram_first_name
if telegram_last_name and not user.last_name:
user.last_name = telegram_last_name
user.updated_at = datetime.now(UTC)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='This Telegram account was just linked to another user',
) from exc
logger.info(
'Telegram linked to account',
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')
# ---------------------------------------------------------------------------
# Server-side OAuth linking callback (NO JWT required — auth via state token)
# Used by Telegram Mini App where OAuth must open in external browser.
# ---------------------------------------------------------------------------
class ServerCompleteRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=2048, description='Authorization code from provider')
state: str = Field(..., min_length=1, max_length=128, description='CSRF state token')
provider: OAuthProviderName | None = Field(None, description='OAuth provider name (resolved from state if omitted)')
device_id: str | None = Field(None, max_length=256, description='Device ID from VK ID callback')
class ServerCompleteResponse(LinkCallbackResponse):
provider: str
@router.post('/link/server-complete', response_model=ServerCompleteResponse)
async def link_server_complete(
request: ServerCompleteRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
) -> ServerCompleteResponse:
"""Complete OAuth account linking without JWT.
Authenticates via the one-time state token stored in Redis during link_provider_init.
Used when OAuth opens in an external browser (e.g., from Telegram Mini App).
Provider is resolved from the state token if not explicitly provided.
"""
# Rate limit by IP (unauthenticated endpoint)
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'server_complete', limit=10, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
# 1. Validate and consume state from Redis (one-time use).
# Provider may be None — validate_oauth_state will skip provider check,
# and we'll resolve it from state_data['provider'].
state_data = await validate_oauth_state(request.state, request.provider)
if not state_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid or expired OAuth state',
)
# Resolve provider from state data (canonical source)
state_provider: str = state_data.get('provider', '')
if not state_provider or state_provider not in OAUTH_PROVIDER_COLUMNS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Could not determine OAuth provider',
)
# If request explicitly provides a provider, ensure it matches the state
if request.provider and request.provider != state_provider:
logger.warning(
'Provider mismatch in server-complete',
request_provider=request.provider,
state_provider=state_provider,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Provider does not match OAuth state',
)
provider_name: str = state_provider
# 2. Must be a linking state (not login)
if state_data.get('linking') != 'true' or not state_data.get('user_id'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='OAuth state was not initiated for account linking',
)
# 3. Parse and validate user_id from state
try:
user_id = int(state_data['user_id'])
except (ValueError, TypeError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid user_id in OAuth state',
) from exc
# 4. Load user from DB
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='User not found',
)
# 5-9. Exchange code, fetch user info, link or merge
result = await _exchange_and_link_oauth(
db=db,
user=user,
provider=provider_name,
code=request.code,
state=request.state,
state_data=state_data,
device_id=request.device_id,
log_context='server-complete',
)
return ServerCompleteResponse(
success=result.success,
message=result.message,
merge_required=result.merge_required,
merge_token=result.merge_token,
provider=provider_name,
)
# ---------------------------------------------------------------------------
# Router 2: Merge (NO JWT required)
# ---------------------------------------------------------------------------
merge_router = APIRouter(prefix='/auth/merge', tags=['Cabinet Account Merge'])
@merge_router.get('/{merge_token}', response_model=MergePreviewResponse)
async def get_merge_preview_endpoint(
raw_request: Request,
merge_token: str = Path(..., min_length=32, max_length=64),
db: AsyncSession = Depends(get_cabinet_db),
) -> MergePreviewResponse:
"""Preview the result of merging two accounts before confirming."""
# Rate limit by IP (unauthenticated endpoint)
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'merge_preview', limit=15, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
token_data = await get_merge_token_data(merge_token)
if not token_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Merge token is invalid or expired',
)
primary_user_id: int = token_data['primary_user_id']
secondary_user_id: int = token_data['secondary_user_id']
try:
preview = await get_merge_preview(db, primary_user_id, secondary_user_id)
except ValueError as exc:
logger.error('Merge preview failed', error=str(exc))
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='One or both users not found',
) from exc
# Calculate remaining TTL
created_at_str: str = token_data.get('created_at', '')
try:
created_at = datetime.fromisoformat(created_at_str)
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=UTC)
elapsed = (datetime.now(UTC) - created_at).total_seconds()
expires_in_seconds = max(0, int(MERGE_TOKEN_TTL_SECONDS - elapsed))
except (ValueError, TypeError):
expires_in_seconds = 0
return MergePreviewResponse(
primary=MergePreviewUser(**preview['primary']),
secondary=MergePreviewUser(**preview['secondary']),
expires_in_seconds=expires_in_seconds,
)
@merge_router.post('/{merge_token}', response_model=MergeResponse)
async def execute_merge_endpoint(
request: MergeRequest,
raw_request: Request,
merge_token: str = Path(..., min_length=32, max_length=64),
db: AsyncSession = Depends(get_cabinet_db),
) -> MergeResponse:
"""Execute account merge. Consumes the merge token (one-time use)."""
# Rate limit by IP (unauthenticated endpoint)
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'merge_execute', limit=5, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
# 1. Consume token atomically first (GETDEL — one-time use, no TOCTOU)
consumed = await consume_merge_token(merge_token)
if not consumed:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Merge token is invalid, expired, or already consumed',
)
primary_user_id: int = consumed['primary_user_id']
secondary_user_id: int = consumed['secondary_user_id']
provider: str = consumed.get('provider', '')
provider_id: str = consumed.get('provider_id', '')
# 2. Validate keep_subscription_from — restore token if invalid
if request.keep_subscription_from not in (primary_user_id, secondary_user_id):
await restore_merge_token(merge_token, consumed)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='keep_subscription_from must be one of the two user IDs being merged',
)
# Convert user_id to 'primary'/'secondary' string for execute_merge()
keep_from: Literal['primary', 'secondary'] = (
'primary' if request.keep_subscription_from == primary_user_id else 'secondary'
)
# 3. Execute merge
try:
merged_user = await execute_merge(
db=db,
primary_user_id=primary_user_id,
secondary_user_id=secondary_user_id,
keep_subscription_from=keep_from,
provider=provider,
provider_id=provider_id,
)
await db.commit()
except ValueError as exc:
await db.rollback()
await restore_merge_token(merge_token, consumed)
logger.error('Merge execution failed (ValueError)', error=str(exc))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Account merge cannot be completed. The accounts may have already been merged or deleted.',
) from exc
except Exception as exc:
await db.rollback()
await restore_merge_token(merge_token, consumed)
logger.exception('Merge execution failed')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Account merge failed due to an internal error',
) from exc
# 4. Re-fetch merged user with full relationships for auth response
merged_user = await get_user_by_id(db, primary_user_id)
if not merged_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
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)
await _store_refresh_token(db, merged_user.id, auth_response.refresh_token, device_info='merge')
except Exception as exc:
logger.exception('Failed to create auth tokens after merge')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Merge succeeded but failed to create new session',
) from exc
logger.info(
'Account merge completed successfully',
primary_user_id=primary_user_id,
secondary_user_id=secondary_user_id,
provider=provider,
)
return MergeResponse(
success=True,
access_token=auth_response.access_token,
refresh_token=auth_response.refresh_token,
user=_user_to_response(merged_user),
)
+30 -453
View File
@@ -1,7 +1,6 @@
"""Admin routes for managing VPN applications in app-config.json."""
"""Admin routes for managing RemnaWave app configuration."""
import json
from pathlib import Path
import re
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
@@ -13,7 +12,7 @@ from app.database.models import User
from app.services.remnawave_service import RemnaWaveService
from app.services.system_settings_service import bot_configuration_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -24,431 +23,6 @@ router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
# ============ Schemas ============
class LocalizedText(BaseModel):
"""Localized text for multiple languages."""
en: str = ''
ru: str = ''
zh: str | None = ''
fa: str | None = ''
class AppButton(BaseModel):
"""Button with link and localized text."""
buttonLink: str
buttonText: LocalizedText
class AppStep(BaseModel):
"""Step with description and optional buttons/title."""
description: LocalizedText
buttons: list[AppButton] | None = None
title: LocalizedText | None = None
class AppDefinition(BaseModel):
"""VPN application definition."""
id: str
name: str
isFeatured: bool = False
urlScheme: str
isNeedBase64Encoding: bool | None = None
installationStep: AppStep
addSubscriptionStep: AppStep
connectAndUseStep: AppStep
additionalBeforeAddSubscriptionStep: AppStep | None = None
additionalAfterAddSubscriptionStep: AppStep | None = None
class PlatformApps(BaseModel):
"""Apps for a specific platform."""
platform: str
apps: list[AppDefinition]
class AppConfigBranding(BaseModel):
"""Branding configuration."""
name: str
logoUrl: str
supportUrl: str
class AppConfigConfig(BaseModel):
"""Top-level config section."""
additionalLocales: list[str]
branding: AppConfigBranding
class AppConfigResponse(BaseModel):
"""Full app config response."""
config: AppConfigConfig
platforms: dict[str, list[AppDefinition]]
class CreateAppRequest(BaseModel):
"""Request to create a new app."""
platform: str
app: AppDefinition
class UpdateAppRequest(BaseModel):
"""Request to update an app."""
app: AppDefinition
class ReorderAppsRequest(BaseModel):
"""Request to reorder apps in a platform."""
app_ids: list[str]
class UpdateBrandingRequest(BaseModel):
"""Request to update branding."""
branding: AppConfigBranding
# ============ Helpers ============
def _get_config_path() -> Path:
"""Get path to app-config.json."""
return Path(settings.get_app_config_path())
def _load_config() -> dict:
"""Load app config from file."""
config_path = _get_config_path()
if not config_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'App config file not found: {config_path}',
)
try:
with open(config_path, encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to parse app config: {e}',
)
def _save_config(config: dict) -> None:
"""Save app config to file."""
config_path = _get_config_path()
try:
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2, ensure_ascii=False)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to save app config: {e}',
)
VALID_PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']
# ============ Routes ============
@router.get('', response_model=AppConfigResponse)
async def get_app_config(
admin: User = Depends(get_current_admin_user),
):
"""Get full app configuration."""
config = _load_config()
return config
@router.get('/platforms', response_model=list[str])
async def get_platforms(
admin: User = Depends(get_current_admin_user),
):
"""Get list of available platforms."""
return VALID_PLATFORMS
@router.get('/platforms/{platform}', response_model=list[AppDefinition])
async def get_platform_apps(
platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Get apps for a specific platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}',
)
config = _load_config()
platforms = config.get('platforms', {})
return platforms.get(platform, [])
@router.post('/platforms/{platform}', response_model=AppDefinition)
async def create_app(
platform: str,
request: CreateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Create a new app for a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
if platform not in platforms:
platforms[platform] = []
# Check if app with same ID already exists
existing_ids = [app.get('id') for app in platforms[platform]]
if request.app.id in existing_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App with ID '{request.app.id}' already exists in {platform}",
)
# Add new app
app_dict = request.app.model_dump(exclude_none=True)
platforms[platform].append(app_dict)
config['platforms'] = platforms
_save_config(config)
logger.info('Admin created app for platform', admin_id=admin.id, app_id=request.app.id, platform=platform)
return request.app
@router.put('/platforms/{platform}/{app_id}', response_model=AppDefinition)
async def update_app(
platform: str,
app_id: str,
request: UpdateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update an existing app."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Find and update app
app_index = None
for i, app in enumerate(apps):
if app.get('id') == app_id:
app_index = i
break
if app_index is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Update app
app_dict = request.app.model_dump(exclude_none=True)
apps[app_index] = app_dict
platforms[platform] = apps
config['platforms'] = platforms
_save_config(config)
logger.info('Admin updated app in platform', admin_id=admin.id, app_id=app_id, platform=platform)
return request.app
@router.delete('/platforms/{platform}/{app_id}')
async def delete_app(
platform: str,
app_id: str,
admin: User = Depends(get_current_admin_user),
):
"""Delete an app from a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Find and remove app
original_length = len(apps)
apps = [app for app in apps if app.get('id') != app_id]
if len(apps) == original_length:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
platforms[platform] = apps
config['platforms'] = platforms
_save_config(config)
logger.info('Admin deleted app from platform', admin_id=admin.id, app_id=app_id, platform=platform)
return {'status': 'deleted', 'app_id': app_id}
@router.post('/platforms/{platform}/reorder')
async def reorder_apps(
platform: str,
request: ReorderAppsRequest,
admin: User = Depends(get_current_admin_user),
):
"""Reorder apps in a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Create a map of apps by ID
apps_map = {app.get('id'): app for app in apps}
# Verify all IDs exist
for app_id in request.app_ids:
if app_id not in apps_map:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Reorder apps
reordered_apps = [apps_map[app_id] for app_id in request.app_ids]
# Add any apps that weren't in the reorder list (shouldn't happen but just in case)
for app in apps:
if app.get('id') not in request.app_ids:
reordered_apps.append(app)
platforms[platform] = reordered_apps
config['platforms'] = platforms
_save_config(config)
logger.info('Admin reordered apps in platform', admin_id=admin.id, platform=platform)
return {'status': 'reordered', 'order': request.app_ids}
@router.put('/branding', response_model=AppConfigBranding)
async def update_branding(
request: UpdateBrandingRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update branding configuration."""
config = _load_config()
if 'config' not in config:
config['config'] = {}
config['config']['branding'] = request.branding.model_dump()
_save_config(config)
logger.info('Admin updated branding', admin_id=admin.id)
return request.branding
@router.get('/branding', response_model=AppConfigBranding)
async def get_branding(
admin: User = Depends(get_current_admin_user),
):
"""Get branding configuration."""
config = _load_config()
branding = config.get('config', {}).get('branding', {})
return branding
@router.post('/platforms/{platform}/copy/{app_id}')
async def copy_app_to_platform(
platform: str,
app_id: str,
target_platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Copy an app from one platform to another."""
if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid platform(s)',
)
config = _load_config()
platforms = config.get('platforms', {})
source_apps = platforms.get(platform, [])
# Find source app
source_app = None
for app in source_apps:
if app.get('id') == app_id:
source_app = app.copy()
break
if not source_app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Generate new ID for copied app
import time
new_id = f'{app_id}-copy-{int(time.time())}'
source_app['id'] = new_id
# Add to target platform
if target_platform not in platforms:
platforms[target_platform] = []
platforms[target_platform].append(source_app)
config['platforms'] = platforms
_save_config(config)
logger.info(
'Admin copied app from to as',
admin_id=admin.id,
app_id=app_id,
platform=platform,
target_platform=target_platform,
new_id=new_id,
)
return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform}
# ============ RemnaWave Config Routes ============
class RemnaWaveConfigStatus(BaseModel):
"""Status of RemnaWave config integration."""
@@ -462,6 +36,11 @@ class UpdateRemnaWaveUuidRequest(BaseModel):
uuid: str | None = None
# ============ Helpers ============
_UUID_PATTERN = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
def _get_remnawave_config_uuid() -> str | None:
"""Get RemnaWave config UUID from system settings or env."""
try:
@@ -470,9 +49,12 @@ def _get_remnawave_config_uuid() -> str | None:
return settings.CABINET_REMNA_SUB_CONFIG
# ============ Routes ============
@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus)
async def get_remnawave_config_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('apps:read')),
):
"""Get RemnaWave config integration status."""
config_uuid = _get_remnawave_config_uuid()
@@ -485,27 +67,26 @@ async def get_remnawave_config_status(
@router.put('/remnawave/uuid', response_model=RemnaWaveConfigStatus)
async def set_remnawave_config_uuid(
request: UpdateRemnaWaveUuidRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('apps:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Set RemnaWave subscription config UUID."""
uuid_value = request.uuid.strip() if request.uuid else None
# Validate UUID format if provided
if uuid_value:
import re
uuid_pattern = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
if not uuid_pattern.match(uuid_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid UUID format',
)
if uuid_value and not _UUID_PATTERN.match(uuid_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid UUID format',
)
try:
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value)
await db.commit()
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG to', admin_id=admin.id, uuid_value=uuid_value)
from app.handlers.subscription.common import invalidate_app_config_cache
invalidate_app_config_cache()
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG', admin_id=admin.id, uuid_value=uuid_value)
except Exception as e:
logger.error('Error saving RemnaWave config UUID', error=e)
raise HTTPException(
@@ -521,17 +102,14 @@ async def set_remnawave_config_uuid(
@router.get('/remnawave/config')
async def get_remnawave_subscription_config(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('apps:read')),
):
"""
Fetch subscription page config from RemnaWave panel.
Uses CABINET_REMNA_SUB_CONFIG setting for the config UUID.
"""
"""Fetch subscription page config from RemnaWave panel."""
config_uuid = _get_remnawave_config_uuid()
if not config_uuid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='CABINET_REMNA_SUB_CONFIG is not configured',
detail='RemnaWave subscription config is not configured',
)
try:
@@ -541,10 +119,9 @@ async def get_remnawave_subscription_config(
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Subscription config '{config_uuid}' not found in RemnaWave",
detail='Subscription config not found',
)
# Return the raw config data from RemnaWave
return {
'uuid': config.uuid,
'name': config.name,
@@ -557,13 +134,13 @@ async def get_remnawave_subscription_config(
logger.error('Error fetching RemnaWave config', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch config from RemnaWave: {e!s}',
detail='Failed to fetch config from RemnaWave',
)
@router.get('/remnawave/configs')
async def list_remnawave_subscription_configs(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('apps:read')),
):
"""List available subscription page configs from RemnaWave panel."""
try:
@@ -582,5 +159,5 @@ async def list_remnawave_subscription_configs(
logger.error('Error listing RemnaWave configs', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch configs from RemnaWave: {e!s}',
detail='Failed to fetch configs from RemnaWave',
)
+208
View File
@@ -0,0 +1,208 @@
"""Admin audit log routes — view and export admin action history."""
from __future__ import annotations
import csv
import io
from datetime import UTC, datetime
from typing import Any
import structlog
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.rbac import AuditLogCRUD
from app.database.models import User
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/rbac/audit-log', tags=['Admin RBAC Audit Log'])
# ============ Schemas ============
class AuditLogEntry(BaseModel):
"""Single audit log entry."""
id: int
user_id: int
action: str
resource_type: str | None = None
resource_id: str | None = None
details: dict[str, Any] | None = None
ip_address: str | None = None
user_agent: str | None = None
status: str
request_method: str | None = None
request_path: str | None = None
created_at: datetime | None = None
user_first_name: str | None = None
user_email: str | None = None
class AuditLogListResponse(BaseModel):
"""Paginated audit log list."""
items: list[AuditLogEntry]
total: int
limit: int
offset: int
# ============ CSV Export ============
_CSV_COLUMNS = [
'id',
'user_id',
'action',
'resource_type',
'resource_id',
'status',
'ip_address',
'request_method',
'request_path',
'created_at',
'user_agent',
'details',
]
def _sanitize_csv_cell(value: str) -> str:
"""Prevent CSV formula injection by prefixing dangerous leading characters."""
if value and value[0] in ('=', '+', '-', '@', '\t', '\r'):
return f"'{value}"
return value
def _logs_to_csv(logs) -> str:
"""Serialize audit log entries to CSV string."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(_CSV_COLUMNS)
for log in logs:
writer.writerow(
[
log.id,
log.user_id,
log.action,
log.resource_type or '',
log.resource_id or '',
log.status,
log.ip_address or '',
log.request_method or '',
_sanitize_csv_cell(log.request_path or ''),
log.created_at.isoformat() if log.created_at else '',
_sanitize_csv_cell((log.user_agent or '')[:200]),
_sanitize_csv_cell(str(log.details) if log.details else ''),
]
)
return output.getvalue()
# ============ Routes ============
@router.get('', response_model=AuditLogListResponse)
async def list_audit_logs(
admin: User = Depends(require_permission('audit_log:read')),
db: AsyncSession = Depends(get_cabinet_db),
user_id: int | None = Query(default=None),
action: str | None = Query(default=None),
resource_type: str | None = Query(default=None),
status: str | None = Query(default=None),
date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0),
):
"""List audit log entries with optional filters and pagination."""
logs, total = await AuditLogCRUD.get_logs(
db,
user_id=user_id,
action=action,
resource_type=resource_type,
status=status,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=offset,
load_user=True,
)
items = [
AuditLogEntry(
id=log.id,
user_id=log.user_id,
action=log.action,
resource_type=log.resource_type,
resource_id=log.resource_id,
details=log.details,
ip_address=log.ip_address,
user_agent=log.user_agent,
status=log.status,
request_method=log.request_method,
request_path=log.request_path,
created_at=log.created_at,
user_first_name=log.user.first_name if log.user else None,
user_email=log.user.email if log.user else None,
)
for log in logs
]
return AuditLogListResponse(
items=items,
total=total,
limit=limit,
offset=offset,
)
@router.get('/export')
async def export_audit_logs(
admin: User = Depends(require_permission('audit_log:export')),
db: AsyncSession = Depends(get_cabinet_db),
user_id: int | None = Query(default=None),
action: str | None = Query(default=None),
resource_type: str | None = Query(default=None),
status: str | None = Query(default=None),
date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None),
limit: int = Query(default=10000, ge=1, le=50000),
):
"""Export audit logs as CSV file."""
logs, _total = await AuditLogCRUD.get_logs(
db,
user_id=user_id,
action=action,
resource_type=resource_type,
status=status,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=0,
)
csv_content = _logs_to_csv(logs)
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
filename = f'audit_log_{timestamp}.csv'
logger.info(
'Admin exported audit logs',
admin_id=admin.id,
rows=len(logs),
filename=filename,
)
return StreamingResponse(
iter([csv_content]),
media_type='text/csv',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
+29 -29
View File
@@ -9,7 +9,7 @@ from app.config import settings
from app.database.models import User
from app.external.ban_system_api import BanSystemAPI, BanSystemAPIError
from ..dependencies import get_current_admin_user
from ..dependencies import require_permission
from ..schemas.ban_system import (
BanAgentHistoryItem,
BanAgentHistoryResponse,
@@ -103,7 +103,7 @@ async def _api_request(api: BanSystemAPI, method: str, *args, **kwargs) -> Any:
@router.get('/status', response_model=BanSystemStatusResponse)
async def get_ban_system_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanSystemStatusResponse:
"""Get Ban System integration status."""
return BanSystemStatusResponse(
@@ -117,7 +117,7 @@ async def get_ban_system_status(
@router.get('/stats/raw')
async def get_stats_raw(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> dict:
"""Get raw stats from Ban System API for debugging."""
api = _get_ban_api()
@@ -127,7 +127,7 @@ async def get_stats_raw(
@router.get('/stats', response_model=BanSystemStatsResponse)
async def get_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanSystemStatsResponse:
"""Get overall Ban System statistics."""
from datetime import datetime
@@ -181,7 +181,7 @@ async def get_users(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
status: str | None = Query(None, description='Filter: over_limit, with_limit, unlimited'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanUsersListResponse:
"""Get list of users from Ban System."""
api = _get_ban_api()
@@ -211,7 +211,7 @@ async def get_users(
@router.get('/users/over-limit', response_model=BanUsersListResponse)
async def get_users_over_limit(
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanUsersListResponse:
"""Get users who exceeded their device limit."""
api = _get_ban_api()
@@ -241,7 +241,7 @@ async def get_users_over_limit(
@router.get('/users/search/{query}')
async def search_users(
query: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanUsersListResponse:
"""Search for users."""
api = _get_ban_api()
@@ -272,7 +272,7 @@ async def search_users(
@router.get('/users/{email}', response_model=BanUserDetailResponse)
async def get_user_detail(
email: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanUserDetailResponse:
"""Get detailed user information."""
api = _get_ban_api()
@@ -325,7 +325,7 @@ async def get_user_detail(
@router.get('/punishments', response_model=BanPunishmentsListResponse)
async def get_punishments(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanPunishmentsListResponse:
"""Get list of active punishments (bans)."""
api = _get_ban_api()
@@ -360,7 +360,7 @@ async def get_punishments(
@router.post('/punishments/{user_id}/unban', response_model=UnbanResponse)
async def unban_user(
user_id: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:unban')),
) -> UnbanResponse:
"""Unban (enable) a user."""
api = _get_ban_api()
@@ -377,7 +377,7 @@ async def unban_user(
@router.post('/ban', response_model=UnbanResponse)
async def ban_user(
request: BanUserRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:ban')),
) -> UnbanResponse:
"""Manually ban a user."""
api = _get_ban_api()
@@ -401,7 +401,7 @@ async def ban_user(
async def get_punishment_history(
query: str,
limit: int = Query(20, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanHistoryResponse:
"""Get punishment history for a user."""
api = _get_ban_api()
@@ -438,7 +438,7 @@ async def get_punishment_history(
@router.get('/nodes', response_model=BanNodesListResponse)
async def get_nodes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanNodesListResponse:
"""Get list of connected nodes."""
api = _get_ban_api()
@@ -480,7 +480,7 @@ async def get_agents(
search: str | None = Query(None),
health: str | None = Query(None, description='healthy, warning, critical'),
agent_status: str | None = Query(None, alias='status', description='online, offline'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanAgentsListResponse:
"""Get list of monitoring agents."""
api = _get_ban_api()
@@ -579,7 +579,7 @@ async def get_agents(
@router.get('/agents/summary', response_model=BanAgentsSummary)
async def get_agents_summary(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanAgentsSummary:
"""Get agents summary statistics."""
api = _get_ban_api()
@@ -603,7 +603,7 @@ async def get_agents_summary(
@router.get('/traffic/violations', response_model=BanTrafficViolationsResponse)
async def get_traffic_violations(
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanTrafficViolationsResponse:
"""Get list of traffic limit violations."""
api = _get_ban_api()
@@ -637,7 +637,7 @@ async def get_traffic_violations(
@router.get('/traffic', response_model=BanTrafficResponse)
async def get_traffic(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanTrafficResponse:
"""Get full traffic statistics including top users."""
api = _get_ban_api()
@@ -681,7 +681,7 @@ async def get_traffic(
@router.get('/traffic/top')
async def get_traffic_top(
limit: int = Query(20, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> list[BanTrafficTopItem]:
"""Get top users by traffic."""
api = _get_ban_api()
@@ -744,7 +744,7 @@ def _parse_setting_response(key: str, data: Any, default_type: str = 'str') -> B
@router.get('/settings', response_model=BanSettingsResponse)
async def get_settings(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanSettingsResponse:
"""Get all Ban System settings."""
api = _get_ban_api()
@@ -802,7 +802,7 @@ async def get_settings(
@router.get('/settings/{key}')
async def get_setting(
key: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanSettingDefinition:
"""Get a specific setting."""
api = _get_ban_api()
@@ -815,7 +815,7 @@ async def get_setting(
async def set_setting(
key: str,
value: str = Query(...),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:edit')),
) -> BanSettingDefinition:
"""Set a setting value."""
api = _get_ban_api()
@@ -829,7 +829,7 @@ async def set_setting(
@router.post('/settings/{key}/toggle')
async def toggle_setting(
key: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:edit')),
) -> BanSettingDefinition:
"""Toggle a boolean setting."""
api = _get_ban_api()
@@ -846,7 +846,7 @@ async def toggle_setting(
@router.post('/settings/whitelist/add', response_model=UnbanResponse)
async def whitelist_add(
request: BanWhitelistRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:edit')),
) -> UnbanResponse:
"""Add user to whitelist."""
api = _get_ban_api()
@@ -863,7 +863,7 @@ async def whitelist_add(
@router.post('/settings/whitelist/remove', response_model=UnbanResponse)
async def whitelist_remove(
request: BanWhitelistRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:edit')),
) -> UnbanResponse:
"""Remove user from whitelist."""
api = _get_ban_api()
@@ -883,7 +883,7 @@ async def whitelist_remove(
@router.get('/report', response_model=BanReportResponse)
async def get_report(
hours: int = Query(24, ge=1, le=168),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanReportResponse:
"""Get period report."""
api = _get_ban_api()
@@ -913,7 +913,7 @@ async def get_report(
@router.get('/health', response_model=BanHealthResponse)
async def get_health(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanHealthResponse:
"""Get Ban System health status."""
api = _get_ban_api()
@@ -947,7 +947,7 @@ async def get_health(
@router.get('/health/detailed', response_model=BanHealthDetailedResponse)
async def get_health_detailed(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanHealthDetailedResponse:
"""Get detailed health information."""
api = _get_ban_api()
@@ -967,7 +967,7 @@ async def get_health_detailed(
async def get_agent_history(
node_name: str,
hours: int = Query(24, ge=1, le=168),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanAgentHistoryResponse:
"""Get agent statistics history."""
api = _get_ban_api()
@@ -1003,7 +1003,7 @@ async def get_agent_history(
async def get_user_punishment_history(
email: str,
limit: int = Query(20, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanHistoryResponse:
"""Get punishment history for a specific user."""
api = _get_ban_api()
+29 -13
View File
@@ -18,7 +18,7 @@ from app.services.broadcast_service import (
email_broadcast_service,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.broadcasts import (
BroadcastButton,
BroadcastButtonsResponse,
@@ -118,9 +118,10 @@ EMAIL_FILTER_GROUPS = {
def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
"""Serialize broadcast to response model."""
blocked = broadcast.blocked_count or 0
progress = 0.0
if broadcast.total_count > 0:
progress = round((broadcast.sent_count + broadcast.failed_count) / broadcast.total_count * 100, 1)
progress = round((broadcast.sent_count + broadcast.failed_count + blocked) / broadcast.total_count * 100, 1)
return BroadcastResponse(
id=broadcast.id,
@@ -133,12 +134,14 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
total_count=broadcast.total_count,
sent_count=broadcast.sent_count,
failed_count=broadcast.failed_count,
blocked_count=blocked,
status=broadcast.status,
admin_id=broadcast.admin_id,
admin_name=broadcast.admin_name,
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),
@@ -245,7 +248,7 @@ def _validate_buttons(buttons: list[str]) -> bool:
@router.get('/filters', response_model=BroadcastFiltersResponse)
async def get_filters(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastFiltersResponse:
"""Get all available filters with user counts."""
@@ -308,7 +311,7 @@ async def get_filters(
@router.get('/tariffs', response_model=BroadcastTariffsResponse)
async def get_tariffs(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastTariffsResponse:
"""Get tariffs for broadcast filtering."""
@@ -331,7 +334,7 @@ async def get_tariffs(
@router.get('/buttons', response_model=BroadcastButtonsResponse)
async def get_buttons(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
) -> BroadcastButtonsResponse:
"""Get available buttons for broadcasts."""
default_buttons = set(DEFAULT_BROADCAST_BUTTONS)
@@ -350,7 +353,7 @@ async def get_buttons(
@router.post('/preview', response_model=BroadcastPreviewResponse)
async def preview_broadcast(
request: BroadcastPreviewRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastPreviewResponse:
"""Preview broadcast recipients count."""
@@ -379,7 +382,7 @@ async def preview_broadcast(
@router.post('', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_broadcast(
request: BroadcastCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Create and start a broadcast."""
@@ -409,6 +412,13 @@ async def create_broadcast(
media_payload = request.media
# Validate caption length for media messages (Telegram limit: 1024 chars)
if media_payload and len(message_text) > 1024:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Текст слишком длинный для сообщения с медиа. Максимум 1024 символов, сейчас {len(message_text)}. Сократите текст или уберите медиафайл.',
)
# Create broadcast record
broadcast = BroadcastHistory(
target_type=request.target,
@@ -423,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()
@@ -444,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
@@ -459,7 +472,7 @@ async def create_broadcast(
@router.get('', response_model=BroadcastListResponse)
async def list_broadcasts(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
@@ -485,7 +498,7 @@ async def list_broadcasts(
@router.get('/email-filters', response_model=EmailFiltersResponse)
async def get_email_filters(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> EmailFiltersResponse:
"""Get all available email filters with user counts."""
@@ -521,7 +534,7 @@ async def get_email_filters(
@router.post('/email-preview', response_model=EmailPreviewResponse)
async def preview_email_broadcast(
request: EmailPreviewRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> EmailPreviewResponse:
"""Preview email broadcast recipients count."""
@@ -546,7 +559,7 @@ async def preview_email_broadcast(
@router.post('/send', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_combined_broadcast(
request: CombinedBroadcastCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:send')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Create and start a combined broadcast (telegram/email/both)."""
@@ -616,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,
@@ -642,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)
@@ -677,7 +693,7 @@ async def create_combined_broadcast(
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
async def get_broadcast(
broadcast_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Get broadcast details."""
@@ -693,7 +709,7 @@ async def get_broadcast(
@router.post('/{broadcast_id}/stop', response_model=BroadcastResponse)
async def stop_broadcast(
broadcast_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:send')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Stop a running broadcast (telegram or email)."""
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -17,7 +17,7 @@ from app.utils.button_styles_cache import (
load_button_styles_cache,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -112,7 +112,7 @@ def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
@router.get('', response_model=ButtonStylesResponse)
async def get_button_styles(
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('settings:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return current per-section button styles. Admin only."""
@@ -145,7 +145,7 @@ async def get_button_styles(
@router.patch('', response_model=ButtonStylesResponse)
async def update_button_styles(
payload: ButtonStylesUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Partially update per-section button styles. Admin only."""
@@ -243,7 +243,7 @@ async def update_button_styles(
@router.post('/reset', response_model=ButtonStylesResponse)
async def reset_button_styles(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset all button styles to defaults. Admin only."""
+200 -97
View File
@@ -1,11 +1,13 @@
"""Admin routes for managing advertising campaigns in cabinet."""
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.cabinet.utils.links import get_campaign_deep_link, get_campaign_web_link
from app.database.crud.campaign import (
create_campaign,
delete_campaign,
@@ -20,14 +22,19 @@ from app.database.crud.campaign import (
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import get_all_tariffs
from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
PartnerStatus,
Subscription,
Tariff,
User,
)
from app.services.partner_stats_service import PartnerStatsService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.campaigns import (
AdminCampaignChartDataResponse,
AvailablePartnerItem,
CampaignCreateRequest,
CampaignDetailResponse,
CampaignListItem,
@@ -49,53 +56,59 @@ logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/campaigns', tags=['Cabinet Admin Campaigns'])
def _get_deep_link(start_parameter: str) -> str:
"""Generate deep link for campaign."""
bot_username = settings.get_bot_username()
if bot_username:
return f'https://t.me/{bot_username}?start={start_parameter}'
return f'?start={start_parameter}'
def _safe_div(value: float | None, divisor: int = 100) -> float:
"""Safely divide kopeks to rubles, handling None values."""
return (value or 0) / divisor
def _get_web_link(start_parameter: str) -> str | None:
"""Generate web link for campaign."""
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
if base_url:
return f'{base_url}/?campaign={start_parameter}'
return None
def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
"""Get partner display name from campaign."""
if not campaign.partner_user_id or not campaign.partner:
return None
partner = campaign.partner
return partner.first_name or partner.username or f'#{partner.id}'
@router.get('/overview', response_model=CampaignsOverviewResponse)
async def get_overview(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get campaigns overview statistics."""
overview = await get_campaigns_overview(db)
try:
overview = await get_campaigns_overview(db)
# Count tariff bonuses
tariff_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.bonus_type == 'tariff'
# Count tariff bonuses
tariff_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.bonus_type == 'tariff'
)
)
)
tariff_count = tariff_result.scalar() or 0
tariff_count = tariff_result.scalar() or 0
return CampaignsOverviewResponse(
total=overview['total'],
active=overview['active'],
inactive=overview['inactive'],
total_registrations=overview['registrations'],
total_balance_issued_kopeks=overview['balance_total'],
total_balance_issued_rubles=overview['balance_total'] / 100,
total_subscription_issued=overview['subscription_total'],
total_tariff_issued=tariff_count,
)
return CampaignsOverviewResponse(
total=overview['total'],
active=overview['active'],
inactive=overview['inactive'],
total_registrations=overview['registrations'],
total_balance_issued_kopeks=overview['balance_total'],
total_balance_issued_rubles=_safe_div(overview['balance_total']),
total_subscription_issued=overview['subscription_total'],
total_tariff_issued=tariff_count,
)
except HTTPException:
raise
except Exception as e:
logger.error('Failed to get campaigns overview', error=str(e), exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load campaigns overview',
)
@router.get('/available-servers', response_model=list[ServerSquadInfo])
async def get_available_servers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available server squads for campaign subscription bonus."""
@@ -113,7 +126,7 @@ async def get_available_servers(
@router.get('/available-tariffs', response_model=list[TariffListItem])
async def get_available_tariffs(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available tariffs for campaign tariff bonus."""
@@ -140,17 +153,37 @@ async def get_available_tariffs(
]
@router.get('/available-partners', response_model=list[AvailablePartnerItem])
async def get_available_partners(
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of approved partners for campaign partner selector."""
result = await db.execute(
select(User).where(User.partner_status == PartnerStatus.APPROVED.value).order_by(User.first_name, User.username)
)
partners = result.scalars().all()
return [
AvailablePartnerItem(
user_id=p.id,
username=p.username,
first_name=p.first_name,
)
for p in partners
]
@router.get('', response_model=CampaignListResponse)
async def list_campaigns(
include_inactive: bool = True,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all campaigns."""
campaigns = await get_campaigns_list(db, offset=offset, limit=limit, include_inactive=include_inactive)
total = await get_campaigns_count(db)
total = await get_campaigns_count(db, is_active=True if not include_inactive else None)
items = []
for campaign in campaigns:
@@ -166,6 +199,8 @@ async def list_campaigns(
registrations_count=stats['registrations'],
total_revenue_kopeks=stats['total_revenue_kopeks'],
conversion_rate=stats['conversion_rate'],
partner_user_id=campaign.partner_user_id,
partner_name=_get_partner_name(campaign),
created_at=campaign.created_at,
)
)
@@ -176,7 +211,7 @@ async def list_campaigns(
@router.get('/{campaign_id}', response_model=CampaignDetailResponse)
async def get_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed campaign info."""
@@ -201,7 +236,7 @@ async def get_campaign(
bonus_type=campaign.bonus_type,
is_active=campaign.is_active,
balance_bonus_kopeks=campaign.balance_bonus_kopeks or 0,
balance_bonus_rubles=(campaign.balance_bonus_kopeks or 0) / 100,
balance_bonus_rubles=_safe_div(campaign.balance_bonus_kopeks),
subscription_duration_days=campaign.subscription_duration_days,
subscription_traffic_gb=campaign.subscription_traffic_gb,
subscription_device_limit=campaign.subscription_device_limit,
@@ -209,56 +244,94 @@ async def get_campaign(
tariff_id=campaign.tariff_id,
tariff_duration_days=campaign.tariff_duration_days,
tariff=tariff_info,
partner_user_id=campaign.partner_user_id,
partner_name=_get_partner_name(campaign),
created_by=campaign.created_by,
created_at=campaign.created_at,
updated_at=campaign.updated_at,
deep_link=_get_deep_link(campaign.start_parameter),
web_link=_get_web_link(campaign.start_parameter),
deep_link=get_campaign_deep_link(campaign.start_parameter),
web_link=get_campaign_web_link(campaign.start_parameter),
)
@router.get('/{campaign_id}/chart-data', response_model=AdminCampaignChartDataResponse)
async def get_campaign_chart_data(
campaign_id: int,
admin: User = Depends(require_permission('campaigns:stats')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get chart data for admin campaign analytics."""
try:
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Campaign not found',
)
data = await PartnerStatsService.get_admin_campaign_chart_data(db, campaign_id)
return AdminCampaignChartDataResponse(**data)
except HTTPException:
raise
except Exception as e:
logger.error('Failed to get campaign chart data', error=str(e), campaign_id=campaign_id, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load campaign chart data',
)
@router.get('/{campaign_id}/stats', response_model=CampaignStatisticsResponse)
async def get_campaign_stats(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:stats')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed campaign statistics."""
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Campaign not found',
try:
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Campaign not found',
)
stats = await get_campaign_statistics(db, campaign_id)
return CampaignStatisticsResponse(
id=campaign.id,
name=campaign.name,
start_parameter=campaign.start_parameter,
bonus_type=campaign.bonus_type,
is_active=campaign.is_active,
registrations=stats['registrations'],
balance_issued_kopeks=stats['balance_issued'],
balance_issued_rubles=_safe_div(stats['balance_issued']),
subscription_issued=stats['subscription_issued'],
last_registration=stats['last_registration'],
total_revenue_kopeks=stats['total_revenue_kopeks'],
total_revenue_rubles=_safe_div(stats['total_revenue_kopeks']),
avg_revenue_per_user_kopeks=stats['avg_revenue_per_user_kopeks'],
avg_revenue_per_user_rubles=_safe_div(stats['avg_revenue_per_user_kopeks']),
avg_first_payment_kopeks=stats['avg_first_payment_kopeks'],
avg_first_payment_rubles=_safe_div(stats['avg_first_payment_kopeks']),
trial_users_count=stats['trial_users_count'],
active_trials_count=stats['active_trials_count'],
conversion_count=stats['conversion_count'],
paid_users_count=stats['paid_users_count'],
conversion_rate=stats['conversion_rate'],
trial_conversion_rate=stats['trial_conversion_rate'],
deep_link=get_campaign_deep_link(campaign.start_parameter),
web_link=get_campaign_web_link(campaign.start_parameter),
)
except HTTPException:
raise
except Exception as e:
logger.error('Failed to get campaign stats', error=str(e), campaign_id=campaign_id, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load campaign statistics',
)
stats = await get_campaign_statistics(db, campaign_id)
return CampaignStatisticsResponse(
id=campaign.id,
name=campaign.name,
start_parameter=campaign.start_parameter,
bonus_type=campaign.bonus_type,
is_active=campaign.is_active,
registrations=stats['registrations'],
balance_issued_kopeks=stats['balance_issued'],
balance_issued_rubles=stats['balance_issued'] / 100,
subscription_issued=stats['subscription_issued'],
last_registration=stats['last_registration'],
total_revenue_kopeks=stats['total_revenue_kopeks'],
total_revenue_rubles=stats['total_revenue_kopeks'] / 100,
avg_revenue_per_user_kopeks=stats['avg_revenue_per_user_kopeks'],
avg_revenue_per_user_rubles=stats['avg_revenue_per_user_kopeks'] / 100,
avg_first_payment_kopeks=stats['avg_first_payment_kopeks'],
avg_first_payment_rubles=stats['avg_first_payment_kopeks'] / 100,
trial_users_count=stats['trial_users_count'],
active_trials_count=stats['active_trials_count'],
conversion_count=stats['conversion_count'],
paid_users_count=stats['paid_users_count'],
conversion_rate=stats['conversion_rate'],
trial_conversion_rate=stats['trial_conversion_rate'],
deep_link=_get_deep_link(campaign.start_parameter),
web_link=_get_web_link(campaign.start_parameter),
)
@router.get('/{campaign_id}/registrations', response_model=CampaignRegistrationsResponse)
@@ -266,7 +339,7 @@ async def get_campaign_registrations(
campaign_id: int,
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of users registered through campaign."""
@@ -344,7 +417,7 @@ async def get_campaign_registrations(
@router.post('', response_model=CampaignDetailResponse)
async def create_new_campaign(
request: CampaignCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:create')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new advertising campaign."""
@@ -371,6 +444,15 @@ async def create_new_campaign(
detail='Tariff not found',
)
# Validate partner exists and is approved
if request.partner_user_id is not None:
partner_user = await db.get(User, request.partner_user_id)
if not partner_user or partner_user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Partner not found or not approved',
)
campaign = await create_campaign(
db,
name=request.name,
@@ -385,11 +467,9 @@ async def create_new_campaign(
tariff_id=request.tariff_id,
tariff_duration_days=request.tariff_duration_days,
is_active=request.is_active,
partner_user_id=request.partner_user_id,
)
# Reload to get tariff relationship
campaign = await get_campaign_by_id(db, campaign.id)
logger.info('Admin created campaign', admin_id=admin.id, campaign_id=campaign.id, campaign_name=campaign.name)
return await get_campaign(campaign.id, admin, db)
@@ -399,7 +479,7 @@ async def create_new_campaign(
async def update_existing_campaign(
campaign_id: int,
request: CampaignUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing campaign."""
@@ -431,33 +511,51 @@ async def update_existing_campaign(
detail='Tariff not found',
)
# Build updates
# Build updates using model_fields_set to distinguish "not sent" from "sent as None"
updates = {}
if request.name is not None:
if 'name' in request.model_fields_set:
updates['name'] = request.name
if request.start_parameter is not None:
if 'start_parameter' in request.model_fields_set:
updates['start_parameter'] = request.start_parameter
if request.bonus_type is not None:
if 'bonus_type' in request.model_fields_set:
updates['bonus_type'] = request.bonus_type
if request.is_active is not None:
if 'is_active' in request.model_fields_set:
updates['is_active'] = request.is_active
if request.balance_bonus_kopeks is not None:
if 'balance_bonus_kopeks' in request.model_fields_set:
updates['balance_bonus_kopeks'] = request.balance_bonus_kopeks
if request.subscription_duration_days is not None:
if 'subscription_duration_days' in request.model_fields_set:
updates['subscription_duration_days'] = request.subscription_duration_days
if request.subscription_traffic_gb is not None:
if 'subscription_traffic_gb' in request.model_fields_set:
updates['subscription_traffic_gb'] = request.subscription_traffic_gb
if request.subscription_device_limit is not None:
if 'subscription_device_limit' in request.model_fields_set:
updates['subscription_device_limit'] = request.subscription_device_limit
if request.subscription_squads is not None:
if 'subscription_squads' in request.model_fields_set:
updates['subscription_squads'] = request.subscription_squads
if request.tariff_id is not None:
if 'tariff_id' in request.model_fields_set:
updates['tariff_id'] = request.tariff_id
if request.tariff_duration_days is not None:
if 'tariff_duration_days' in request.model_fields_set:
updates['tariff_duration_days'] = request.tariff_duration_days
# Handle partner_user_id separately (allows explicit None to unassign)
partner_changed = False
if 'partner_user_id' in request.model_fields_set:
new_partner_id = request.partner_user_id
if new_partner_id is not None:
partner_user = await db.get(User, new_partner_id)
if not partner_user or partner_user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Partner not found or not approved',
)
campaign.partner_user_id = new_partner_id
campaign.updated_at = datetime.now(UTC)
partner_changed = True
if updates:
await update_campaign(db, campaign, **updates)
elif partner_changed:
await db.commit()
await db.refresh(campaign)
logger.info('Admin updated campaign', admin_id=admin.id, campaign_id=campaign_id)
@@ -467,7 +565,7 @@ async def update_existing_campaign(
@router.delete('/{campaign_id}')
async def delete_existing_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a campaign."""
@@ -478,8 +576,13 @@ async def delete_existing_campaign(
detail='Campaign not found',
)
# Check if campaign has registrations
reg_count = len(campaign.registrations) if campaign.registrations else 0
# Check if campaign has registrations (COUNT query instead of loading all)
reg_count_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.campaign_id == campaign_id
)
)
reg_count = reg_count_result.scalar() or 0
if reg_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -495,7 +598,7 @@ async def delete_existing_campaign(
@router.post('/{campaign_id}/toggle', response_model=CampaignToggleResponse)
async def toggle_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle campaign active status."""
+98
View File
@@ -0,0 +1,98 @@
"""Admin API for managing required channels."""
import structlog
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.required_channel import (
add_channel,
delete_channel,
get_all_channels,
toggle_channel,
update_channel,
)
from app.database.models import User
from app.services.channel_subscription_service import channel_subscription_service
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.channel import (
ChannelCreateRequest,
ChannelListResponse,
ChannelResponse,
ChannelUpdateRequest,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/channel-subscriptions', tags=['Cabinet Admin Channels'])
@router.get('', response_model=ChannelListResponse)
async def list_channels(
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(require_permission('channels:read')),
) -> ChannelListResponse:
channels = await get_all_channels(db)
return ChannelListResponse(
items=[ChannelResponse.model_validate(ch) for ch in channels],
total=len(channels),
)
@router.post('', response_model=ChannelResponse, status_code=201)
async def create_channel(
data: ChannelCreateRequest,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(require_permission('channels:edit')),
) -> ChannelResponse:
ch = await add_channel(
db,
channel_id=data.channel_id,
channel_link=data.channel_link,
title=data.title,
disable_trial_on_leave=data.disable_trial_on_leave,
disable_paid_on_leave=data.disable_paid_on_leave,
)
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.patch('/{channel_db_id}', response_model=ChannelResponse)
async def update_channel_endpoint(
channel_db_id: int,
data: ChannelUpdateRequest,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(require_permission('channels:edit')),
) -> ChannelResponse:
update_data = data.model_dump(exclude_unset=True)
ch = await update_channel(db, channel_db_id, **update_data)
if not ch:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.post('/{channel_db_id}/toggle', response_model=ChannelResponse)
async def toggle_channel_endpoint(
channel_db_id: int,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(require_permission('channels:edit')),
) -> ChannelResponse:
ch = await toggle_channel(db, channel_db_id)
if not ch:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.delete('/{channel_db_id}', status_code=204)
async def delete_channel_endpoint(
channel_db_id: int,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(require_permission('channels:edit')),
) -> None:
ok = await delete_channel(db, channel_db_id)
if not ok:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
+149 -35
View File
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..services.email_template_overrides import (
delete_template_override,
get_all_overrides,
@@ -37,7 +37,7 @@ TEMPLATE_TYPES = [
'zh': '余额充值通知',
'ua': 'Сповіщення про поповнення балансу',
},
'context_vars': ['amount', 'balance'],
'context_vars': ['formatted_amount', 'formatted_balance', 'amount_rubles', 'new_balance_rubles'],
},
{
'type': 'balance_change',
@@ -48,7 +48,7 @@ TEMPLATE_TYPES = [
'zh': '余额变动通知',
'ua': 'Сповіщення про зміну балансу',
},
'context_vars': ['amount', 'balance'],
'context_vars': ['formatted_amount', 'formatted_balance', 'amount_rubles', 'new_balance_rubles'],
},
{
'type': 'subscription_expiring',
@@ -96,7 +96,7 @@ TEMPLATE_TYPES = [
'zh': '订阅已续期通知',
'ua': 'Сповіщення про продовження підписки',
},
'context_vars': ['new_end_date', 'tariff_name'],
'context_vars': ['new_expires_at', 'tariff_name', 'traffic_limit_gb', 'device_limit'],
},
{
'type': 'subscription_activated',
@@ -112,7 +112,7 @@ TEMPLATE_TYPES = [
'zh': '订阅已激活通知',
'ua': 'Сповіщення про активацію підписки',
},
'context_vars': ['tariff_name', 'end_date'],
'context_vars': ['expires_at', 'tariff_name', 'traffic_limit_gb', 'device_limit'],
},
{
'type': 'autopay_success',
@@ -128,7 +128,7 @@ TEMPLATE_TYPES = [
'zh': '自动续费成功通知',
'ua': 'Сповіщення про успішний автоплатіж',
},
'context_vars': ['amount', 'balance', 'new_end_date'],
'context_vars': ['formatted_amount', 'amount_rubles', 'new_expires_at'],
},
{
'type': 'autopay_failed',
@@ -160,7 +160,7 @@ TEMPLATE_TYPES = [
'zh': '自动续费余额不足通知',
'ua': 'Сповіщення про нестачу коштів для автоплатежу',
},
'context_vars': ['required_amount', 'balance'],
'context_vars': ['required_amount', 'current_balance'],
},
{
'type': 'daily_debit',
@@ -171,7 +171,7 @@ TEMPLATE_TYPES = [
'zh': '每日扣费通知',
'ua': 'Сповіщення про добове списання',
},
'context_vars': ['amount', 'balance'],
'context_vars': ['formatted_amount', 'formatted_balance', 'amount_rubles', 'new_balance_rubles'],
},
{
'type': 'daily_insufficient_funds',
@@ -187,7 +187,7 @@ TEMPLATE_TYPES = [
'zh': '每日扣费余额不足通知',
'ua': 'Сповіщення про нестачу коштів для добового списання',
},
'context_vars': ['required_amount', 'balance'],
'context_vars': ['required_amount', 'current_balance'],
},
{
'type': 'ban_notification',
@@ -236,7 +236,7 @@ TEMPLATE_TYPES = [
'zh': '推荐奖励通知',
'ua': 'Сповіщення про нарахування реферального бонусу',
},
'context_vars': ['amount', 'referral_name'],
'context_vars': ['formatted_bonus', 'bonus_rubles', 'referral_name'],
},
{
'type': 'referral_registered',
@@ -258,7 +258,7 @@ TEMPLATE_TYPES = [
'zh': '流量重置通知',
'ua': 'Сповіщення про скидання трафіку',
},
'context_vars': ['traffic_limit'],
'context_vars': ['reset_gb', 'current_limit_gb'],
},
{
'type': 'payment_received',
@@ -269,7 +269,7 @@ TEMPLATE_TYPES = [
'zh': '收到付款通知',
'ua': 'Сповіщення про отримання платежу',
},
'context_vars': ['amount', 'payment_method'],
'context_vars': ['formatted_amount', 'payment_method'],
},
{
'type': 'email_verification',
@@ -298,6 +298,77 @@ TEMPLATE_TYPES = [
},
'context_vars': ['username', 'reset_url', 'expire_hours'],
},
{
'type': 'guest_subscription_delivered',
'label': {
'ru': 'Быстрая покупка: подписка доставлена',
'en': 'Quick Purchase: Subscription Delivered',
'zh': '快捷购买:订阅已交付',
'ua': 'Швидка покупка: підписка доставлена',
},
'description': {
'ru': 'Письмо покупателю после успешной оплаты через лендинг',
'en': 'Email to buyer after successful landing page payment',
'zh': '通过落地页成功付款后发送给买家的邮件',
'ua': 'Лист покупцю після успішної оплати через лендінг',
},
'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'],
},
{
'type': 'guest_activation_required',
'label': {
'ru': 'Быстрая покупка: требуется активация',
'en': 'Quick Purchase: Activation Required',
'zh': '快捷购买:需要激活',
'ua': 'Швидка покупка: потрібна активація',
},
'description': {
'ru': 'Письмо когда у покупателя уже есть активная подписка',
'en': 'Email when buyer already has an active subscription',
'zh': '买家已有活跃订阅时发送的邮件',
'ua': 'Лист коли у покупця вже є активна підписка',
},
'context_vars': ['tariff_name', 'period_days', 'success_page_url', 'gift_message', 'is_gift'],
},
{
'type': 'guest_gift_received',
'label': {
'ru': 'Быстрая покупка: подарок получен',
'en': 'Quick Purchase: Gift Received',
'zh': '快捷购买:收到礼物',
'ua': 'Швидка покупка: подарунок отримано',
},
'description': {
'ru': 'Письмо получателю подарочной подписки',
'en': 'Email to gift subscription recipient',
'zh': '发送给礼物订阅接收者的邮件',
'ua': 'Лист отримувачу подарункової підписки',
},
'context_vars': [
'tariff_name',
'period_days',
'cabinet_url',
'gift_message',
'cabinet_email',
'cabinet_password',
],
},
{
'type': 'guest_cabinet_credentials',
'label': {
'ru': 'Быстрая покупка: данные для входа',
'en': 'Quick Purchase: Login Credentials',
'zh': '快捷购买:登录凭据',
'ua': 'Швидка покупка: дані для входу',
},
'description': {
'ru': 'Письмо с логином и паролем для личного кабинета',
'en': 'Email with login credentials for the cabinet',
'zh': '包含个人中心登录信息的邮件',
'ua': 'Лист з логіном та паролем для особистого кабінету',
},
'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'],
},
]
SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
@@ -315,26 +386,70 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
},
'subscription_expiring': {'days_left': 3, 'expires_at': '2025-01-30'},
'subscription_expired': {},
'subscription_renewed': {'new_end_date': '2025-02-28', 'tariff_name': 'Premium'},
'subscription_activated': {'tariff_name': 'Premium', 'end_date': '2025-02-28'},
'autopay_success': {'formatted_amount': '300.00 ₽', 'formatted_balance': '200.00 ₽', 'new_end_date': '2025-02-28'},
'subscription_renewed': {
'new_expires_at': '2025-02-28',
'tariff_name': 'Premium',
'traffic_limit_gb': 100,
'device_limit': 3,
},
'subscription_activated': {
'expires_at': '2025-02-28',
'tariff_name': 'Premium',
'traffic_limit_gb': 100,
'device_limit': 3,
},
'autopay_success': {'formatted_amount': '300.00 ₽', 'amount_rubles': 300, 'new_expires_at': '2025-02-28'},
'autopay_failed': {'reason': 'Card declined'},
'autopay_insufficient_funds': {'formatted_required': '300.00 ₽', 'formatted_balance': '50.00 ₽'},
'daily_debit': {'formatted_amount': '10.00 ₽', 'formatted_balance': '490.00 ₽'},
'daily_insufficient_funds': {'formatted_required': '10.00 ₽', 'formatted_balance': '5.00 ₽'},
'autopay_insufficient_funds': {'required_amount': '300.00 ₽', 'current_balance': '50.00 ₽'},
'daily_debit': {
'formatted_amount': '10.00 ₽',
'formatted_balance': '490.00 ₽',
'amount_rubles': 10,
'new_balance_rubles': 490,
},
'daily_insufficient_funds': {'required_amount': '10.00 ₽', 'current_balance': '5.00 ₽'},
'ban_notification': {'reason': 'Violation of terms of service'},
'unban_notification': {},
'warning_notification': {'message': 'Please review our terms of service'},
'referral_bonus': {'formatted_amount': '100.00 ₽', 'referral_name': 'John'},
'referral_bonus': {'formatted_bonus': '100.00 ₽', 'bonus_rubles': 100, 'referral_name': 'John'},
'referral_registered': {'referral_name': 'John'},
'traffic_reset': {'traffic_limit': '100 GB'},
'payment_received': {'formatted_amount': '500.00 ₽', 'payment_method': 'YooKassa'},
'traffic_reset': {'reset_gb': 50, 'current_limit_gb': 100},
'payment_received': {'formatted_amount': '500.00 ₽', 'amount_rubles': 500, 'payment_method': 'YooKassa'},
'email_verification': {
'username': 'John',
'verification_url': 'https://example.com/verify?token=abc123',
'expire_hours': 24,
},
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
'guest_subscription_delivered': {
'tariff_name': 'Premium',
'period_days': 30,
'cabinet_url': 'https://example.com/cabinet',
'cabinet_email': 'user@example.com',
'cabinet_password': 'SecurePass123',
},
'guest_activation_required': {
'tariff_name': 'Premium',
'period_days': 30,
'success_page_url': 'https://example.com/cabinet/buy/success/abc123',
'is_gift': True,
'gift_message': 'Happy birthday!',
},
'guest_gift_received': {
'tariff_name': 'Premium',
'period_days': 30,
'cabinet_url': 'https://example.com/cabinet',
'gift_message': 'Happy birthday!',
'cabinet_email': 'recipient@example.com',
'cabinet_password': 'SecurePass123',
},
'guest_cabinet_credentials': {
'tariff_name': 'Premium',
'period_days': 30,
'cabinet_url': 'https://example.com/cabinet',
'cabinet_email': 'user@example.com',
'cabinet_password': 'SecurePass123',
},
}
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
@@ -370,7 +485,7 @@ class EmailTemplateSendTestRequest(BaseModel):
@router.get('', summary='List all email template types')
async def list_template_types(
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('email_templates:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""List all available email template types with override status."""
@@ -405,7 +520,7 @@ async def list_template_types(
@router.get('/{notification_type}', summary='Get templates for a notification type')
async def get_templates_for_type(
notification_type: str,
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('email_templates:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Get all language templates for a specific notification type."""
@@ -479,7 +594,7 @@ async def update_template(
notification_type: str,
language: str,
data: EmailTemplateUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('email_templates:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Save a custom email template override."""
@@ -515,7 +630,7 @@ async def update_template(
async def reset_template(
notification_type: str,
language: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('email_templates:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Delete custom template override, reverting to default."""
@@ -543,7 +658,7 @@ async def reset_template(
async def preview_template(
notification_type: str,
data: EmailTemplatePreviewRequest,
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('email_templates:read')),
) -> dict[str, Any]:
"""Preview a rendered email template with sample data."""
valid_types = [t['type'] for t in TEMPLATE_TYPES]
@@ -557,8 +672,8 @@ async def preview_template(
language = data.language if data.language in AVAILABLE_LANGUAGES else 'ru'
if data.body_html:
# Preview custom content wrapped in base template
rendered_html = templates_instance._get_base_template(data.body_html, language)
# Preview custom content — auto-detects styled vs simple HTML
rendered_html = templates_instance._wrap_override_template(data.body_html, language)
subject = data.subject or notification_type
else:
# Preview default template
@@ -588,7 +703,7 @@ async def preview_template(
async def send_test_email(
notification_type: str,
data: EmailTemplateSendTestRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('email_templates:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Send a test email to the admin's email address."""
@@ -618,14 +733,13 @@ async def send_test_email(
sample_context = SAMPLE_CONTEXTS.get(notification_type, {})
templates_instance = EmailNotificationTemplates()
# Check for DB override
from ..services.email_template_overrides import get_template_override
# Check for DB override (get_rendered_override substitutes sample context vars)
from ..services.email_template_overrides import get_rendered_override
override = await get_template_override(notification_type, language, db)
rendered = await get_rendered_override(notification_type, language, sample_context, db)
if override:
subject = override['subject']
body_html = templates_instance._get_base_template(override['body_html'], language)
if rendered:
subject, body_html = rendered
else:
try:
from app.services.notification_delivery_service import NotificationType
+219
View File
@@ -0,0 +1,219 @@
"""Admin routes for managing info pages in cabinet."""
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.info_pages import (
clear_replaces_tab,
create_info_page,
delete_info_page,
get_all_info_pages,
get_info_page_by_id,
reorder_info_pages,
update_info_page,
)
from app.database.models import User
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.info_pages import (
InfoPageCreateRequest,
InfoPageListItem,
InfoPageResponse,
InfoPageUpdateRequest,
ReorderRequest,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/info-pages', tags=['Cabinet Admin Info Pages'])
@router.get('', response_model=list[InfoPageListItem])
async def list_all_info_pages(
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
admin: User = Depends(require_permission('info_pages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[InfoPageListItem]:
"""Get all info pages (admin view, includes inactive)."""
try:
pages = await get_all_info_pages(db, include_inactive=True, page_type=page_type)
return [InfoPageListItem.model_validate(p) for p in pages]
except HTTPException:
raise
except Exception:
logger.exception('Failed to list info pages')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load info pages',
)
@router.get('/{page_id}', response_model=InfoPageResponse)
async def get_info_page_detail(
page_id: int,
admin: User = Depends(require_permission('info_pages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Get a single info page by ID (admin view)."""
page = await get_info_page_by_id(db, page_id)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
return InfoPageResponse.model_validate(page)
@router.post('', response_model=InfoPageResponse, status_code=status.HTTP_201_CREATED)
async def create_page(
request: InfoPageCreateRequest,
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Create a new info page."""
try:
if request.replaces_tab:
await clear_replaces_tab(db, request.replaces_tab)
page = await create_info_page(
db,
slug=request.slug,
title=request.title,
content=request.content,
page_type=request.page_type,
is_active=request.is_active,
sort_order=request.sort_order,
icon=request.icon,
replaces_tab=request.replaces_tab,
)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='An info page with this slug already exists',
)
except Exception:
logger.exception('Failed to create info page')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create info page',
)
return InfoPageResponse.model_validate(page)
@router.put('/{page_id}', response_model=InfoPageResponse)
async def update_page(
page_id: int,
request: InfoPageUpdateRequest,
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Update an existing info page."""
existing = await get_info_page_by_id(db, page_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
try:
update_data = request.model_dump(exclude_unset=True)
replaces_tab = update_data.get('replaces_tab')
if replaces_tab is not None:
await clear_replaces_tab(db, replaces_tab, exclude_page_id=page_id)
page = await update_info_page(db, page_id, **update_data)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='An info page with this slug already exists',
)
except Exception:
logger.exception('Failed to update info page', page_id=page_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update info page',
)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found after update',
)
return InfoPageResponse.model_validate(page)
@router.delete('/{page_id}', status_code=status.HTTP_204_NO_CONTENT)
async def remove_page(
page_id: int,
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete an info page."""
existing = await get_info_page_by_id(db, page_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
try:
await delete_info_page(db, page_id)
except Exception:
logger.exception('Failed to delete info page', page_id=page_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to delete info page',
)
@router.post('/reorder', status_code=status.HTTP_204_NO_CONTENT)
async def reorder_pages(
request: ReorderRequest,
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Bulk update sort_order for info pages."""
try:
await reorder_info_pages(db, request.items)
except Exception:
logger.exception('Failed to reorder info pages')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to reorder info pages',
)
@router.post('/{page_id}/toggle-active', response_model=InfoPageResponse)
async def toggle_active(
page_id: int,
admin: User = Depends(require_permission('info_pages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Toggle the active status of an info page."""
existing = await get_info_page_by_id(db, page_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
try:
page = await update_info_page(db, page_id, is_active=not existing.is_active)
except Exception:
logger.exception('Failed to toggle info page active status', page_id=page_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to toggle active status',
)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found after toggle',
)
return InfoPageResponse.model_validate(page)
File diff suppressed because it is too large Load Diff
+398
View File
@@ -0,0 +1,398 @@
"""Admin routes for cabinet menu layout configuration (rows + custom URL buttons).
Serves a MERGED view combining ``CABINET_MENU_LAYOUT`` (row arrangement, custom buttons)
and ``CABINET_BUTTON_STYLES`` (per-section style/emoji/enabled/labels) to the frontend.
On save, splits the payload back into two SystemSetting keys.
"""
import json
import re
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.utils.button_styles_cache import (
ALLOWED_STYLE_VALUES,
BOT_LOCALES,
BUTTON_STYLES_KEY,
DEFAULT_BUTTON_STYLES,
get_cached_button_styles,
load_button_styles_cache,
)
from app.utils.menu_layout_cache import (
BUILTIN_SECTIONS,
DEFAULT_MENU_LAYOUT,
MENU_LAYOUT_KEY,
VALID_CUSTOM_BUTTON_STYLES,
get_cached_menu_layout,
load_menu_layout_cache,
)
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/menu-layout', tags=['Admin Menu Layout'])
# ---- Constants ---------------------------------------------------------------
MAX_ROWS = 20
MAX_BUTTONS_PER_ROW = 8 # Telegram inline keyboard limit
MAX_LABEL_LENGTH = 100
URL_PATTERN = re.compile(r'^(https?://|tg://)')
# ---- Schemas -----------------------------------------------------------------
class ButtonConfig(BaseModel):
"""Configuration for a single button (built-in or custom URL)."""
id: str = Field(max_length=100)
type: Literal['builtin', 'custom']
style: str = Field(default='primary', max_length=20)
icon_custom_emoji_id: str = Field(default='', max_length=100)
enabled: bool = True
labels: dict[str, str] = Field(default_factory=dict, max_length=10)
url: str | None = Field(default=None, max_length=2048)
open_in: Literal['external', 'webapp'] = 'external'
class RowConfig(BaseModel):
"""Configuration for a single row of buttons."""
id: str = Field(max_length=100)
max_per_row: int = Field(default=2, ge=1, le=3)
buttons: list[ButtonConfig] = Field(default_factory=list, max_length=MAX_BUTTONS_PER_ROW)
class MenuConfigResponse(BaseModel):
"""Full merged menu configuration returned to the frontend."""
rows: list[RowConfig]
class MenuConfigUpdateRequest(BaseModel):
"""Full menu configuration submitted by the frontend."""
rows: list[RowConfig] = Field(max_length=MAX_ROWS)
# ---- Helpers -----------------------------------------------------------------
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def _upsert_setting(db: AsyncSession, key: str, value: str) -> None:
"""Insert or update a SystemSetting without committing."""
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = SystemSetting(key=key, value=value)
db.add(setting)
def _build_merged_response(
layout: dict[str, object],
button_styles: dict[str, dict],
) -> MenuConfigResponse:
"""Merge layout rows with button_styles into a unified response.
Built-in buttons get style/emoji/enabled/labels from ``button_styles``.
Custom URL buttons get all config from layout's ``custom_buttons``.
"""
custom_buttons: dict[str, dict] = layout.get('custom_buttons', {})
# Collect row entries sorted numerically (row_1, row_2, ..., row_10, ...)
row_keys = sorted(
(k for k in layout if k.startswith('row_')),
key=lambda k: int(k.split('_', 1)[1]) if k.split('_', 1)[1].isdigit() else 0,
)
rows: list[RowConfig] = []
for row_key in row_keys:
row_data = layout[row_key]
if not isinstance(row_data, dict):
continue
raw_buttons: list[str] = row_data.get('buttons', [])
max_per_row: int = row_data.get('max_per_row', 2)
row_id: str = row_data.get('id', row_key)
merged_buttons: list[ButtonConfig] = []
for btn_id in raw_buttons:
if btn_id in BUILTIN_SECTIONS:
# Built-in: pull style data from button_styles cache
style_cfg = button_styles.get(btn_id, {})
merged_buttons.append(
ButtonConfig(
id=btn_id,
type='builtin',
style=style_cfg.get('style', 'primary'),
icon_custom_emoji_id=style_cfg.get('icon_custom_emoji_id', ''),
enabled=style_cfg.get('enabled', True),
labels=style_cfg.get('labels', {}),
),
)
elif btn_id.startswith('custom_') and btn_id in custom_buttons:
# Custom URL button: pull config from layout's custom_buttons
cb = custom_buttons[btn_id]
merged_buttons.append(
ButtonConfig(
id=btn_id,
type='custom',
style=cb.get('style', 'primary'),
icon_custom_emoji_id=cb.get('icon_custom_emoji_id', ''),
enabled=cb.get('enabled', True),
labels=cb.get('labels', {}),
url=cb.get('url'),
open_in=cb.get('open_in', 'external'),
),
)
rows.append(
RowConfig(
id=row_id,
max_per_row=max_per_row,
buttons=merged_buttons,
),
)
return MenuConfigResponse(rows=rows)
def _split_update(
rows: list[RowConfig],
) -> tuple[dict[str, object], dict[str, dict]]:
"""Split a flat list of RowConfig back into layout_data and button_styles_updates.
Returns:
(layout_data, button_styles_updates)
- layout_data: rows + custom_buttons for ``CABINET_MENU_LAYOUT``
- button_styles_updates: ``{section: {style, icon_custom_emoji_id, enabled, labels}}``
for built-in sections only
"""
layout_data: dict[str, object] = {}
custom_buttons: dict[str, dict] = {}
button_styles_updates: dict[str, dict] = {}
for idx, row in enumerate(rows, start=1):
row_key = f'row_{idx}'
button_ids: list[str] = []
for btn in row.buttons:
button_ids.append(btn.id)
if btn.type == 'builtin' and btn.id in BUILTIN_SECTIONS:
button_styles_updates[btn.id] = {
'style': btn.style,
'icon_custom_emoji_id': btn.icon_custom_emoji_id,
'enabled': btn.enabled,
'labels': btn.labels,
}
elif btn.type == 'custom' and btn.id.startswith('custom_'):
custom_buttons[btn.id] = {
'id': btn.id,
'url': btn.url or '',
'style': btn.style,
'icon_custom_emoji_id': btn.icon_custom_emoji_id,
'enabled': btn.enabled,
'labels': btn.labels,
'open_in': btn.open_in,
}
layout_data[row_key] = {
'id': row.id or row_key,
'buttons': button_ids,
'max_per_row': row.max_per_row,
}
layout_data['custom_buttons'] = custom_buttons
return layout_data, button_styles_updates
def _validate_update_payload(rows: list[RowConfig]) -> None:
"""Validate the full update payload. Raises HTTPException on failure."""
if len(rows) > MAX_ROWS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Too many rows: {len(rows)}. Maximum allowed: {MAX_ROWS}.',
)
# Check for duplicate button IDs across all rows
seen_ids: set[str] = set()
for row in rows:
for btn in row.buttons:
if btn.id in seen_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Duplicate button ID: "{btn.id}". Each button can only appear once.',
)
seen_ids.add(btn.id)
for row in rows:
if len(row.buttons) > MAX_BUTTONS_PER_ROW:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Row "{row.id}" has {len(row.buttons)} buttons. Maximum per row: {MAX_BUTTONS_PER_ROW}.',
)
for btn in row.buttons:
# Validate button type consistency
if btn.type == 'builtin' and btn.id not in BUILTIN_SECTIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Unknown built-in section: "{btn.id}".',
)
if btn.type == 'custom' and not btn.id.startswith('custom_'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Custom button id must start with "custom_": "{btn.id}".',
)
# Validate URL for custom buttons
if btn.type == 'custom':
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://, https://, or tg://.',
)
if btn.open_in == 'webapp' and not btn.url.startswith('https://'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Custom button "{btn.id}" with webapp mode requires an https:// URL.',
)
# Validate style
all_allowed = ALLOWED_STYLE_VALUES | VALID_CUSTOM_BUTTON_STYLES
if btn.style not in all_allowed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid style "{btn.style}" for button "{btn.id}". '
f'Allowed: {", ".join(sorted(all_allowed))}.',
)
# Validate labels
for locale_key, label_val in btn.labels.items():
if locale_key not in BOT_LOCALES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid locale "{locale_key}" for button "{btn.id}". '
f'Allowed: {", ".join(BOT_LOCALES)}.',
)
if not isinstance(label_val, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label value for locale "{locale_key}" must be a string.',
)
if len(label_val.strip()) > MAX_LABEL_LENGTH:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label for locale "{locale_key}" on button "{btn.id}" '
f'exceeds {MAX_LABEL_LENGTH} characters.',
)
# ---- Routes ------------------------------------------------------------------
@router.get('', response_model=MenuConfigResponse)
async def get_menu_layout(
_admin: User = Depends(require_permission('settings:read')),
):
"""Return merged menu layout config (rows + button styles). Admin only."""
layout = get_cached_menu_layout()
button_styles = get_cached_button_styles()
return _build_merged_response(layout, button_styles)
@router.put('', response_model=MenuConfigResponse)
async def update_menu_layout(
payload: MenuConfigUpdateRequest,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Save full menu layout config. Splits into layout + button styles. Admin only."""
_validate_update_payload(payload.rows)
layout_data, button_styles_updates = _split_update(payload.rows)
# Save layout to CABINET_MENU_LAYOUT (without committing)
await _upsert_setting(db, MENU_LAYOUT_KEY, json.dumps(layout_data))
# Merge button styles updates with existing styles (don't overwrite sections not in request)
if button_styles_updates:
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
current_styles: dict[str, dict] = {}
if raw:
try:
current_styles = json.loads(raw)
except (json.JSONDecodeError, TypeError):
current_styles = {}
for section, updates in button_styles_updates.items():
current_styles[section] = updates
await _upsert_setting(db, BUTTON_STYLES_KEY, json.dumps(current_styles))
# Single atomic commit for both settings
await db.commit()
# Refresh caches after commit
await load_button_styles_cache()
await load_menu_layout_cache()
logger.info(
'Admin updated menu layout',
telegram_id=admin.telegram_id,
rows_count=len(payload.rows),
custom_buttons_count=len(layout_data.get('custom_buttons', {})),
)
# Return merged response from fresh caches
layout = get_cached_menu_layout()
button_styles = get_cached_button_styles()
return _build_merged_response(layout, button_styles)
@router.post('/reset', response_model=MenuConfigResponse)
async def reset_menu_layout(
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset menu layout AND button styles to defaults. Admin only."""
await _upsert_setting(db, MENU_LAYOUT_KEY, json.dumps(DEFAULT_MENU_LAYOUT))
await _upsert_setting(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
# Single atomic commit for both settings
await db.commit()
# Refresh caches after commit
await load_button_styles_cache()
await load_menu_layout_cache()
logger.info('Admin reset menu layout and button styles to defaults', telegram_id=admin.telegram_id)
layout = get_cached_menu_layout()
button_styles = get_cached_button_styles()
return _build_merged_response(layout, button_styles)
+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)
+606
View File
@@ -0,0 +1,606 @@
"""Admin routes for managing partners in cabinet."""
from datetime import UTC, datetime
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import desc, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import (
AdvertisingCampaign,
PartnerApplication,
PartnerStatus,
ReferralEarning,
User,
)
from app.services.partner_application_service import partner_application_service
from app.services.partner_stats_service import PartnerStatsService
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.partners import (
AdminApproveRequest,
AdminPartnerApplicationItem,
AdminPartnerApplicationsResponse,
AdminPartnerDetailResponse,
AdminPartnerItem,
AdminPartnerListResponse,
AdminRejectRequest,
AdminUpdateCommissionRequest,
CampaignSummary,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/partners', tags=['Cabinet Admin Partners'])
# ==================== Settings ====================
class PartnerSettingsResponse(BaseModel):
withdrawal_enabled: bool
withdrawal_min_amount_kopeks: int
withdrawal_cooldown_days: int
withdrawal_requisites_text: str
partner_section_visible: bool
referral_program_enabled: bool
class PartnerSettingsUpdateRequest(BaseModel):
withdrawal_enabled: bool | None = None
withdrawal_min_amount_kopeks: int | None = Field(None, ge=0, le=100_000_000)
withdrawal_cooldown_days: int | None = Field(None, ge=0, le=365)
withdrawal_requisites_text: str | None = Field(None, max_length=2000)
partner_section_visible: bool | None = None
referral_program_enabled: bool | None = None
def _build_partner_settings_response() -> PartnerSettingsResponse:
return PartnerSettingsResponse(
withdrawal_enabled=settings.REFERRAL_WITHDRAWAL_ENABLED,
withdrawal_min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
withdrawal_cooldown_days=settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS,
withdrawal_requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
referral_program_enabled=settings.REFERRAL_PROGRAM_ENABLED,
)
@router.get('/settings', response_model=PartnerSettingsResponse)
async def get_partner_settings(
admin: User = Depends(require_permission('partners:settings')),
):
"""Get partner system settings."""
return _build_partner_settings_response()
@router.patch('/settings', response_model=PartnerSettingsResponse)
async def update_partner_settings(
request: PartnerSettingsUpdateRequest,
admin: User = Depends(require_permission('partners:settings')),
):
"""Update partner system settings."""
import asyncio
from pathlib import Path
# Update in-memory settings
if request.withdrawal_enabled is not None:
settings.REFERRAL_WITHDRAWAL_ENABLED = request.withdrawal_enabled
if request.withdrawal_min_amount_kopeks is not None:
settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS = request.withdrawal_min_amount_kopeks
if request.withdrawal_cooldown_days is not None:
settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS = request.withdrawal_cooldown_days
if request.withdrawal_requisites_text is not None:
settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT = request.withdrawal_requisites_text
if request.partner_section_visible is not None:
settings.REFERRAL_PARTNER_SECTION_VISIBLE = request.partner_section_visible
if request.referral_program_enabled is not None:
settings.REFERRAL_PROGRAM_ENABLED = request.referral_program_enabled
# Persist to .env file
try:
env_file = Path('.env')
if await asyncio.to_thread(env_file.exists):
lines = (await asyncio.to_thread(env_file.read_text)).splitlines()
updates: dict[str, str] = {}
if request.withdrawal_enabled is not None:
updates['REFERRAL_WITHDRAWAL_ENABLED'] = str(request.withdrawal_enabled).lower()
if request.withdrawal_min_amount_kopeks is not None:
updates['REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS'] = str(request.withdrawal_min_amount_kopeks)
if request.withdrawal_cooldown_days is not None:
updates['REFERRAL_WITHDRAWAL_COOLDOWN_DAYS'] = str(request.withdrawal_cooldown_days)
if request.withdrawal_requisites_text is not None:
# Sanitize: replace newlines to prevent .env injection
sanitized = (
request.withdrawal_requisites_text.replace('\r\n', ' ').replace('\n', ' ').replace('\r', ' ')
)
updates['REFERRAL_WITHDRAWAL_REQUISITES_TEXT'] = sanitized
if request.partner_section_visible is not None:
updates['REFERRAL_PARTNER_SECTION_VISIBLE'] = str(request.partner_section_visible).lower()
if request.referral_program_enabled is not None:
updates['REFERRAL_PROGRAM_ENABLED'] = str(request.referral_program_enabled).lower()
new_lines = []
updated_keys: set[str] = set()
for line in lines:
updated = False
for key, value in updates.items():
if line.startswith(f'{key}='):
new_lines.append(f'{key}={value}')
updated_keys.add(key)
updated = True
break
if not updated:
new_lines.append(line)
for key, value in updates.items():
if key not in updated_keys:
new_lines.append(f'{key}={value}')
await asyncio.to_thread(env_file.write_text, '\n'.join(new_lines) + '\n')
logger.info('Updated partner settings in .env file', admin_id=admin.id)
except Exception as e:
logger.warning('Failed to update .env file', error=e)
return _build_partner_settings_response()
# ==================== Applications (static paths first) ====================
@router.get('/applications', response_model=AdminPartnerApplicationsResponse)
async def list_applications(
application_status: Literal['pending', 'approved', 'rejected', 'none'] | None = Query(None, alias='status'),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List partner applications."""
applications, total = await partner_application_service.get_all_applications(
db, status=application_status, limit=limit, offset=offset
)
# Batch-fetch users to avoid N+1
user_ids = list({app.user_id for app in applications})
if user_ids:
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
else:
users_map = {}
items = []
for app in applications:
user = users_map.get(app.user_id)
items.append(
AdminPartnerApplicationItem(
id=app.id,
user_id=app.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
company_name=app.company_name,
website_url=app.website_url,
telegram_channel=app.telegram_channel,
description=app.description,
expected_monthly_referrals=app.expected_monthly_referrals,
desired_commission_percent=app.desired_commission_percent,
status=app.status,
admin_comment=app.admin_comment,
approved_commission_percent=app.approved_commission_percent,
created_at=app.created_at,
processed_at=app.processed_at,
)
)
return AdminPartnerApplicationsResponse(items=items, total=total)
@router.post('/applications/{application_id}/approve')
async def approve_application(
application_id: int,
request: AdminApproveRequest,
admin: User = Depends(require_permission('partners:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a partner application."""
success, error = await partner_application_service.approve_application(
db,
application_id=application_id,
admin_id=admin.id,
commission_percent=request.commission_percent,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about approval
try:
from app.bot_factory import create_bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
application = await db.get(PartnerApplication, application_id)
user = await db.get(User, application.user_id) if application else None
if user:
comment_text = f'\n{request.comment}' if request.comment else ''
tg_message = (
f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}'
)
bot = create_bot()
try:
await notification_delivery_service.notify_partner_approved(
user=user,
commission_percent=request.commission_percent,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send partner approval notification', error=e)
return {'success': True}
@router.post('/applications/{application_id}/reject')
async def reject_application(
application_id: int,
request: AdminRejectRequest,
admin: User = Depends(require_permission('partners:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a partner application."""
success, error = await partner_application_service.reject_application(
db,
application_id=application_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about rejection
try:
from app.bot_factory import create_bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
application = await db.get(PartnerApplication, application_id)
user = await db.get(User, application.user_id) if application else None
if user:
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}'
bot = create_bot()
try:
await notification_delivery_service.notify_partner_rejected(
user=user,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send partner rejection notification', error=e)
return {'success': True}
# ==================== Stats (static paths) ====================
@router.get('/stats')
async def get_partner_stats(
admin: User = Depends(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get overall partner statistics."""
total_partners = await db.execute(
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
)
pending_apps = await db.execute(
select(func.count())
.select_from(PartnerApplication)
.where(PartnerApplication.status == PartnerStatus.PENDING.value)
)
total_referrals = await db.execute(select(func.count()).select_from(User).where(User.referred_by_id.isnot(None)))
total_earnings = await db.execute(select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
return {
'total_partners': total_partners.scalar() or 0,
'pending_applications': pending_apps.scalar() or 0,
'total_referrals': total_referrals.scalar() or 0,
'total_earnings_kopeks': total_earnings.scalar() or 0,
}
# ==================== Partners list ====================
@router.get('', response_model=AdminPartnerListResponse)
async def list_partners(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List approved partners."""
count_result = await db.execute(
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
)
total = count_result.scalar() or 0
result = await db.execute(
select(User)
.where(User.partner_status == PartnerStatus.APPROVED.value)
.order_by(desc(User.created_at))
.offset(offset)
.limit(limit)
)
partners = result.scalars().all()
# Batch-fetch earnings and referral counts to avoid N+1
partner_ids = [u.id for u in partners]
earnings_map: dict[int, int] = {}
referral_count_map: dict[int, int] = {}
if partner_ids:
earnings_result = await db.execute(
select(ReferralEarning.user_id, func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.user_id.in_(partner_ids))
.group_by(ReferralEarning.user_id)
)
earnings_map = {row[0]: int(row[1]) for row in earnings_result.all()}
referral_result = await db.execute(
select(User.referred_by_id, func.count())
.where(User.referred_by_id.in_(partner_ids))
.group_by(User.referred_by_id)
)
referral_count_map = {row[0]: row[1] for row in referral_result.all()}
items = []
for user in partners:
items.append(
AdminPartnerItem(
user_id=user.id,
username=user.username,
first_name=user.first_name,
telegram_id=user.telegram_id,
commission_percent=user.referral_commission_percent,
total_referrals=referral_count_map.get(user.id, 0),
total_earnings_kopeks=earnings_map.get(user.id, 0),
balance_kopeks=user.balance_kopeks,
partner_status=user.partner_status,
created_at=user.created_at,
)
)
return AdminPartnerListResponse(items=items, total=total)
# ==================== Partner detail (parametric paths last) ====================
@router.get('/{user_id}', response_model=AdminPartnerDetailResponse)
async def get_partner_detail(
user_id: int,
admin: User = Depends(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed partner info."""
user = await db.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Пользователь не найден',
)
stats = await PartnerStatsService.get_referrer_detailed_stats(db, user_id)
# Get assigned campaigns with per-campaign stats
campaigns_result = await db.execute(
select(AdvertisingCampaign).where(AdvertisingCampaign.partner_user_id == user_id)
)
campaigns = campaigns_result.scalars().all()
campaign_ids = [c.id for c in campaigns]
per_campaign_stats = await PartnerStatsService.get_per_campaign_stats(db, user_id, campaign_ids)
campaign_list = [
CampaignSummary(
id=c.id,
name=c.name,
start_parameter=c.start_parameter,
is_active=c.is_active,
registrations_count=per_campaign_stats.get(c.id, {}).get('registrations_count', 0),
referrals_count=per_campaign_stats.get(c.id, {}).get('referrals_count', 0),
earnings_kopeks=per_campaign_stats.get(c.id, {}).get('earnings_kopeks', 0),
)
for c in campaigns
]
summary = stats['summary']
earnings = stats['earnings']
return AdminPartnerDetailResponse(
user_id=user.id,
username=user.username,
first_name=user.first_name,
telegram_id=user.telegram_id,
commission_percent=user.referral_commission_percent,
partner_status=user.partner_status,
balance_kopeks=user.balance_kopeks,
total_referrals=summary['total_referrals'],
paid_referrals=summary['paid_referrals'],
active_referrals=summary['active_referrals'],
earnings_all_time=earnings['all_time_kopeks'],
earnings_today=earnings['today_kopeks'],
earnings_week=earnings['week_kopeks'],
earnings_month=earnings['month_kopeks'],
conversion_to_paid=summary['conversion_to_paid_percent'],
campaigns=campaign_list,
created_at=user.created_at,
)
@router.patch('/{user_id}/commission')
async def update_commission(
user_id: int,
request: AdminUpdateCommissionRequest,
admin: User = Depends(require_permission('partners:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update partner commission percent."""
user = await db.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Пользователь не найден',
)
if user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Пользователь не является партнёром',
)
old_commission = user.referral_commission_percent
user.referral_commission_percent = request.commission_percent
await db.commit()
logger.info(
'Комиссия партнёра обновлена',
user_id=user_id,
old_commission=old_commission,
new_commission=request.commission_percent,
admin_id=admin.id,
)
return {'success': True, 'commission_percent': request.commission_percent}
@router.post('/{user_id}/revoke')
async def revoke_partner(
user_id: int,
admin: User = Depends(require_permission('partners:revoke')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Revoke partner status."""
success, error = await partner_application_service.revoke_partner(db, user_id=user_id, admin_id=admin.id)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
return {'success': True}
@router.post('/{user_id}/campaigns/{campaign_id}/assign')
async def assign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(require_permission('partners:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Assign a campaign to a partner."""
campaign = await db.get(AdvertisingCampaign, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Кампания не найдена',
)
user = await db.get(User, user_id)
if not user or user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Пользователь не является партнёром',
)
# Atomic check-and-set to prevent race conditions
result = await db.execute(
update(AdvertisingCampaign)
.where(
AdvertisingCampaign.id == campaign_id,
or_(
AdvertisingCampaign.partner_user_id.is_(None),
AdvertisingCampaign.partner_user_id == user_id,
),
)
.values(partner_user_id=user_id, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Кампания уже привязана к другому партнёру',
)
await db.commit()
logger.info(
'Кампания привязана к партнёру',
campaign_id=campaign_id,
partner_user_id=user_id,
admin_id=admin.id,
)
return {'success': True}
@router.post('/{user_id}/campaigns/{campaign_id}/unassign')
async def unassign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(require_permission('partners:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unassign a campaign from a partner."""
# Atomic check-and-unset to prevent race conditions
result = await db.execute(
update(AdvertisingCampaign)
.where(
AdvertisingCampaign.id == campaign_id,
AdvertisingCampaign.partner_user_id == user_id,
)
.values(partner_user_id=None, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
campaign = await db.get(AdvertisingCampaign, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Кампания не найдена',
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Кампания не привязана к этому партнёру',
)
await db.commit()
logger.info(
'Кампания откреплена от партнёра',
campaign_id=campaign_id,
partner_user_id=user_id,
admin_id=admin.id,
)
return {'success': True}
+21 -8
View File
@@ -4,7 +4,7 @@ from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
@@ -17,7 +17,7 @@ from app.services.payment_method_config_service import (
update_sort_order,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -60,10 +60,23 @@ class PaymentMethodConfigResponse(BaseModel):
class PaymentMethodConfigUpdateRequest(BaseModel):
is_enabled: bool | None = None
display_name: str | None = Field(default=None, description='Null to reset to default')
sub_options: dict | None = None
sub_options: dict[str, bool] | None = None
min_amount_kopeks: int | None = Field(default=None, ge=0)
max_amount_kopeks: int | None = Field(default=None, ge=0)
user_type_filter: str | None = Field(default=None, pattern='^(all|telegram|email)$')
@field_validator('sub_options', mode='before')
@classmethod
def validate_sub_options(cls, v: dict[str, bool] | None) -> dict[str, bool] | None:
if not v:
return None
if len(v) > 20:
raise ValueError('sub_options cannot have more than 20 keys')
for key in v:
if not isinstance(key, str) or len(key) > 50:
raise ValueError('sub_options keys must be strings of at most 50 characters')
return v
first_topup_filter: str | None = Field(default=None, pattern='^(any|yes|no)$')
promo_group_filter_mode: str | None = Field(default=None, pattern='^(all|selected)$')
allowed_promo_group_ids: list[int] | None = None
@@ -124,7 +137,7 @@ def _enrich_config(config, defaults: dict) -> PaymentMethodConfigResponse:
@router.get('', response_model=list[PaymentMethodConfigResponse])
async def list_payment_methods(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all payment method configurations."""
@@ -135,7 +148,7 @@ async def list_payment_methods(
@router.get('/promo-groups', response_model=list[PromoGroupSimple])
async def list_promo_groups(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all promo groups for filter selector."""
@@ -146,7 +159,7 @@ async def list_promo_groups(
@router.get('/{method_id}', response_model=PaymentMethodConfigResponse)
async def get_payment_method(
method_id: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get a single payment method configuration."""
@@ -163,7 +176,7 @@ async def get_payment_method(
@router.put('/order')
async def update_payment_methods_order(
request: SortOrderRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Batch update sort order for payment methods."""
@@ -176,7 +189,7 @@ async def update_payment_methods_order(
async def update_payment_method(
method_id: str,
request: PaymentMethodConfigUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update a payment method configuration."""
+172 -11
View File
@@ -1,14 +1,23 @@
"""Admin routes for payment verification in cabinet."""
import math
from datetime import datetime
from datetime import UTC, datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.bot_factory import create_bot
from app.database.models import PaymentMethod, User
from app.services.payment_search_service import (
MAX_ALL_TIME_DAYS,
PeriodPreset,
SearchParams,
StatusFilter,
search_payments,
search_payments_stats,
)
from app.services.payment_service import PaymentService
from app.services.payment_verification_service import (
SUPPORTED_MANUAL_CHECK_METHODS,
@@ -19,7 +28,7 @@ from app.services.payment_verification_service import (
run_manual_check,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -50,6 +59,7 @@ class PendingPaymentResponse(BaseModel):
user_id: int | None = None
user_telegram_id: int | None = None
user_username: str | None = None
user_email: str | None = None
class Config:
from_attributes = True
@@ -83,6 +93,16 @@ class PaymentsStatsResponse(BaseModel):
by_method: dict
class SearchStatsResponse(BaseModel):
"""Statistics for payment search results."""
total: int
pending: int
paid: int
cancelled: int
by_method: dict
# ============ Helper functions ============
@@ -206,7 +226,7 @@ def _is_checkable(record: PendingPayment) -> bool:
if record.method == PaymentMethod.YOOKASSA:
return status_str in {'pending', 'waiting_for_capture'}
if record.method == PaymentMethod.CRYPTOBOT:
return status_str in {'active'}
return status_str == 'active'
if record.method == PaymentMethod.CLOUDPAYMENTS:
return status_str in {'pending', 'authorized'}
if record.method == PaymentMethod.FREEKASSA:
@@ -237,6 +257,8 @@ def _get_payment_url(record: PendingPayment) -> str | None:
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
payment_url = getattr(payment, 'payment_url', None) or payment_url
if payment_url and not payment_url.startswith(('https://', 'http://')):
return None
return payment_url
@@ -261,6 +283,7 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
user_id=record.user.id if record.user else None,
user_telegram_id=record.user.telegram_id if record.user else None,
user_username=record.user.username if record.user else None,
user_email=record.user.email if record.user else None,
)
@@ -272,7 +295,7 @@ async def get_all_pending_payments(
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
method_filter: str | None = Query(None, description='Filter by payment method'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all pending payments for admin verification."""
@@ -306,7 +329,7 @@ async def get_all_pending_payments(
@router.get('/stats', response_model=PaymentsStatsResponse)
async def get_payments_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get statistics about pending payments."""
@@ -325,11 +348,145 @@ async def get_payments_stats(
)
@router.get('/search', response_model=PendingPaymentListResponse)
async def search_payments_endpoint(
search: str | None = Query(
None, max_length=256, description='Search query (invoice, @username, telegram_id, email)'
),
status_filter: str = Query('all', description='Status filter: all, pending, paid, cancelled'),
method_filter: str | None = Query(None, description='Filter by payment method'),
period: str = Query('24h', description='Period preset: 24h, 7d, 30d, all'),
date_from: datetime | None = Query(None, description='Custom range start (ISO 8601)'),
date_to: datetime | None = Query(None, description='Custom range end (ISO 8601)'),
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Search payments across all providers with filters."""
try:
parsed_status = StatusFilter(status_filter)
except ValueError:
parsed_status = StatusFilter.ALL
try:
parsed_period = PeriodPreset(period)
except ValueError:
parsed_period = PeriodPreset.H24
parsed_method: PaymentMethod | None = None
if method_filter:
try:
parsed_method = PaymentMethod(method_filter)
except ValueError:
pass
# Ensure custom dates are timezone-aware
if date_from is not None and date_from.tzinfo is None:
date_from = date_from.replace(tzinfo=UTC)
if date_to is not None and date_to.tzinfo is None:
date_to = date_to.replace(tzinfo=UTC)
# Clamp custom dates to safety limit
min_allowed = datetime.now(UTC) - timedelta(days=MAX_ALL_TIME_DAYS)
if date_from is not None and date_from < min_allowed:
date_from = min_allowed
if date_from is not None and date_to is not None and date_from > date_to:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='date_from must be before date_to')
params = SearchParams(
search=search.strip() if search else None,
status_filter=parsed_status,
method_filter=parsed_method,
period=parsed_period,
date_from=date_from,
date_to=date_to,
page=page,
per_page=per_page,
)
page_items, total = await search_payments(db, params)
pages = math.ceil(total / per_page) if total > 0 else 1
items = [_record_to_response(p) for p in page_items]
return PendingPaymentListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get('/search/stats', response_model=SearchStatsResponse)
async def search_payments_stats_endpoint(
search: str | None = Query(
None, max_length=256, description='Search query (invoice, @username, telegram_id, email)'
),
status_filter: str = Query('all', description='Status filter: all, pending, paid, cancelled'),
method_filter: str | None = Query(None, description='Filter by payment method'),
period: str = Query('24h', description='Period preset: 24h, 7d, 30d, all'),
date_from: datetime | None = Query(None, description='Custom range start (ISO 8601)'),
date_to: datetime | None = Query(None, description='Custom range end (ISO 8601)'),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get aggregated statistics for payment search results."""
try:
parsed_status = StatusFilter(status_filter)
except ValueError:
parsed_status = StatusFilter.ALL
try:
parsed_period = PeriodPreset(period)
except ValueError:
parsed_period = PeriodPreset.H24
parsed_method: PaymentMethod | None = None
if method_filter:
try:
parsed_method = PaymentMethod(method_filter)
except ValueError:
pass
# Ensure custom dates are timezone-aware
if date_from is not None and date_from.tzinfo is None:
date_from = date_from.replace(tzinfo=UTC)
if date_to is not None and date_to.tzinfo is None:
date_to = date_to.replace(tzinfo=UTC)
# Clamp custom dates to safety limit
min_allowed = datetime.now(UTC) - timedelta(days=MAX_ALL_TIME_DAYS)
if date_from is not None and date_from < min_allowed:
date_from = min_allowed
if date_from is not None and date_to is not None and date_from > date_to:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='date_from must be before date_to')
params = SearchParams(
search=search.strip() if search else None,
status_filter=parsed_status,
method_filter=parsed_method,
period=parsed_period,
date_from=date_from,
date_to=date_to,
)
stats = await search_payments_stats(db, params)
return SearchStatsResponse(
total=stats.total,
pending=stats.pending,
paid=stats.paid,
cancelled=stats.cancelled,
by_method=stats.by_method or {},
)
@router.get('/{method}/{payment_id}', response_model=PendingPaymentResponse)
async def get_pending_payment_details(
method: str,
payment_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get details of a specific pending payment."""
@@ -338,7 +495,7 @@ async def get_pending_payment_details(
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid payment method: {method}',
detail='Invalid payment method',
)
record = await get_payment_record(db, payment_method, payment_id)
@@ -356,7 +513,7 @@ async def get_pending_payment_details(
async def check_payment_status(
method: str,
payment_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payments:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Manually check and update payment status."""
@@ -365,7 +522,7 @@ async def check_payment_status(
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid payment method: {method}',
detail='Invalid payment method',
)
# Get current record
@@ -390,8 +547,12 @@ async def check_payment_status(
old_is_paid = record.is_paid
# Run manual check
payment_service = PaymentService()
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
bot = create_bot()
try:
payment_service = PaymentService(bot=bot)
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
finally:
await bot.session.close()
if not updated:
return ManualCheckResponse(
+14 -19
View File
@@ -5,13 +5,11 @@ from datetime import UTC, datetime
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.bot_factory import create_bot
from app.database.models import PinnedMessage, User
from app.services.pinned_message_service import (
broadcast_pinned_message,
@@ -22,7 +20,7 @@ from app.services.pinned_message_service import (
)
from app.utils.validators import sanitize_html, validate_html_tags
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.pinned_messages import (
PinnedMessageBroadcastResponse,
PinnedMessageCreateRequest,
@@ -77,10 +75,7 @@ _cached_bot: Bot | None = None
def _get_bot() -> Bot:
global _cached_bot
if _cached_bot is None:
_cached_bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
_cached_bot = create_bot()
return _cached_bot
@@ -89,7 +84,7 @@ def _get_bot() -> Bot:
@router.get('', response_model=PinnedMessageListResponse)
async def list_pinned_messages(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
@@ -117,7 +112,7 @@ async def list_pinned_messages(
@router.get('/active', response_model=PinnedMessageResponse | None)
async def get_active_message(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse | None:
"""Get current active pinned message."""
@@ -130,7 +125,7 @@ async def get_active_message(
@router.get('/{message_id}', response_model=PinnedMessageResponse)
async def get_pinned_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Get pinned message by ID."""
@@ -147,7 +142,7 @@ async def get_pinned_message(
@router.post('', response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_pinned_message(
payload: PinnedMessageCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""
@@ -201,7 +196,7 @@ async def create_pinned_message(
async def update_pinned_message(
message_id: int,
payload: PinnedMessageUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Update a pinned message content, media, or settings."""
@@ -240,7 +235,7 @@ async def update_pinned_message(
async def update_pinned_message_settings(
message_id: int,
payload: PinnedMessageSettingsRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Update only pinned message display settings."""
@@ -267,7 +262,7 @@ async def update_pinned_message_settings(
@router.post('/active/deactivate', response_model=PinnedMessageResponse | None)
async def deactivate_active_message(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse | None:
"""Deactivate the current active pinned message without unpinning from users."""
@@ -282,7 +277,7 @@ async def deactivate_active_message(
@router.post('/active/unpin', response_model=PinnedMessageUnpinResponse)
async def unpin_active_message(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageUnpinResponse:
"""Unpin messages from all users and deactivate the active pinned message."""
@@ -311,7 +306,7 @@ async def unpin_active_message(
async def activate_pinned_message(
message_id: int,
broadcast: bool = Query(False),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""
@@ -360,7 +355,7 @@ async def activate_pinned_message(
@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse)
async def broadcast_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""Broadcast a pinned message to all active users."""
@@ -391,7 +386,7 @@ async def broadcast_message(
@router.delete('/{message_id}', status_code=status.HTTP_204_NO_CONTENT, response_model=None)
async def delete_pinned_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete a pinned message. Active messages must be deactivated first."""
+227
View File
@@ -0,0 +1,227 @@
"""Admin RBAC access policies management routes."""
from __future__ import annotations
from datetime import datetime
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.rbac import AccessPolicyCRUD, AdminRoleCRUD
from app.database.models import User
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/rbac/policies', tags=['Admin RBAC Policies'])
# ============ Schemas ============
class PolicyResponse(BaseModel):
"""Access policy response."""
id: int
name: str
description: str | None = None
role_id: int | None = None
role_name: str | None = None
priority: int
effect: str
conditions: dict[str, Any] = Field(default_factory=dict)
resource: str
actions: list[str] = Field(default_factory=list)
is_active: bool
created_by: int | None = None
created_at: datetime | None = None
class PolicyCreateRequest(BaseModel):
"""Create a new access policy."""
name: str = Field(min_length=1, max_length=200)
description: str | None = None
role_id: int | None = None
priority: int = Field(default=0, ge=0, le=1000)
effect: str = Field(pattern=r'^(allow|deny)$')
conditions: dict[str, Any] = Field(default_factory=dict)
resource: str = Field(min_length=1, max_length=100)
actions: list[str] = Field(default_factory=list)
class PolicyUpdateRequest(BaseModel):
"""Update policy fields (all optional)."""
name: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = None
role_id: int | None = None
priority: int | None = Field(default=None, ge=0, le=1000)
effect: str | None = Field(default=None, pattern=r'^(allow|deny)$')
conditions: dict[str, Any] | None = None
resource: str | None = Field(default=None, min_length=1, max_length=100)
actions: list[str] | None = None
is_active: bool | None = None
# ============ Helper Functions ============
async def _policy_to_response(db: AsyncSession, policy) -> PolicyResponse:
"""Convert AccessPolicy model to PolicyResponse with role name."""
role_name = None
if policy.role_id is not None:
role = await AdminRoleCRUD.get_by_id(db, policy.role_id)
if role:
role_name = role.name
return PolicyResponse(
id=policy.id,
name=policy.name,
description=policy.description,
role_id=policy.role_id,
role_name=role_name,
priority=policy.priority,
effect=policy.effect,
conditions=policy.conditions or {},
resource=policy.resource,
actions=policy.actions or [],
is_active=policy.is_active,
created_by=policy.created_by,
created_at=policy.created_at,
)
# ============ Routes ============
@router.get('', response_model=list[PolicyResponse])
async def list_policies(
admin: User = Depends(require_permission('roles:read')),
db: AsyncSession = Depends(get_cabinet_db),
role_id: int | None = None,
):
"""List all access policies. Optionally filter by role_id."""
policies = await AccessPolicyCRUD.get_all(db, role_id=role_id)
return [await _policy_to_response(db, p) for p in policies]
@router.post('', response_model=PolicyResponse, status_code=status.HTTP_201_CREATED)
async def create_policy(
payload: PolicyCreateRequest,
admin: User = Depends(require_permission('roles:create')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new access policy (ABAC rule)."""
# Validate role_id if provided
if payload.role_id is not None:
role = await AdminRoleCRUD.get_by_id(db, payload.role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Referenced role not found',
)
policy = await AccessPolicyCRUD.create(
db,
name=payload.name,
description=payload.description,
role_id=payload.role_id,
priority=payload.priority,
effect=payload.effect,
conditions=payload.conditions,
resource=payload.resource,
actions=payload.actions,
created_by=admin.id,
)
await db.commit()
logger.info(
'Admin created access policy',
admin_id=admin.id,
policy_id=policy.id,
policy_name=policy.name,
effect=policy.effect,
)
return await _policy_to_response(db, policy)
@router.put('/{policy_id}', response_model=PolicyResponse)
async def update_policy(
policy_id: int,
payload: PolicyUpdateRequest,
admin: User = Depends(require_permission('roles:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing access policy."""
existing = await AccessPolicyCRUD.get_by_id(db, policy_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Policy not found',
)
update_data = payload.model_dump(exclude_unset=True)
# Validate role_id if changing
if 'role_id' in update_data and update_data['role_id'] is not None:
role = await AdminRoleCRUD.get_by_id(db, update_data['role_id'])
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Referenced role not found',
)
updated = await AccessPolicyCRUD.update(db, policy_id, **update_data)
if not updated:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Policy not found',
)
await db.commit()
logger.info(
'Admin updated access policy',
admin_id=admin.id,
policy_id=policy_id,
fields=list(update_data.keys()),
)
return await _policy_to_response(db, updated)
@router.delete('/{policy_id}')
async def delete_policy(
policy_id: int,
admin: User = Depends(require_permission('roles:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete an access policy."""
existing = await AccessPolicyCRUD.get_by_id(db, policy_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Policy not found',
)
deleted = await AccessPolicyCRUD.delete(db, policy_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to delete policy',
)
await db.commit()
logger.info(
'Admin deleted access policy',
admin_id=admin.id,
policy_id=policy_id,
policy_name=existing.name,
)
return {'message': 'Policy deleted', 'policy_id': policy_id}
+25 -16
View File
@@ -4,19 +4,17 @@ 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.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
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.config import settings
from app.bot_factory import create_bot
from app.database.crud.discount_offer import (
count_discount_offers,
list_discount_offers,
@@ -34,7 +32,7 @@ from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate
from app.handlers.admin.messages import get_custom_users, get_target_users
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -130,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
@@ -272,7 +284,7 @@ async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
@router.get('/templates', response_model=PromoOfferTemplateListResponse)
async def list_templates(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateListResponse:
"""Get list of promo offer templates."""
@@ -288,7 +300,7 @@ async def list_templates(
@router.get('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
async def get_template(
template_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateResponse:
"""Get a promo offer template."""
@@ -302,7 +314,7 @@ async def get_template(
async def update_template(
template_id: int,
payload: PromoOfferTemplateUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateResponse:
"""Update a promo offer template."""
@@ -338,7 +350,7 @@ async def update_template(
@router.get('', response_model=PromoOfferListResponse)
async def list_offers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
@@ -369,10 +381,7 @@ async def list_offers(
def _get_bot() -> Bot:
"""Create bot instance for sending notifications."""
return Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
return create_bot()
def _build_default_promo_message(
@@ -491,7 +500,7 @@ async def _send_promo_notifications(
@router.post('/broadcast', response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED)
async def broadcast_offer(
payload: PromoOfferBroadcastRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:send')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferBroadcastResponse:
"""Broadcast promo offer to users with optional Telegram notification."""
@@ -605,7 +614,7 @@ async def broadcast_offer(
@router.get('/logs', response_model=PromoOfferLogListResponse)
async def get_logs(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
+80 -43
View File
@@ -28,9 +28,10 @@ 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, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
router = APIRouter(prefix='/admin/promocodes', tags=['Admin Promocodes'])
@@ -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,
@@ -305,7 +317,7 @@ def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCo
@router.get('', response_model=PromoCodeListResponse)
async def list_promocodes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
@@ -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,
@@ -326,7 +339,7 @@ async def list_promocodes(
@router.get('/{promocode_id}', response_model=PromoCodeDetailResponse)
async def get_promocode(
promocode_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeDetailResponse:
"""Get promocode details with usage statistics."""
@@ -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(
@@ -349,7 +362,7 @@ async def get_promocode(
@router.post('', response_model=PromoCodeResponse, status_code=status.HTTP_201_CREATED)
async def create_promocode_endpoint(
payload: PromoCodeCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeResponse:
"""Create a new promocode."""
@@ -388,18 +401,20 @@ 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)
async def update_promocode_endpoint(
promocode_id: int,
payload: PromoCodeUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeResponse:
"""Update an existing promocode."""
@@ -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(
@@ -460,7 +478,7 @@ async def update_promocode_endpoint(
)
async def delete_promocode_endpoint(
promocode_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> Response:
"""Delete a promocode."""
@@ -486,47 +504,66 @@ class DeactivateDiscountResponse(BaseModel):
@router.post('/deactivate-discount/{user_id}', response_model=DeactivateDiscountResponse)
async def admin_deactivate_discount_promocode(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> DeactivateDiscountResponse:
"""Admin: deactivate a user's active discount promo code."""
"""Admin: deactivate a user's active discount (promo code or promo offer)."""
from app.database.crud.user import get_user_by_id as get_user
target_user = await get_user(db, user_id)
if not target_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
from app.services.promocode_service import PromoCodeService
current_discount = getattr(target_user, 'promo_offer_discount_percent', 0) or 0
source = getattr(target_user, 'promo_offer_discount_source', None)
service = PromoCodeService()
result = await service.deactivate_discount_promocode(
db=db,
user_id=user_id,
admin_initiated=True,
)
if current_discount <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'User has no active discount')
if result['success']:
return DeactivateDiscountResponse(
success=True,
message=f'Discount promo code deactivated for user {user_id}',
deactivated_code=result.get('deactivated_code'),
discount_percent=result.get('discount_percent', 0),
# If source is a promo code, use the service to properly rollback usage
if source and source.startswith('promocode:'):
from app.services.promocode_service import PromoCodeService
service = PromoCodeService()
result = await service.deactivate_discount_promocode(
db=db,
user_id=user_id,
admin_initiated=True,
)
error_messages = {
'user_not_found': 'User not found',
'no_active_discount_promocode': 'User has no active discount from a promo code',
'discount_already_expired': 'Discount has already expired (cleaned up)',
'server_error': 'Server error occurred',
}
if result['success']:
return DeactivateDiscountResponse(
success=True,
message=f'Discount promo code deactivated for user {user_id}',
deactivated_code=result.get('deactivated_code'),
discount_percent=result.get('discount_percent', 0),
user_id=user_id,
)
error_code = result.get('error', 'server_error')
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
error_messages = {
'user_not_found': 'User not found',
'no_active_discount_promocode': 'User has no active discount from a promo code',
'discount_already_expired': 'Discount has already expired (cleaned up)',
'server_error': 'Server error occurred',
}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
error_code = result.get('error', 'server_error')
raise HTTPException(status.HTTP_400_BAD_REQUEST, error_messages.get(error_code, 'Failed to deactivate'))
# For non-promocode offers (admin offers, etc.) — just clear the fields
old_percent = target_user.promo_offer_discount_percent
target_user.promo_offer_discount_percent = 0
target_user.promo_offer_discount_source = None
target_user.promo_offer_discount_expires_at = None
target_user.updated_at = datetime.now(UTC)
await db.commit()
return DeactivateDiscountResponse(
success=True,
message=f'Promo offer deactivated for user {user_id}',
deactivated_code=None,
discount_percent=old_percent,
user_id=user_id,
)
@@ -537,7 +574,7 @@ promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo
@promo_groups_router.get('', response_model=PromoGroupListResponse)
async def list_promo_groups(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
@@ -561,7 +598,7 @@ async def list_promo_groups(
@promo_groups_router.get('/{group_id}', response_model=PromoGroupResponse)
async def get_promo_group(
group_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Get promo group details."""
@@ -576,7 +613,7 @@ async def get_promo_group(
@promo_groups_router.post('', response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
async def create_promo_group_endpoint(
payload: PromoGroupCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Create a new promo group."""
@@ -608,7 +645,7 @@ async def create_promo_group_endpoint(
async def update_promo_group_endpoint(
group_id: int,
payload: PromoGroupUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Update a promo group."""
@@ -645,7 +682,7 @@ async def update_promo_group_endpoint(
@promo_groups_router.delete('/{group_id}', status_code=status.HTTP_204_NO_CONTENT)
async def delete_promo_group_endpoint(
group_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> Response:
"""Delete a promo group."""
File diff suppressed because it is too large Load Diff
+43 -38
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 (
@@ -16,7 +17,7 @@ from app.database.crud.server_squad import (
from app.database.models import User
from app.utils.cache import cache
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.remnawave import (
AutoSyncRunResponse,
# Auto Sync
@@ -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'),
)
@@ -153,7 +154,7 @@ def _serialize_node(node_data: dict[str, Any]) -> NodeInfo:
@router.get('/status', response_model=RemnaWaveStatusResponse)
async def get_remnawave_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> RemnaWaveStatusResponse:
"""Get RemnaWave configuration and connection status."""
service = _get_service()
@@ -176,7 +177,7 @@ async def get_remnawave_status(
@router.get('/system', response_model=SystemStatsResponse)
async def get_system_statistics(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> SystemStatsResponse:
"""Get full system statistics from RemnaWave."""
service = _get_service()
@@ -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(
@@ -238,7 +237,7 @@ async def get_system_statistics(
@router.get('/nodes', response_model=NodesListResponse)
async def list_nodes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodesListResponse:
"""Get list of all nodes."""
service = _get_service()
@@ -252,7 +251,7 @@ async def list_nodes(
@router.get('/nodes/overview', response_model=NodesOverview)
async def get_nodes_overview(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodesOverview:
"""Get nodes overview with statistics."""
service = _get_service()
@@ -278,7 +277,7 @@ async def get_nodes_overview(
@router.get('/nodes/realtime')
async def get_nodes_realtime(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> list[dict[str, Any]]:
"""Get realtime node usage data."""
service = _get_service()
@@ -290,7 +289,7 @@ async def get_nodes_realtime(
@router.get('/nodes/{node_uuid}', response_model=NodeInfo)
async def get_node_details(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodeInfo:
"""Get detailed information about a specific node."""
service = _get_service()
@@ -309,7 +308,7 @@ async def get_node_details(
@router.get('/nodes/{node_uuid}/statistics', response_model=NodeStatisticsResponse)
async def get_node_statistics(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodeStatisticsResponse:
"""Get node statistics with usage history."""
service = _get_service()
@@ -335,7 +334,7 @@ async def get_node_usage(
node_uuid: str,
start: datetime | None = Query(default=None),
end: datetime | None = Query(default=None),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodeUsageResponse:
"""Get node usage history for a date range."""
service = _get_service()
@@ -358,7 +357,7 @@ async def get_node_usage(
async def perform_node_action(
node_uuid: str,
payload: NodeActionRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> NodeActionResponse:
"""Perform an action on a node (enable/disable/restart)."""
service = _get_service()
@@ -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(
admin: User = Depends(get_current_admin_user),
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)
@@ -421,7 +426,7 @@ async def restart_all_nodes(
@router.get('/squads', response_model=SquadsListResponse)
async def list_squads(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SquadsListResponse:
"""Get list of all squads with local database info."""
@@ -463,7 +468,7 @@ async def list_squads(
@router.get('/squads/{squad_uuid}', response_model=SquadDetailResponse)
async def get_squad_details(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SquadDetailResponse:
"""Get detailed information about a squad."""
@@ -506,7 +511,7 @@ async def get_squad_details(
@router.post('/squads', response_model=SquadOperationResponse, status_code=status.HTTP_201_CREATED)
async def create_squad(
payload: SquadCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> SquadOperationResponse:
"""Create a new squad in RemnaWave."""
service = _get_service()
@@ -533,7 +538,7 @@ async def create_squad(
async def update_squad(
squad_uuid: str,
payload: SquadUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> SquadOperationResponse:
"""Update a squad in RemnaWave."""
service = _get_service()
@@ -564,7 +569,7 @@ async def update_squad(
async def perform_squad_action(
squad_uuid: str,
payload: SquadActionRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> SquadOperationResponse:
"""Perform an action on a squad."""
service = _get_service()
@@ -609,7 +614,7 @@ async def perform_squad_action(
@router.delete('/squads/{squad_uuid}', response_model=SquadOperationResponse)
async def delete_squad(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> SquadOperationResponse:
"""Delete a squad."""
service = _get_service()
@@ -632,7 +637,7 @@ async def delete_squad(
@router.get('/squads/{squad_uuid}/migration-preview', response_model=MigrationPreviewResponse)
async def preview_migration(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> MigrationPreviewResponse:
"""Get migration preview for a squad."""
@@ -657,7 +662,7 @@ async def preview_migration(
@router.post('/squads/migrate', response_model=MigrationResponse)
async def migrate_squad_users(
payload: MigrationRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
db: AsyncSession = Depends(get_cabinet_db),
) -> MigrationResponse:
"""Migrate users from one squad to another."""
@@ -731,7 +736,7 @@ async def migrate_squad_users(
@router.get('/inbounds', response_model=InboundsListResponse)
async def list_inbounds(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> InboundsListResponse:
"""Get list of all available inbounds."""
service = _get_service()
@@ -746,7 +751,7 @@ async def list_inbounds(
@router.get('/sync/auto/status', response_model=AutoSyncStatus)
async def get_auto_sync_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> AutoSyncStatus:
"""Get auto sync status."""
if remnawave_sync_service is None:
@@ -775,7 +780,7 @@ async def get_auto_sync_status(
@router.post('/sync/auto/toggle', response_model=SyncResponse)
async def toggle_auto_sync(
payload: AutoSyncToggleRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
) -> SyncResponse:
"""Toggle auto sync on/off."""
if remnawave_sync_service is None:
@@ -811,7 +816,7 @@ async def toggle_auto_sync(
@router.post('/sync/auto/run', response_model=AutoSyncRunResponse)
async def run_auto_sync_now(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
) -> AutoSyncRunResponse:
"""Run auto sync immediately."""
if remnawave_sync_service is None:
@@ -839,7 +844,7 @@ async def run_auto_sync_now(
@router.post('/sync/from-panel', response_model=SyncResponse)
async def sync_from_panel(
payload: SyncMode,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync users from RemnaWave panel to bot."""
@@ -863,7 +868,7 @@ async def sync_from_panel(
@router.post('/sync/to-panel', response_model=SyncResponse)
async def sync_to_panel(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync users from bot to RemnaWave panel."""
@@ -882,7 +887,7 @@ async def sync_to_panel(
@router.post('/sync/servers', response_model=SyncResponse)
async def sync_servers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync servers/squads from RemnaWave."""
@@ -925,7 +930,7 @@ async def sync_servers(
@router.post('/sync/subscriptions/validate', response_model=SyncResponse)
async def validate_subscriptions(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Validate and fix subscriptions."""
@@ -944,7 +949,7 @@ async def validate_subscriptions(
@router.post('/sync/subscriptions/cleanup', response_model=SyncResponse)
async def cleanup_subscriptions(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Cleanup orphaned subscriptions."""
@@ -963,7 +968,7 @@ async def cleanup_subscriptions(
@router.post('/sync/subscriptions/statuses', response_model=SyncResponse)
async def sync_subscription_statuses(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync subscription statuses."""
@@ -982,7 +987,7 @@ async def sync_subscription_statuses(
@router.get('/sync/recommendations', response_model=SyncResponse)
async def get_sync_recommendations(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Get sync recommendations."""
+558
View File
@@ -0,0 +1,558 @@
"""Admin RBAC roles management routes."""
from __future__ import annotations
from datetime import datetime
import sqlalchemy as sa
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.rbac import SUPERADMIN_LEVEL, AdminRoleCRUD, UserRoleCRUD
from app.database.models import User
from app.services.permission_service import PERMISSION_REGISTRY, get_all_permissions
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/rbac', tags=['Admin RBAC'])
# ============ Schemas ============
class RoleResponse(BaseModel):
"""Admin role with user count."""
id: int
name: str
description: str | None = None
level: int
permissions: list[str] = Field(default_factory=list)
color: str | None = None
icon: str | None = None
is_system: bool
is_active: bool
user_count: int = 0
created_at: datetime | None = None
class RoleCreateRequest(BaseModel):
"""Create a new custom role."""
name: str = Field(min_length=1, max_length=100)
description: str | None = None
level: int = Field(ge=0, le=998)
permissions: list[str] = Field(default_factory=list)
color: str | None = Field(default=None, max_length=7)
icon: str | None = Field(default=None, max_length=50)
class RoleUpdateRequest(BaseModel):
"""Update role fields (all optional)."""
name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = None
level: int | None = Field(default=None, ge=0, le=998)
permissions: list[str] | None = None
color: str | None = Field(default=None, max_length=7)
icon: str | None = Field(default=None, max_length=50)
is_active: bool | None = None
class RoleAssignRequest(BaseModel):
"""Assign a role to a user."""
user_id: int
role_id: int
expires_at: datetime | None = None
class PermissionSection(BaseModel):
"""Permission section with available actions."""
section: str
actions: list[str]
class UserRoleResponse(BaseModel):
"""User-role assignment details."""
id: int
user_id: int
role_id: int
role_name: str | None = None
user_telegram_id: int | None = None
user_username: str | None = None
user_first_name: str | None = None
user_email: str | None = None
assigned_by: int | None = None
assigned_at: datetime | None = None
expires_at: datetime | None = None
is_active: bool
class AdminWithRolesResponse(BaseModel):
"""User that has at least one admin role."""
user_id: int
telegram_id: int | None = None
username: str | None = None
first_name: str | None = None
last_name: str | None = None
email: str | None = None
role_names: list[str] = Field(default_factory=list)
# ============ Helper Functions ============
async def _role_to_response(db: AsyncSession, role) -> RoleResponse:
"""Convert AdminRole model to RoleResponse with user count."""
user_count = await AdminRoleCRUD.count_users(db, role.id)
return RoleResponse(
id=role.id,
name=role.name,
description=role.description,
level=role.level,
permissions=role.permissions or [],
color=role.color,
icon=role.icon,
is_system=role.is_system,
is_active=role.is_active,
user_count=user_count,
created_at=role.created_at,
)
async def _get_admin_level(db: AsyncSession, admin: User) -> int:
"""Get the effective management level of the current admin.
Superadmin-tier users (DB level 999 or legacy ADMIN_IDS) are promoted to
level 1000 so they can manage peer Superadmins. Without this, the ``>=``
hierarchy guard would block 999-vs-999 operations.
"""
from app.config import settings
_perms, _names, max_level = await UserRoleCRUD.get_user_permissions(db, admin.id)
# DB-assigned Superadmins can manage peers
if max_level >= SUPERADMIN_LEVEL:
max_level = SUPERADMIN_LEVEL + 1
# Legacy config-based admins always get the highest level
if settings.is_admin(
telegram_id=admin.telegram_id,
email=admin.email if admin.email_verified else None,
):
max_level = max(max_level, SUPERADMIN_LEVEL + 1)
return max_level
def _validate_permissions(permissions: list[str]) -> None:
"""Validate that all provided permissions exist in the registry."""
all_valid = set(get_all_permissions())
# Also allow wildcard patterns
all_valid.add('*:*')
for section in PERMISSION_REGISTRY:
all_valid.add(f'{section}:*')
invalid = [p for p in permissions if p not in all_valid]
if invalid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid permissions: {", ".join(invalid)}',
)
# ============ Routes ============
@router.get('/permissions', response_model=list[PermissionSection])
async def get_permission_registry(
admin: User = Depends(require_permission('roles:read')),
):
"""Get all available permissions grouped by section."""
return [
PermissionSection(section=section, actions=list(actions)) for section, actions in PERMISSION_REGISTRY.items()
]
@router.get('/users', response_model=list[AdminWithRolesResponse])
async def list_rbac_users(
admin: User = Depends(require_permission('roles:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all users that have at least one active RBAC role."""
from sqlalchemy import select as _sa_select
from sqlalchemy.orm import selectinload as _sel
from app.database.models import UserRole as _UserRole
result = await db.execute(
_sa_select(_UserRole)
.options(_sel(_UserRole.user), _sel(_UserRole.role))
.where(_UserRole.is_active.is_(True))
.order_by(_UserRole.user_id)
)
assignments = result.scalars().all()
users_map: dict[int, AdminWithRolesResponse] = {}
for a in assignments:
if not a.user:
continue
if a.user_id not in users_map:
users_map[a.user_id] = AdminWithRolesResponse(
user_id=a.user_id,
telegram_id=a.user.telegram_id,
username=a.user.username,
first_name=a.user.first_name,
last_name=a.user.last_name,
email=a.user.email,
role_names=[],
)
if a.role:
users_map[a.user_id].role_names.append(a.role.name)
return list(users_map.values())
@router.get('/roles/{role_id}/users', response_model=list[UserRoleResponse])
async def list_role_users(
role_id: int,
admin: User = Depends(require_permission('roles:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List user-role assignments for a specific role."""
from sqlalchemy.orm import selectinload as _sel
role = await AdminRoleCRUD.get_by_id(db, role_id)
if not role:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Role not found')
from sqlalchemy import select as _sa_select
from app.database.models import UserRole as _UserRole
result = await db.execute(
_sa_select(_UserRole)
.options(_sel(_UserRole.user), _sel(_UserRole.role))
.where(_UserRole.role_id == role_id, _UserRole.is_active.is_(True))
.order_by(_UserRole.assigned_at.desc())
)
assignments = result.scalars().all()
return [
UserRoleResponse(
id=a.id,
user_id=a.user_id,
role_id=a.role_id,
role_name=a.role.name if a.role else None,
user_telegram_id=a.user.telegram_id if a.user else None,
user_username=a.user.username if a.user else None,
user_first_name=a.user.first_name if a.user else None,
user_email=a.user.email if a.user else None,
assigned_by=a.assigned_by,
assigned_at=a.assigned_at,
expires_at=a.expires_at,
is_active=a.is_active,
)
for a in assignments
]
@router.get('/roles', response_model=list[RoleResponse])
async def list_roles(
admin: User = Depends(require_permission('roles:read')),
db: AsyncSession = Depends(get_cabinet_db),
include_inactive: bool = False,
):
"""List all admin roles with user counts."""
roles = await AdminRoleCRUD.get_all(db, include_inactive=include_inactive)
return [await _role_to_response(db, role) for role in roles]
@router.post('/roles', response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
async def create_role(
payload: RoleCreateRequest,
admin: User = Depends(require_permission('roles:create')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new custom admin role."""
# Validate permissions list
_validate_permissions(payload.permissions)
# Hierarchy enforcement: cannot create role with level >= own level
admin_level = await _get_admin_level(db, admin)
if payload.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot create a role with level >= your own role level',
)
# Check name uniqueness
existing = await AdminRoleCRUD.get_by_name(db, payload.name)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Role with this name already exists',
)
role = await AdminRoleCRUD.create(
db,
name=payload.name,
description=payload.description,
level=payload.level,
permissions=payload.permissions,
color=payload.color,
icon=payload.icon,
created_by=admin.id,
)
await db.commit()
logger.info('Admin created role', admin_id=admin.id, role_id=role.id, role_name=role.name)
return await _role_to_response(db, role)
@router.put('/roles/{role_id}', response_model=RoleResponse)
async def update_role(
role_id: int,
payload: RoleUpdateRequest,
admin: User = Depends(require_permission('roles:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing admin role."""
role = await AdminRoleCRUD.get_by_id(db, role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Role not found',
)
admin_level = await _get_admin_level(db, admin)
# Cannot edit a role at or above own level
if role.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot edit a role at or above your own level',
)
update_data = payload.model_dump(exclude_unset=True)
# System roles: only permissions can be extended, block is_active/level changes
if role.is_system:
blocked = {'is_active', 'level'} & update_data.keys()
if blocked:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Cannot change {", ".join(sorted(blocked))} on a system role',
)
# Validate level change
if 'level' in update_data and update_data['level'] >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot set role level >= your own role level',
)
# Validate permissions
if 'permissions' in update_data and update_data['permissions'] is not None:
_validate_permissions(update_data['permissions'])
# Check name uniqueness if name is changing
if 'name' in update_data and update_data['name'] != role.name:
existing = await AdminRoleCRUD.get_by_name(db, update_data['name'])
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Role with this name already exists',
)
updated = await AdminRoleCRUD.update(db, role_id, **update_data)
if not updated:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Role not found',
)
await db.commit()
logger.info('Admin updated role', admin_id=admin.id, role_id=role_id, fields=list(update_data.keys()))
return await _role_to_response(db, updated)
@router.delete('/roles/{role_id}')
async def delete_role(
role_id: int,
admin: User = Depends(require_permission('roles:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a custom admin role. System roles cannot be deleted."""
role = await AdminRoleCRUD.get_by_id(db, role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Role not found',
)
if role.is_system:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot delete a system role',
)
admin_level = await _get_admin_level(db, admin)
if role.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot delete a role at or above your own level',
)
deleted = await AdminRoleCRUD.delete(db, role_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to delete role',
)
await db.commit()
logger.info('Admin deleted role', admin_id=admin.id, role_id=role_id, role_name=role.name)
return {'message': 'Role deleted', 'role_id': role_id}
@router.post('/assignments', response_model=UserRoleResponse, status_code=status.HTTP_201_CREATED)
async def assign_role(
payload: RoleAssignRequest,
admin: User = Depends(require_permission('roles:assign')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Assign a role to a user. Hierarchy enforcement applies."""
role = await AdminRoleCRUD.get_by_id(db, payload.role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
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
if role.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot assign a role with level >= your own role level',
)
# Verify target user exists
from app.database.crud.user import get_user_by_id
target_user = await get_user_by_id(db, payload.user_id)
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Target user not found',
)
user_role = await UserRoleCRUD.assign_role(
db,
user_id=payload.user_id,
role_id=payload.role_id,
assigned_by=admin.id,
expires_at=payload.expires_at,
)
await db.commit()
logger.info(
'Admin assigned role',
admin_id=admin.id,
target_user_id=payload.user_id,
role_id=payload.role_id,
role_name=role.name,
)
return UserRoleResponse(
id=user_role.id,
user_id=user_role.user_id,
role_id=user_role.role_id,
role_name=role.name,
user_telegram_id=target_user.telegram_id,
user_username=target_user.username,
user_first_name=target_user.first_name,
user_email=target_user.email,
assigned_by=user_role.assigned_by,
assigned_at=user_role.assigned_at,
expires_at=user_role.expires_at,
is_active=user_role.is_active,
)
@router.delete('/assignments/{assignment_id}')
async def revoke_role(
assignment_id: int,
admin: User = Depends(require_permission('roles:assign')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""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)
result = await db.execute(sa.select(UserRole).where(UserRole.id == assignment_id).with_for_update())
user_role = result.scalar_one_or_none()
if not user_role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Role assignment not found',
)
role = await AdminRoleCRUD.get_by_id(db, user_role.role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
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
if role.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot revoke a role at or above your own level',
)
# Revoke directly on the locked object (avoid CRUD re-fetch without FOR UPDATE)
user_role.is_active = False
await db.flush()
await db.commit()
logger.info(
'Admin revoked role assignment',
admin_id=admin.id,
assignment_id=assignment_id,
target_user_id=user_role.user_id,
role_name=role.name,
)
return {'message': 'Role revoked', 'assignment_id': assignment_id}
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -16,7 +16,7 @@ from app.database.crud.server_squad import (
from app.database.models import PromoGroup, ServerSquad, Subscription, Tariff, User
from app.services.subscription_service import SubscriptionService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.servers import (
PromoGroupInfo,
ServerDetailResponse,
@@ -66,7 +66,7 @@ async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> list[s
@router.get('', response_model=ServerListResponse)
async def list_servers(
include_unavailable: bool = True,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all servers."""
@@ -103,7 +103,7 @@ async def list_servers(
@router.get('/{server_id}', response_model=ServerDetailResponse)
async def get_server(
server_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed server info."""
@@ -146,7 +146,7 @@ async def get_server(
async def update_existing_server(
server_id: int,
request: ServerUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing server."""
@@ -191,7 +191,7 @@ async def update_existing_server(
@router.post('/{server_id}/toggle', response_model=ServerToggleResponse)
async def toggle_server(
server_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle server availability."""
@@ -218,7 +218,7 @@ async def toggle_server(
@router.post('/{server_id}/trial', response_model=ServerTrialToggleResponse)
async def toggle_server_trial(
server_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle server trial eligibility."""
@@ -245,7 +245,7 @@ async def toggle_server_trial(
@router.get('/{server_id}/stats', response_model=ServerStatsResponse)
async def get_server_stats(
server_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get server statistics."""
@@ -287,7 +287,7 @@ async def get_server_stats(
@router.post('/sync', response_model=ServerSyncResponse)
async def sync_servers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Sync servers with RemnaWave."""
+6 -6
View File
@@ -13,7 +13,7 @@ from app.services.system_settings_service import (
bot_configuration_service,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -179,7 +179,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
@router.get('/categories', response_model=list[SettingCategorySummary])
async def list_categories(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:read')),
):
"""Get list of setting categories."""
categories = bot_configuration_service.get_categories()
@@ -196,7 +196,7 @@ async def list_categories(
@router.get('', response_model=list[SettingDefinition])
async def list_settings(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:read')),
category: str | None = Query(default=None, alias='category_key'),
):
"""Get list of all settings or settings for a specific category."""
@@ -217,7 +217,7 @@ async def list_settings(
@router.get('/{key}', response_model=SettingDefinition)
async def get_setting(
key: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:read')),
):
"""Get a specific setting by key."""
try:
@@ -232,7 +232,7 @@ async def get_setting(
async def update_setting(
key: str,
payload: SettingUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update a setting value."""
@@ -255,7 +255,7 @@ async def update_setting(
@router.delete('/{key}', response_model=SettingDefinition)
async def reset_setting(
key: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset a setting to its default value."""
+43 -83
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
@@ -13,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.campaign import get_campaign_statistics, get_campaigns_count, get_campaigns_list
from app.database.crud.server_squad import get_server_statistics
from app.database.crud.subscription import get_subscriptions_statistics
from app.database.crud.transaction import get_revenue_by_period, get_transactions_statistics
from app.database.crud.transaction import REAL_PAYMENT_METHODS, get_revenue_by_period, get_transactions_statistics
from app.database.models import (
ReferralEarning,
Subscription,
@@ -26,7 +27,7 @@ from app.database.models import (
from app.services.remnawave_service import RemnaWaveService
from app.services.version_service import version_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -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
@@ -246,7 +243,7 @@ class RecentPaymentsResponse(BaseModel):
@router.get('/dashboard', response_model=DashboardStats)
async def get_dashboard_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get complete dashboard statistics for admin panel."""
@@ -262,6 +259,9 @@ async def get_dashboard_stats(
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
trans_stats = await get_transactions_statistics(db, month_start, now)
all_time_stats = await get_transactions_statistics(
db, start_date=datetime(2020, 1, 1, tzinfo=UTC), end_date=now
)
# Get revenue chart data (last 30 days)
revenue_data = await get_revenue_by_period(db, days=30)
@@ -272,6 +272,14 @@ async def get_dashboard_stats(
# Get tariff statistics
tariff_stats = await _get_tariff_stats(db)
# Derive income_today from revenue_chart to ensure consistency with chart
today_str = now.date().isoformat()
income_today_from_chart = sum(
item.get('amount_kopeks', 0) for item in revenue_data if str(item.get('date', '')) == today_str
)
# Use chart-derived value if available, otherwise fall back to trans_stats
income_today_kopeks = income_today_from_chart or trans_stats.get('today', {}).get('income_kopeks', 0)
# Build response
return DashboardStats(
nodes=nodes_data,
@@ -287,14 +295,15 @@ async def get_dashboard_stats(
trial_to_paid_conversion=sub_stats.get('trial_to_paid_conversion', 0.0),
),
financial=FinancialStats(
income_today_kopeks=trans_stats.get('today', {}).get('income_kopeks', 0),
income_today_rubles=trans_stats.get('today', {}).get('income_kopeks', 0) / 100,
income_today_kopeks=income_today_kopeks,
income_today_rubles=income_today_kopeks / 100,
income_month_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0),
income_month_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100,
income_total_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0),
income_total_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100,
subscription_income_kopeks=trans_stats.get('totals', {}).get('subscription_income_kopeks', 0),
subscription_income_rubles=trans_stats.get('totals', {}).get('subscription_income_kopeks', 0) / 100,
income_total_kopeks=all_time_stats.get('totals', {}).get('income_kopeks', 0),
income_total_rubles=all_time_stats.get('totals', {}).get('income_kopeks', 0) / 100,
subscription_income_kopeks=abs(all_time_stats.get('totals', {}).get('subscription_income_kopeks', 0)),
subscription_income_rubles=abs(all_time_stats.get('totals', {}).get('subscription_income_kopeks', 0))
/ 100,
),
servers=ServerStats(
total_servers=server_stats.get('total_servers', 0),
@@ -326,7 +335,7 @@ async def get_dashboard_stats(
@router.get('/system-info', response_model=SystemInfoResponse)
async def get_system_info(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get system information for admin dashboard."""
@@ -358,7 +367,7 @@ async def get_system_info(
@router.get('/nodes', response_model=NodesOverview)
async def get_nodes_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
):
"""Get status of all nodes."""
try:
@@ -374,7 +383,7 @@ async def get_nodes_status(
@router.post('/nodes/{node_uuid}/restart')
async def restart_node(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
):
"""Restart a node."""
try:
@@ -401,7 +410,7 @@ async def restart_node(
@router.post('/nodes/{node_uuid}/toggle')
async def toggle_node(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
):
"""Enable or disable a node."""
try:
@@ -457,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
@@ -596,7 +601,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
@router.get('/referrals/top', response_model=TopReferrersResponse)
async def get_top_referrers(
limit: int = 20,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get top referrers with earnings breakdown by period."""
@@ -686,53 +691,6 @@ async def get_top_referrers(
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_month'] = row.total or 0
# Also add REFERRAL_REWARD transactions
trans_total_query = await db.execute(
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
.group_by(Transaction.user_id)
)
for row in trans_total_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_total'] = referrers_data[row.referrer_id].get(
'earnings_total', 0
) + (row.total or 0)
trans_today_query = await db.execute(
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
.where(
and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= today_start)
)
.group_by(Transaction.user_id)
)
for row in trans_today_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_today'] = referrers_data[row.referrer_id].get(
'earnings_today', 0
) + (row.total or 0)
trans_week_query = await db.execute(
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
.where(and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= week_ago))
.group_by(Transaction.user_id)
)
for row in trans_week_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_week'] = referrers_data[row.referrer_id].get(
'earnings_week', 0
) + (row.total or 0)
trans_month_query = await db.execute(
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
.where(and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= month_ago))
.group_by(Transaction.user_id)
)
for row in trans_month_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_month'] = referrers_data[row.referrer_id].get(
'earnings_month', 0
) + (row.total or 0)
# Get user info for all referrers
referrer_ids = list(referrers_data.keys())
if referrer_ids:
@@ -812,7 +770,7 @@ async def get_top_referrers(
@router.get('/campaigns/top', response_model=TopCampaignsResponse)
async def get_top_campaigns(
limit: int = 20,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get top advertising campaigns with statistics."""
@@ -869,7 +827,7 @@ async def get_top_campaigns(
@router.get('/payments/recent', response_model=RecentPaymentsResponse)
async def get_recent_payments(
limit: int = 50,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get recent payments with user info."""
@@ -944,8 +902,8 @@ async def get_recent_payments(
email=user.email,
username=user.username,
display_name=display_name,
amount_kopeks=trans.amount_kopeks,
amount_rubles=trans.amount_kopeks / 100,
amount_kopeks=abs(trans.amount_kopeks),
amount_rubles=abs(trans.amount_kopeks) / 100,
type=trans.type,
type_display=type_display.get(trans.type, trans.type),
payment_method=trans.payment_method,
@@ -969,22 +927,24 @@ async def get_recent_payments(
total_count = total_count_result.scalar() or 0
today_total_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.created_at >= today_start,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
)
)
total_today = today_total_result.scalar() or 0
week_total_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.created_at >= week_ago,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
)
)
)
+297 -14
View File
@@ -1,10 +1,14 @@
"""Admin routes for managing tariffs in cabinet."""
import asyncio
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
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,
@@ -17,14 +21,16 @@ from app.database.crud.tariff import (
set_tariff_promo_groups,
update_tariff,
)
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Tariff, Transaction, TransactionType, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.tariffs import (
ExternalSquadInfoResponse,
PeriodPrice,
PromoGroupInfo,
ServerInfo,
ServerTrafficLimit,
SyncSquadsResponse,
TariffCreateRequest,
TariffDetailResponse,
TariffListItem,
@@ -107,7 +113,7 @@ def _period_prices_to_dict(period_prices: list[PeriodPrice]) -> dict:
@router.get('', response_model=TariffListResponse)
async def list_tariffs(
include_inactive: bool = True,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all tariffs."""
@@ -126,6 +132,7 @@ async def list_tariffs(
is_daily=tariff.is_daily,
daily_price_kopeks=tariff.daily_price_kopeks,
allow_traffic_topup=tariff.allow_traffic_topup,
show_in_gift=tariff.show_in_gift,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
tier_level=tariff.tier_level,
@@ -141,7 +148,7 @@ async def list_tariffs(
@router.get('/available-servers', response_model=list[ServerInfo])
async def get_available_servers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all servers for tariff selection."""
@@ -158,10 +165,34 @@ async def get_available_servers(
]
@router.get('/available-external-squads', response_model=list[ExternalSquadInfoResponse])
async def get_available_external_squads(
admin: User = Depends(require_permission('tariffs:read')),
):
"""Fetch external squads from RemnaWave panel."""
from app.services.remnawave_service import RemnaWaveService
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
squads = await api.get_external_squads()
return [
{
'uuid': s.uuid,
'name': s.name,
'members_count': s.members_count,
}
for s in squads
]
except Exception:
logger.warning('Failed to fetch external squads from RemnaWave', exc_info=True)
return []
@router.put('/order')
async def update_tariff_order(
request: TariffSortOrderRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update the display order of tariffs."""
@@ -176,7 +207,7 @@ async def update_tariff_order(
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
async def get_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed tariff info."""
@@ -238,6 +269,10 @@ async def get_tariff(
daily_price_kopeks=tariff.daily_price_kopeks,
# Режим сброса трафика
traffic_reset_mode=tariff.traffic_reset_mode,
# Внешний сквад
external_squad_uuid=tariff.external_squad_uuid,
# Показывать в подарках
show_in_gift=tariff.show_in_gift,
created_at=tariff.created_at,
updated_at=tariff.updated_at,
)
@@ -246,7 +281,7 @@ async def get_tariff(
@router.post('', response_model=TariffDetailResponse)
async def create_new_tariff(
request: TariffCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:create')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new tariff."""
@@ -276,7 +311,7 @@ async def create_new_tariff(
period_prices=period_prices_dict,
allowed_squads=request.allowed_squads,
server_traffic_limits=server_limits_dict,
promo_group_ids=request.promo_group_ids if request.promo_group_ids else None,
promo_group_ids=request.promo_group_ids or None,
# Произвольное количество дней
custom_days_enabled=request.custom_days_enabled,
price_per_day_kopeks=request.price_per_day_kopeks,
@@ -292,6 +327,10 @@ async def create_new_tariff(
daily_price_kopeks=request.daily_price_kopeks,
# Режим сброса трафика
traffic_reset_mode=request.traffic_reset_mode,
# Внешний сквад
external_squad_uuid=request.external_squad_uuid,
# Показывать в подарках
show_in_gift=request.show_in_gift,
)
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
@@ -307,7 +346,7 @@ async def create_new_tariff(
async def update_existing_tariff(
tariff_id: int,
request: TariffUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing tariff."""
@@ -318,6 +357,10 @@ async def update_existing_tariff(
detail='Tariff not found',
)
# Capture old values for change detection
old_squads = list(tariff.allowed_squads) if tariff.allowed_squads else []
old_external_squad = tariff.external_squad_uuid
# Build updates dict
updates = {}
if request.name is not None:
@@ -381,6 +424,12 @@ async def update_existing_tariff(
# Режим сброса трафика (None допускается как значение для сброса к глобальной настройке)
if 'traffic_reset_mode' in request.model_fields_set:
updates['traffic_reset_mode'] = request.traffic_reset_mode
# Внешний сквад (None допускается для сброса)
if 'external_squad_uuid' in request.model_fields_set:
updates['external_squad_uuid'] = request.external_squad_uuid
# Показывать в подарках
if request.show_in_gift is not None:
updates['show_in_gift'] = request.show_in_gift
if updates:
await update_tariff(db, tariff, **updates)
@@ -394,13 +443,25 @@ async def update_existing_tariff(
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
# Auto-sync squads to active subscriptions in Remnawave when squads changed
new_squads = tariff.allowed_squads or []
squads_changed = request.allowed_squads is not None and sorted(old_squads) != sorted(new_squads)
ext_squad_changed = (
'external_squad_uuid' in request.model_fields_set and tariff.external_squad_uuid != old_external_squad
)
if squads_changed or ext_squad_changed:
asyncio.create_task(
_background_sync_squads(tariff_id, admin.id),
name=f'sync-squads-tariff-{tariff_id}',
)
return await get_tariff(tariff_id, admin, db)
@router.delete('/{tariff_id}')
async def delete_existing_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a tariff."""
@@ -430,7 +491,7 @@ async def delete_existing_tariff(
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
async def toggle_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle tariff active status."""
@@ -460,7 +521,7 @@ async def toggle_tariff(
@router.post('/{tariff_id}/trial', response_model=TariffTrialResponse)
async def toggle_trial_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle tariff trial availability.
@@ -500,7 +561,7 @@ async def toggle_trial_tariff(
@router.get('/{tariff_id}/stats', response_model=TariffStatsResponse)
async def get_tariff_stats(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get tariff statistics."""
@@ -554,3 +615,225 @@ async def get_tariff_stats(
revenue_kopeks=revenue_kopeks,
revenue_rubles=revenue_kopeks / 100,
)
async def _background_sync_squads(tariff_id: int, admin_id: int) -> None:
"""Run squad sync in background with its own DB session (fire-and-forget)."""
from app.database.database import AsyncSessionLocal
from app.services.remnawave_service import RemnaWaveService
try:
async with AsyncSessionLocal() as db:
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
return
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(joinedload(Subscription.user))
.where(
and_(
Subscription.tariff_id == tariff_id,
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
User.remnawave_uuid.isnot(None),
)
)
)
subscriptions = list(result.unique().scalars().all())
if not subscriptions:
return
new_squads = tariff.allowed_squads or []
ext_squad_uuid = tariff.external_squad_uuid
service = RemnaWaveService()
updated = 0
failed = 0
async with service.get_api_client() as api:
semaphore = asyncio.Semaphore(5)
async def _sync_one(sub: Subscription) -> None:
nonlocal updated, failed
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:
try:
await api.update_user(
uuid=remnawave_uuid,
active_internal_squads=new_squads,
external_squad_uuid=ext_squad_uuid,
)
sub.connected_squads = new_squads
updated += 1
except Exception as e:
failed += 1
logger.warning(
'Background sync: failed to sync squads for user',
user_id=sub.user_id,
error=str(e),
)
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
await db.commit()
logger.info(
'Background squad sync completed after tariff update',
admin_id=admin_id,
tariff_id=tariff_id,
tariff_name=tariff.name,
total=len(subscriptions),
updated=updated,
failed=failed,
)
except Exception:
logger.exception('Background squad sync failed', tariff_id=tariff_id)
_SYNC_SQUADS_CONCURRENCY = 5
_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES = 10
@router.post('/{tariff_id}/sync-squads', response_model=SyncSquadsResponse)
async def sync_tariff_squads(
tariff_id: int,
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Sync squads from tariff to all active/trial subscriptions in Remnawave panel.
Updates connected_squads and external_squad_uuid for every active or trial
subscription linked to this tariff. Only users that have a remnawave_uuid
(i.e. already exist in the panel) are touched.
"""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
# Fetch active + trial subscriptions for this tariff whose users exist in Remnawave
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(joinedload(Subscription.user))
.where(
and_(
Subscription.tariff_id == tariff_id,
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
User.remnawave_uuid.isnot(None),
)
)
)
subscriptions = list(result.unique().scalars().all())
if not subscriptions:
return SyncSquadsResponse(
tariff_id=tariff_id,
tariff_name=tariff.name,
total_subscriptions=0,
updated_count=0,
failed_count=0,
skipped_count=0,
)
new_squads = tariff.allowed_squads or []
# None means "clear external squad" — intentional when tariff has none
ext_squad_uuid = tariff.external_squad_uuid
# Sync to Remnawave panel with concurrency limit and circuit breaker
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
updated_count = 0
failed_count = 0
skipped_count = 0
consecutive_failures = 0
errors: list[str] = []
aborted = False
async with service.get_api_client() as api:
semaphore = asyncio.Semaphore(_SYNC_SQUADS_CONCURRENCY)
async def _sync_one(sub: Subscription) -> str:
# Counter mutations are safe: no `await` between read-modify-write
# and the check within each branch (single-threaded asyncio event loop).
nonlocal updated_count, failed_count, skipped_count, consecutive_failures, aborted
if aborted:
skipped_count += 1
return 'skipped'
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'
async with semaphore:
if aborted:
skipped_count += 1
return 'skipped'
try:
await api.update_user(
uuid=remnawave_uuid,
active_internal_squads=new_squads,
external_squad_uuid=ext_squad_uuid,
)
# Update local DB only on successful API call
sub.connected_squads = new_squads
updated_count += 1
consecutive_failures = 0
return 'ok'
except Exception as e:
failed_count += 1
consecutive_failures += 1
errors.append(f'user_id={sub.user_id}: sync failed')
logger.warning(
'Failed to sync squads for user in Remnawave',
user_id=sub.user_id,
remnawave_uuid=remnawave_uuid,
error=str(e),
)
if consecutive_failures >= _SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES:
aborted = True
errors.append(f'Aborted after {_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES} consecutive failures')
return 'error'
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
# Commit local DB changes only for successfully synced subscriptions
await db.commit()
logger.info(
'Admin synced squads for tariff',
admin_id=admin.id,
tariff_id=tariff_id,
tariff_name=tariff.name,
total=len(subscriptions),
updated=updated_count,
failed=failed_count,
skipped=skipped_count,
)
return SyncSquadsResponse(
tariff_id=tariff_id,
tariff_name=tariff.name,
total_subscriptions=len(subscriptions),
updated_count=updated_count,
failed_count=failed_count,
skipped_count=skipped_count,
errors=errors[:20],
)
+59 -24
View File
@@ -5,7 +5,7 @@ from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -16,8 +16,8 @@ from app.database.crud.ticket import TicketCRUD
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.database.models import Ticket, TicketMessage, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.tickets import TicketMessageResponse
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.tickets import TicketMediaItem, TicketMessageResponse, _validate_media_bundle
logger = structlog.get_logger(__name__)
@@ -89,7 +89,20 @@ class AdminTicketListResponse(BaseModel):
class AdminReplyRequest(BaseModel):
"""Admin reply to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
message: str = Field(default='', max_length=4000, description='Reply message')
media_type: str | None = Field(None, description='Media type: photo, video, or document')
media_file_id: str | None = Field(None, max_length=255, description='Telegram file_id from media upload')
media_caption: str | None = Field(None, max_length=1000, description='Caption for media')
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media gallery attachments')
@model_validator(mode='after')
def validate_media_fields(self) -> 'AdminReplyRequest':
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
has_text = bool(self.message.strip())
has_media = bool(self.media_file_id) or bool(self.media_items)
if not has_text and not has_media:
raise ValueError('message or media is required')
return self
class AdminStatusUpdateRequest(BaseModel):
@@ -144,14 +157,23 @@ class TicketSettingsUpdateRequest(BaseModel):
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
"""Convert TicketMessage to response."""
raw_items = getattr(message, 'media_items', None) or None
items = None
if raw_items:
try:
items = [TicketMediaItem(**it) for it in raw_items]
except (TypeError, KeyError, ValueError) as exc:
logger.warning('Failed to parse media_items', message_id=message.id, error=str(exc))
items = None
return TicketMessageResponse(
id=message.id,
message_text=message.message_text or '',
is_from_admin=message.is_from_admin,
has_media=bool(message.media_file_id),
has_media=bool(message.media_file_id) or bool(items),
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
media_items=items,
created_at=message.created_at,
)
@@ -197,7 +219,7 @@ def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) ->
@router.get('/stats', response_model=AdminStatsResponse)
async def get_ticket_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket statistics."""
@@ -222,7 +244,7 @@ async def get_ticket_stats(
@router.get('/settings', response_model=TicketSettingsResponse)
async def get_ticket_settings(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket system settings."""
@@ -242,10 +264,11 @@ async def get_ticket_settings(
@router.patch('/settings', response_model=TicketSettingsResponse)
async def update_ticket_settings(
request: TicketSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket system settings."""
import asyncio
from pathlib import Path
from app.services.support_settings_service import SupportSettingsService
@@ -280,8 +303,8 @@ async def update_ticket_settings(
# Try to persist to .env file
try:
env_file = Path('.env')
if env_file.exists():
lines = env_file.read_text().splitlines()
if await asyncio.to_thread(env_file.exists):
lines = (await asyncio.to_thread(env_file.read_text)).splitlines()
updates = {}
if request.sla_enabled is not None:
@@ -314,7 +337,7 @@ async def update_ticket_settings(
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n')
await asyncio.to_thread(env_file.write_text, '\n'.join(new_lines) + '\n')
logger.info('Updated ticket settings in .env file')
except Exception as e:
logger.warning('Failed to update .env file', error=e)
@@ -337,7 +360,7 @@ async def get_all_tickets(
status_filter: str | None = Query(None, alias='status', description='Filter by status'),
priority_filter: str | None = Query(None, alias='priority', description='Filter by priority'),
user_id: int | None = Query(None, description='Filter by user ID'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all tickets for admin."""
@@ -386,7 +409,7 @@ async def get_all_tickets(
@router.get('/{ticket_id}', response_model=AdminTicketDetailResponse)
async def get_ticket_detail(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket with all messages for admin."""
@@ -428,7 +451,7 @@ async def get_ticket_detail(
async def reply_to_ticket(
ticket_id: int,
request: AdminReplyRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:reply')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reply to a ticket as admin."""
@@ -441,12 +464,29 @@ async def reply_to_ticket(
detail='Ticket not found',
)
# Create admin message
# Resolve media payload: prefer media_items, fall back to legacy single-media fields
items_payload = None
primary_type = request.media_type
primary_file_id = request.media_file_id
primary_caption = request.media_caption
if request.media_items:
items_payload = [it.model_dump() for it in request.media_items]
first = request.media_items[0]
primary_type = first.type
primary_file_id = first.file_id
primary_caption = primary_caption or first.caption
has_media = bool(primary_file_id)
message = TicketMessage(
ticket_id=ticket.id,
user_id=ticket.user_id,
message_text=request.message,
is_from_admin=True,
has_media=has_media,
media_type=primary_type if has_media else None,
media_file_id=primary_file_id if has_media else None,
media_caption=primary_caption if has_media else None,
media_items=items_payload,
created_at=datetime.now(UTC),
)
db.add(message)
@@ -460,14 +500,9 @@ async def reply_to_ticket(
# Try to notify user via Telegram
try:
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.bot_factory import create_bot
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
from app.handlers.admin.tickets import notify_user_about_ticket_reply
@@ -497,7 +532,7 @@ async def reply_to_ticket(
async def update_ticket_status(
ticket_id: int,
request: AdminStatusUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:close')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket status."""
@@ -556,7 +591,7 @@ async def update_ticket_status(
async def update_ticket_priority(
ticket_id: int,
request: AdminPriorityUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:close')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket priority."""
+102 -35
View File
@@ -7,23 +7,22 @@ import time
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.bot_factory import create_bot
from app.database.models import Subscription, Transaction, TransactionType, User
from app.services.remnawave_service import RemnaWaveService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.traffic import (
ExportCsvRequest,
ExportCsvResponse,
SubscriptionEnrichmentInfo,
SubscriptionTrafficInfo,
TrafficEnrichmentResponse,
TrafficNodeInfo,
TrafficUsageResponse,
@@ -99,7 +98,13 @@ async def _aggregate_traffic(
user_uuids_set = set(user_uuids)
async with service.get_api_client() as api:
nodes = await api.get_all_nodes()
try:
nodes = await api.get_all_nodes()
except Exception:
logger.warning('Failed to fetch nodes for traffic aggregation', exc_info=True)
# Cache empty result to avoid hammering the failing API
_traffic_cache[cache_key] = (now, {}, [])
return {}, []
# Fetch per-node user stats — O(nodes) calls instead of O(users)
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
@@ -153,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(
@@ -199,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
@@ -226,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,
@@ -239,6 +287,7 @@ def _build_traffic_items(
device_limit=device_limit,
node_traffic=traffic,
total_bytes=total_bytes,
subscriptions=subscriptions_traffic,
)
)
@@ -256,7 +305,7 @@ def _build_traffic_items(
@router.get('', response_model=TrafficUsageResponse)
async def get_traffic_usage(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('traffic:read')),
db: AsyncSession = Depends(get_cabinet_db),
period: int = Query(30, ge=1, le=30),
limit: int = Query(50, ge=1, le=200),
@@ -302,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
@@ -463,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
@@ -491,7 +561,7 @@ async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
async def get_traffic_enrichment(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('traffic:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return enrichment data: device counts, spending, dates, last node."""
@@ -524,7 +594,7 @@ async def get_traffic_enrichment(
@router.post('/export-csv', response_model=ExportCsvResponse)
async def export_traffic_csv(
request: ExportCsvRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('traffic:export')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Generate CSV with traffic usage and send to admin's Telegram DM."""
@@ -674,10 +744,7 @@ async def export_traffic_csv(
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
try:
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
async with bot:
await bot.send_document(
chat_id=admin.telegram_id,
+2 -2
View File
@@ -10,7 +10,7 @@ from pydantic import BaseModel
from app.database.models import User
from app.services.version_service import version_service
from ..dependencies import get_current_admin_user
from ..dependencies import require_permission
logger = structlog.get_logger(__name__)
@@ -93,7 +93,7 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
@router.get('/releases', response_model=ReleasesResponse)
async def get_releases(
current_user: User = Depends(get_current_admin_user),
current_user: User = Depends(require_permission('updates:read')),
) -> ReleasesResponse:
"""Get release information for bot and cabinet."""
# Bot releases
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -9,7 +9,7 @@ import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.cabinet.dependencies import get_cabinet_db, get_current_admin_user
from app.cabinet.dependencies import get_cabinet_db, require_permission
from app.cabinet.schemas.wheel import (
AdminSpinItem,
AdminSpinsResponse,
@@ -42,7 +42,7 @@ router = APIRouter(prefix='/admin/wheel', tags=['Admin Fortune Wheel'])
@router.get('/config', response_model=AdminWheelConfigResponse)
async def get_admin_wheel_config(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить полную конфигурацию колеса."""
@@ -93,7 +93,7 @@ async def get_admin_wheel_config(
@router.put('/config', response_model=AdminWheelConfigResponse)
async def update_admin_wheel_config(
request: UpdateWheelConfigRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Обновить конфигурацию колеса."""
@@ -155,7 +155,7 @@ async def update_admin_wheel_config(
@router.get('/prizes', response_model=list[WheelPrizeAdminResponse])
async def get_prizes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить список призов."""
@@ -188,7 +188,7 @@ async def get_prizes(
@router.post('/prizes', response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED)
async def create_prize(
request: CreatePrizeRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Создать новый приз."""
@@ -237,7 +237,7 @@ async def create_prize(
async def update_prize(
prize_id: int,
request: UpdatePrizeRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Обновить приз."""
@@ -286,7 +286,7 @@ async def update_prize(
@router.delete('/prizes/{prize_id}', status_code=status.HTTP_204_NO_CONTENT)
async def delete_prize_endpoint(
prize_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Удалить приз."""
@@ -304,7 +304,7 @@ async def delete_prize_endpoint(
@router.post('/prizes/reorder', status_code=status.HTTP_200_OK)
async def reorder_prizes(
request: ReorderPrizesRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Переупорядочить призы."""
@@ -317,7 +317,7 @@ async def reorder_prizes(
async def get_statistics(
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить статистику колеса."""
@@ -344,7 +344,7 @@ async def get_all_spins_endpoint(
date_to: datetime | None = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить все спины с фильтрами."""
+300
View File
@@ -0,0 +1,300 @@
"""Admin routes for managing withdrawal requests in cabinet."""
import json
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
ReferralEarning,
User,
WithdrawalRequest,
WithdrawalRequestStatus,
)
from app.services.referral_withdrawal_service import referral_withdrawal_service
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.withdrawals import (
AdminApproveWithdrawalRequest,
AdminRejectWithdrawalRequest,
AdminWithdrawalDetailResponse,
AdminWithdrawalItem,
AdminWithdrawalListResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/withdrawals', tags=['Cabinet Admin Withdrawals'])
def _get_risk_level(risk_score: int) -> str:
"""Get risk level from score."""
if risk_score >= 70:
return 'critical'
if risk_score >= 50:
return 'high'
if risk_score >= 30:
return 'medium'
return 'low'
@router.get('', response_model=AdminWithdrawalListResponse)
async def list_withdrawals(
withdrawal_status: Literal['pending', 'approved', 'rejected', 'completed', 'cancelled'] | None = Query(
None, alias='status'
),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(require_permission('withdrawals:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all withdrawal requests."""
query = select(WithdrawalRequest)
count_query = select(func.count()).select_from(WithdrawalRequest)
if withdrawal_status:
query = query.where(WithdrawalRequest.status == withdrawal_status)
count_query = count_query.where(WithdrawalRequest.status == withdrawal_status)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Pending stats
pending_count_result = await db.execute(
select(func.count())
.select_from(WithdrawalRequest)
.where(WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value)
)
pending_count = pending_count_result.scalar() or 0
pending_total_result = await db.execute(
select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value
)
)
pending_total = pending_total_result.scalar() or 0
query = query.order_by(desc(WithdrawalRequest.created_at)).offset(offset).limit(limit)
result = await db.execute(query)
withdrawals = result.scalars().all()
# Batch-fetch users to avoid N+1
user_ids = list({w.user_id for w in withdrawals})
if user_ids:
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
else:
users_map = {}
items = []
for w in withdrawals:
user = users_map.get(w.user_id)
items.append(
AdminWithdrawalItem(
id=w.id,
user_id=w.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
amount_kopeks=w.amount_kopeks,
amount_rubles=w.amount_kopeks / 100,
status=w.status,
risk_score=w.risk_score or 0,
risk_level=_get_risk_level(w.risk_score or 0),
payment_details=w.payment_details,
admin_comment=w.admin_comment,
created_at=w.created_at,
processed_at=w.processed_at,
)
)
return AdminWithdrawalListResponse(
items=items,
total=total,
pending_count=pending_count,
pending_total_kopeks=pending_total,
)
@router.get('/{withdrawal_id}', response_model=AdminWithdrawalDetailResponse)
async def get_withdrawal_detail(
withdrawal_id: int,
admin: User = Depends(require_permission('withdrawals:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed withdrawal request with risk analysis."""
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Заявка не найдена',
)
user = await db.get(User, withdrawal.user_id)
# Parse risk analysis
risk_analysis = None
if withdrawal.risk_analysis:
try:
risk_analysis = json.loads(withdrawal.risk_analysis)
except (json.JSONDecodeError, TypeError):
pass
# Get referral stats
referral_count = await db.execute(
select(func.count()).select_from(User).where(User.referred_by_id == withdrawal.user_id)
)
total_earnings = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == withdrawal.user_id
)
)
return AdminWithdrawalDetailResponse(
id=withdrawal.id,
user_id=withdrawal.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
amount_kopeks=withdrawal.amount_kopeks,
amount_rubles=withdrawal.amount_kopeks / 100,
status=withdrawal.status,
risk_score=withdrawal.risk_score or 0,
risk_level=_get_risk_level(withdrawal.risk_score or 0),
risk_analysis=risk_analysis,
payment_details=withdrawal.payment_details,
admin_comment=withdrawal.admin_comment,
balance_kopeks=user.balance_kopeks if user else 0,
total_referrals=referral_count.scalar() or 0,
total_earnings_kopeks=total_earnings.scalar() or 0,
created_at=withdrawal.created_at,
processed_at=withdrawal.processed_at,
)
@router.post('/{withdrawal_id}/approve')
async def approve_withdrawal(
withdrawal_id: int,
request: AdminApproveWithdrawalRequest,
admin: User = Depends(require_permission('withdrawals:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a withdrawal request."""
success, error = await referral_withdrawal_service.approve_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about approval
try:
from app.bot_factory import create_bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
user = await db.get(User, withdrawal.user_id) if withdrawal else None
if user and withdrawal:
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
comment_text = f'\n{request.comment}' if request.comment else ''
tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}'
bot = create_bot()
try:
await notification_delivery_service.notify_withdrawal_approved(
user=user,
amount_kopeks=withdrawal.amount_kopeks,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send withdrawal approval notification', error=e)
return {'success': True}
@router.post('/{withdrawal_id}/reject')
async def reject_withdrawal(
withdrawal_id: int,
request: AdminRejectWithdrawalRequest,
admin: User = Depends(require_permission('withdrawals:reject')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a withdrawal request."""
success, error = await referral_withdrawal_service.reject_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error or 'Не удалось отклонить заявку',
)
# Notify user about rejection
try:
from app.bot_factory import create_bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
user = await db.get(User, withdrawal.user_id) if withdrawal else None
if user and withdrawal:
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}'
bot = create_bot()
try:
await notification_delivery_service.notify_withdrawal_rejected(
user=user,
amount_kopeks=withdrawal.amount_kopeks,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send withdrawal rejection notification', error=e)
return {'success': True}
@router.post('/{withdrawal_id}/complete')
async def complete_withdrawal(
withdrawal_id: int,
admin: User = Depends(require_permission('withdrawals:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark a withdrawal as completed (money transferred)."""
success, error = await referral_withdrawal_service.complete_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error or 'Не удалось завершить заявку',
)
return {'success': True}
+267
View File
@@ -0,0 +1,267 @@
"""Apple In-App Purchase cabinet route."""
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.apple_iap import (
create_apple_transaction,
)
from app.database.crud.transaction import create_transaction as create_trans
from app.database.crud.user import lock_user_for_update
from app.database.models import PaymentMethod, TransactionType, User
from app.external.apple_iap import AppleIAPService
from app.utils.user_utils import format_referrer_info
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.apple_iap import ApplePurchaseRequest, ApplePurchaseResponse
logger = structlog.get_logger(__name__)
router = APIRouter(tags=['Cabinet Apple IAP'])
def get_apple_iap_service() -> AppleIAPService:
return AppleIAPService()
@router.post('/apple-purchase', response_model=ApplePurchaseResponse)
async def apple_purchase(
request: ApplePurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
apple_iap_service: AppleIAPService = Depends(get_apple_iap_service),
):
"""Verify an Apple In-App Purchase and credit the user's balance.
The iOS app calls this endpoint after a successful StoreKit transaction.
If the backend returns success=false, the iOS app will NOT finish the
transaction and will retry on next launch.
"""
if not settings.is_apple_iap_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Apple In-App Purchase is not enabled',
)
# Validate product ID
products = settings.get_apple_iap_products()
if request.product_id not in products:
logger.warning(
'Unknown Apple product ID',
product_id=request.product_id,
user_id=user.id,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Unknown product ID',
)
amount_kopeks = products[request.product_id]
# Verify transaction with Apple Server API (no DB lock needed).
# verify_transaction automatically falls back Sandbox<->Production.
txn_info = await apple_iap_service.verify_transaction(request.transaction_id, settings.APPLE_IAP_ENVIRONMENT)
if not txn_info:
logger.warning(
'Apple transaction verification failed',
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=False)
# Validate transaction fields
validation_error = apple_iap_service.validate_transaction_info(txn_info, request.product_id)
if validation_error:
logger.warning(
'Apple transaction validation failed',
error=validation_error,
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=False)
# FIX 4: appAccountToken is mandatory -- reject if missing
app_account_token = txn_info.get('appAccountToken')
if not app_account_token:
logger.warning(
'Apple appAccountToken missing -- rejecting transaction',
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=False)
if app_account_token != str(user.id):
logger.warning(
'Apple appAccountToken mismatch -- possible replay',
expected=str(user.id),
received=app_account_token,
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=False)
# Detect sandbox transactions -- store actual environment from Apple's response
actual_environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
is_sandbox = actual_environment == 'Sandbox'
if is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production':
# Sandbox transaction on a production server (e.g. App Review).
# Record it for audit but do NOT credit real balance.
logger.info(
'Apple sandbox transaction on production -- storing without balance credit',
transaction_id=request.transaction_id,
product_id=request.product_id,
user_id=user.id,
)
try:
async with db.begin_nested():
await create_apple_transaction(
db=db,
user_id=user.id,
transaction_id=request.transaction_id,
original_transaction_id=txn_info.get('originalTransactionId'),
product_id=request.product_id,
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
amount_kopeks=amount_kopeks,
environment='Sandbox',
)
except IntegrityError:
pass # already stored
await db.commit()
return ApplePurchaseResponse(success=True)
# Atomically insert transaction record -- unique constraint on transaction_id
# prevents double-spend even under concurrent requests.
apple_txn = None
try:
async with db.begin_nested():
apple_txn = await create_apple_transaction(
db=db,
user_id=user.id,
transaction_id=request.transaction_id,
original_transaction_id=txn_info.get('originalTransactionId'),
product_id=request.product_id,
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
amount_kopeks=amount_kopeks,
environment=actual_environment,
)
except IntegrityError:
logger.info(
'Apple transaction already processed (idempotent)',
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=True)
# Create financial transaction record
transaction = await create_trans(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=f'Пополнение через Apple IAP: {request.product_id}',
payment_method=PaymentMethod.APPLE_IAP,
external_id=request.transaction_id,
is_completed=True,
commit=False,
)
# FIX 9: Link AppleTransaction to financial Transaction via FK
if apple_txn and transaction:
apple_txn.transaction_id_fk = transaction.id
apple_txn.updated_at = datetime.now(UTC)
# Lock user row and credit balance
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += amount_kopeks
# FIX 10: Update user.updated_at when modifying balance
user.updated_at = datetime.now(UTC)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
topup_status = 'Первое пополнение' if was_first_topup else 'Пополнение'
await db.commit()
# --- Post-payment side-effects (after atomic commit) ---
from app.database.crud.transaction import emit_transaction_side_effects
try:
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=amount_kopeks,
user_id=user.id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.APPLE_IAP,
external_id=request.transaction_id,
)
except Exception as error:
logger.error('Ошибка emit_transaction_side_effects Apple IAP', error=error)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(db, user.id, amount_kopeks, bot=None)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Apple IAP', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
# Admin notification + cart auto-purchase
try:
from app.bot_factory import create_bot
bot = create_bot()
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления Apple IAP', error=error)
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, amount_kopeks, db, bot)
except Exception as error:
logger.error('Ошибка при работе с сохраненной корзиной Apple IAP', user_id=user.id, error=error)
finally:
await bot.session.close()
except Exception as error:
logger.error('Ошибка создания бота для уведомлений Apple IAP', error=error)
logger.info(
'Apple IAP purchase credited',
transaction_id=request.transaction_id,
product_id=request.product_id,
amount_kopeks=amount_kopeks,
user_id=user.id,
)
return ApplePurchaseResponse(success=True)
+975 -205
View File
File diff suppressed because it is too large Load Diff
+575 -60
View File
@@ -4,13 +4,17 @@ import math
import time
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
import httpx
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.bot_factory import create_bot
from app.config import settings
from app.database.crud.saved_payment_method import (
deactivate_payment_method,
get_active_payment_methods_by_user,
)
from app.database.crud.user import get_user_by_id
from app.database.models import PaymentMethod, Transaction, User
from app.services.payment_method_config_service import get_enabled_methods_for_user
@@ -32,6 +36,8 @@ from ..schemas.balance import (
PaymentMethodResponse,
PendingPaymentListResponse,
PendingPaymentResponse,
SavedCardResponse,
SavedCardsListResponse,
StarsInvoiceRequest,
StarsInvoiceResponse,
TopUpRequest,
@@ -99,8 +105,8 @@ async def get_transactions(
for t in transactions:
# Determine sign based on transaction type
# Credits (positive): DEPOSIT, REFERRAL_REWARD, REFUND, POLL_REWARD
# Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL
is_debit = t.type in ['subscription_payment', 'withdrawal']
# Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL, GIFT_PAYMENT
is_debit = t.type in ['subscription_payment', 'withdrawal', 'gift_payment']
amount_kopeks = -abs(t.amount_kopeks) if is_debit else abs(t.amount_kopeks)
items.append(
@@ -195,7 +201,7 @@ async def get_payment_methods(
'description': description,
}
)
options = formatted_options if formatted_options else None
options = formatted_options or None
methods.append(
PaymentMethodResponse(
@@ -241,13 +247,16 @@ async def create_stars_invoice(
detail='Maximum amount is 10,000.00 RUB',
)
# Calculate Stars amount
# Calculate Stars amount and normalize kopeks to match exact star value
try:
amount_rubles = request.amount_kopeks / 100
stars_amount = settings.rubles_to_stars(amount_rubles)
if stars_amount <= 0:
stars_amount = 1
# Normalize kopeks so credited amount = stars * rate (no rounding mismatch)
normalized_kopeks = round(stars_amount * settings.get_stars_rate() * 100)
except Exception as e:
logger.error('Error calculating Stars amount', error=e)
raise HTTPException(
@@ -256,54 +265,41 @@ async def create_stars_invoice(
)
# Create payload for tracking payment
payload = f'balance_topup_{user.id}_{request.amount_kopeks}_{int(time.time())}'
payload = f'balance_topup_{user.id}_{normalized_kopeks}_{int(time.time())}'
# Create invoice through Telegram Bot API
try:
bot_token = settings.BOT_TOKEN
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
from aiogram.exceptions import TelegramAPIError
from aiogram.types import LabeledPrice
async with httpx.AsyncClient() as client:
response = await client.post(
api_url,
json={
'title': 'Пополнение баланса VPN',
'description': f'Пополнение баланса на {amount_rubles:.2f} ₽ ({stars_amount} ⭐)',
'payload': payload,
'provider_token': '', # Empty for Stars
'currency': 'XTR',
'prices': [{'label': 'Пополнение баланса', 'amount': stars_amount}],
},
async with create_bot() as bot:
invoice_url = await bot.create_invoice_link(
title='Пополнение баланса VPN',
description=f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
payload=payload,
provider_token='',
currency='XTR',
prices=[LabeledPrice(label='Пополнение баланса', amount=stars_amount)],
)
result = response.json()
logger.info(
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
user_id=user.id,
amount_kopeks=request.amount_kopeks,
stars_amount=stars_amount,
)
if not result.get('ok'):
logger.error('Telegram API error', result=result)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Stars invoice',
)
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
amount_kopeks=normalized_kopeks,
)
invoice_url = result['result']
logger.info(
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
user_id=user.id,
amount_kopeks=request.amount_kopeks,
stars_amount=stars_amount,
)
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
amount_kopeks=request.amount_kopeks,
)
except httpx.HTTPError as e:
logger.error('HTTP error creating Stars invoice', error=e)
except TelegramAPIError as e:
logger.error('Error creating Stars invoice', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to connect to Telegram API',
detail='Failed to create Stars invoice',
)
@@ -314,6 +310,12 @@ async def create_topup(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create payment for balance top-up."""
if getattr(user, 'restriction_topup', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Balance top-up is restricted for this account',
)
# Validate payment method
methods = await get_payment_methods(user=user, db=db)
method = next((m for m in methods if m.id == request.payment_method), None)
@@ -340,6 +342,9 @@ async def create_topup(
amount_rubles = request.amount_kopeks / 100
payment_url = None
payment_id = None
cabinet_return_url = f'{settings.CABINET_URL.rstrip("/")}/balance/top-up/result?method={request.payment_method}'
cabinet_success_url = f'{cabinet_return_url}&status=success'
cabinet_failed_url = f'{cabinet_return_url}&status=failed'
try:
if request.payment_method == 'yookassa':
@@ -355,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(
@@ -364,6 +369,7 @@ async def create_topup(
amount_kopeks=request.amount_kopeks,
description=description,
metadata=yookassa_metadata,
return_url=cabinet_return_url,
)
else:
result = await payment_service.create_yookassa_payment(
@@ -372,11 +378,12 @@ async def create_topup(
amount_kopeks=request.amount_kopeks,
description=description,
metadata=yookassa_metadata,
return_url=cabinet_return_url,
)
if result:
payment_url = result.get('confirmation_url')
payment_id = result.get('yookassa_payment_id')
payment_id = str(result.get('local_payment_id') or result.get('yookassa_payment_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -416,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}',
)
@@ -477,10 +484,12 @@ 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,
return_url=cabinet_success_url,
failed_url=cabinet_failed_url,
)
if result and result.get('redirect_url'):
@@ -504,8 +513,12 @@ 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,
)
if result and result.get('payment_url'):
@@ -529,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,
)
@@ -553,16 +568,17 @@ async def create_topup(
option = (request.payment_option or '').strip().lower()
if option not in {'card', 'sbp'}:
option = 'sbp'
provider_method = 'card' if option == 'card' else 'sbp'
payment_service = PaymentService()
result = await payment_service.create_pal24_payment(
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=provider_method,
payment_method=option,
)
if result:
@@ -601,8 +617,12 @@ 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,
)
if result and result.get('payment_url'):
@@ -626,9 +646,13 @@ 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,
failed_url=cabinet_failed_url,
)
if result and result.get('payment_url'):
@@ -652,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,
)
@@ -672,14 +698,22 @@ async def create_topup(
detail='KassaAI payment method is unavailable',
)
# Use payment_option to select sbp or card
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
payment_service = PaymentService()
result = await payment_service.create_kassa_ai_payment(
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,
)
if result and result.get('payment_url'):
@@ -691,6 +725,35 @@ async def create_topup(
detail='Failed to create KassaAI payment',
)
elif request.payment_method == 'riopay':
if not settings.is_riopay_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='RioPay payment method is unavailable',
)
payment_service = PaymentService()
result = await payment_service.create_riopay_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
),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
success_url=cabinet_success_url,
fail_url=cabinet_failed_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('riopay_order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create RioPay payment',
)
elif request.payment_method == 'tribute':
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
raise HTTPException(
@@ -702,6 +765,248 @@ 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',
)
elif request.payment_method == 'paypear':
if not settings.is_paypear_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='PayPear payment method is unavailable',
)
payment_service = PaymentService()
result = await payment_service.create_paypear_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 PayPear payment',
)
elif request.payment_method == 'rollypay':
if not settings.is_rollypay_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='RollyPay payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_rollypay_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,
payment_method_type=payment_method_type,
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 RollyPay payment',
)
elif request.payment_method == 'overpay':
if not settings.is_overpay_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Overpay payment method is unavailable',
)
payment_service = PaymentService()
result = await payment_service.create_overpay_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 Overpay payment',
)
elif request.payment_method == 'aurapay':
if not settings.is_aurapay_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='AuraPay payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_aurapay_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,
payment_method_type=payment_method_type,
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 AuraPay payment',
)
elif request.payment_method == 'jupiter':
if not settings.is_jupiter_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Jupiter payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_jupiter_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,
payment_method_type=payment_method_type,
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 Jupiter payment',
)
elif request.payment_method == 'donut':
if not settings.is_donut_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Donut payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_donut_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,
payment_method_type=payment_method_type,
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 Donut payment',
)
elif request.payment_method == 'lava':
if not settings.is_lava_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Lava payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_lava_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,
payment_method_type=payment_method_type,
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 Lava payment',
)
else:
# For other payment methods, redirect to bot
raise HTTPException(
@@ -842,6 +1147,56 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.RIOPAY:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'success': ('', 'Оплачено'),
'failed': ('', 'Ошибка'),
'canceled': ('', 'Отменено'),
'expired': ('', 'Истёк'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.JUPITER:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'processing': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'cancelled': ('', 'Отменено'),
'declined': ('', 'Отклонено'),
'error': ('', 'Ошибка'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.DONUT:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'created': ('', 'Создано'),
'processing': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'cancelled': ('', 'Отменено'),
'error': ('', 'Ошибка'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.LAVA:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'created': ('', 'Создано'),
'processing': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'cancel': ('', 'Отменено'),
'cancelled': ('', 'Отменено'),
'expired': ('', 'Истёк'),
'failed': ('', 'Ошибка'),
'error': ('', 'Ошибка'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
return '', 'Неизвестно'
@@ -865,13 +1220,15 @@ def _is_checkable(record: PendingPayment) -> bool:
if record.method == PaymentMethod.YOOKASSA:
return status in {'pending', 'waiting_for_capture'}
if record.method == PaymentMethod.CRYPTOBOT:
return status in {'active'}
return status == 'active'
if record.method == PaymentMethod.CLOUDPAYMENTS:
return status in {'pending', 'authorized'}
if record.method == PaymentMethod.FREEKASSA:
return status in {'pending', 'created', 'processing'}
if record.method == PaymentMethod.KASSA_AI:
return status in {'pending', 'created', 'processing'}
if record.method == PaymentMethod.RIOPAY:
return status in {'pending'}
return False
@@ -895,7 +1252,12 @@ def _get_payment_url(record: PendingPayment) -> str | None:
)
elif record.method == PaymentMethod.PLATEGA:
payment_url = getattr(payment, 'redirect_url', None) or payment_url
elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI):
elif record.method in (
PaymentMethod.CLOUDPAYMENTS,
PaymentMethod.FREEKASSA,
PaymentMethod.KASSA_AI,
PaymentMethod.RIOPAY,
):
payment_url = getattr(payment, 'payment_url', None) or payment_url
return payment_url
@@ -956,6 +1318,103 @@ async def get_pending_payments(
)
@router.get('/pending-payments/{method}/latest', response_model=PendingPaymentResponse)
async def get_latest_payment_by_method(
method: str,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's most recent payment for a given method (any status, not just pending)."""
try:
payment_method = PaymentMethod(method)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid payment method: {method}',
)
from datetime import UTC, datetime, timedelta
from sqlalchemy.orm import selectinload
from app.database.models import (
AuraPayPayment,
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
OverpayPayment,
Pal24Payment,
PayPearPayment,
PlategaPayment,
RioPayPayment,
RollyPayPayment,
SeverPayPayment,
WataPayment,
YooKassaPayment,
)
model_map: dict[PaymentMethod, type] = {
PaymentMethod.YOOKASSA: YooKassaPayment,
PaymentMethod.CRYPTOBOT: CryptoBotPayment,
PaymentMethod.HELEKET: HeleketPayment,
PaymentMethod.MULENPAY: MulenPayPayment,
PaymentMethod.PAL24: Pal24Payment,
PaymentMethod.WATA: WataPayment,
PaymentMethod.PLATEGA: PlategaPayment,
PaymentMethod.CLOUDPAYMENTS: CloudPaymentsPayment,
PaymentMethod.FREEKASSA: FreekassaPayment,
PaymentMethod.KASSA_AI: KassaAiPayment,
PaymentMethod.RIOPAY: RioPayPayment,
PaymentMethod.SEVERPAY: SeverPayPayment,
PaymentMethod.ROLLYPAY: RollyPayPayment,
PaymentMethod.PAYPEAR: PayPearPayment,
PaymentMethod.OVERPAY: OverpayPayment,
PaymentMethod.AURAPAY: AuraPayPayment,
}
model = model_map.get(payment_method)
if not model:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Unsupported payment method: {method}',
)
cutoff = datetime.now(UTC) - timedelta(hours=1)
stmt = (
select(model)
.options(selectinload(model.user))
.where(model.user_id == user.id, model.created_at >= cutoff)
.order_by(desc(model.created_at))
.limit(1)
)
result = await db.execute(stmt)
payment = result.scalars().first()
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No recent payments found',
)
record = PendingPayment(
local_id=payment.id,
method=payment_method,
identifier=str(getattr(payment, 'correlation_id', None) or payment.id),
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
created_at=payment.created_at,
expires_at=getattr(payment, 'expires_at', None),
user=payment.user,
payment=payment,
)
return _record_to_response(record)
@router.get('/pending-payments/{method}/{payment_id}', response_model=PendingPaymentResponse)
async def get_pending_payment_details(
method: str,
@@ -1035,8 +1494,12 @@ async def check_payment_status(
old_is_paid = record.is_paid
# Run manual check
payment_service = PaymentService()
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
bot = create_bot()
try:
payment_service = PaymentService(bot=bot)
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
finally:
await bot.session.close()
if not updated:
return ManualCheckResponse(
@@ -1062,3 +1525,55 @@ async def check_payment_status(
old_status=old_status,
new_status=updated.status,
)
@router.get('/saved-cards', response_model=SavedCardsListResponse)
async def get_saved_cards(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's saved payment methods (cards) for recurrent payments."""
recurrent_enabled = settings.YOOKASSA_RECURRENT_ENABLED
if not recurrent_enabled:
return SavedCardsListResponse(cards=[], recurrent_enabled=False)
methods = await get_active_payment_methods_by_user(db, user.id)
cards = [
SavedCardResponse(
id=m.id,
method_type=m.method_type,
card_last4=m.card_last4,
card_type=m.card_type,
title=m.title,
created_at=m.created_at,
)
for m in methods
]
return SavedCardsListResponse(cards=cards, recurrent_enabled=True)
@router.delete('/saved-cards/{card_id}', status_code=status.HTTP_200_OK)
async def delete_saved_card(
card_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unlink (deactivate) a saved payment method."""
if not settings.YOOKASSA_RECURRENT_ENABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Recurrent payments are not enabled',
)
success = await deactivate_payment_method(db, card_id, user.id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Saved card not found',
)
return {'success': True, 'message': 'Card unlinked successfully'}
+369 -30
View File
@@ -1,20 +1,23 @@
"""Branding routes for cabinet - logo, project name, and theme colors management."""
import asyncio
import json
import os
from pathlib import Path
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.system_setting import get_setting_value
from app.database.models import SystemSetting, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, get_current_cabinet_user, require_permission
logger = structlog.get_logger(__name__)
@@ -37,6 +40,24 @@ YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeri
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
GIFT_ENABLED_KEY = 'CABINET_GIFT_ENABLED' # Stores "true" or "false"
ANIMATION_CONFIG_KEY = 'CABINET_ANIMATION_CONFIG' # Stores JSON with animation config
TELEGRAM_WIDGET_SIZE_KEY = 'TELEGRAM_WIDGET_SIZE'
TELEGRAM_WIDGET_RADIUS_KEY = 'TELEGRAM_WIDGET_RADIUS'
TELEGRAM_WIDGET_USERPIC_KEY = 'TELEGRAM_WIDGET_USERPIC'
TELEGRAM_WIDGET_REQUEST_ACCESS_KEY = 'TELEGRAM_WIDGET_REQUEST_ACCESS'
TELEGRAM_OIDC_ENABLED_KEY = 'TELEGRAM_OIDC_ENABLED'
TELEGRAM_OIDC_CLIENT_ID_KEY = 'TELEGRAM_OIDC_CLIENT_ID'
# Default animation config
DEFAULT_ANIMATION_CONFIG = {
'enabled': True,
'type': 'aurora',
'settings': {},
'opacity': 1.0,
'blur': 0,
'reducedOnMobile': True,
}
# Allowed image types
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
@@ -121,6 +142,92 @@ class AnimationEnabledUpdate(BaseModel):
enabled: bool
ALLOWED_BG_TYPES = (
'aurora',
'sparkles',
'vortex',
'shooting-stars',
'background-beams',
'background-beams-collision',
'gradient-animation',
'wavy',
'background-lines',
'boxes',
'meteors',
'grid',
'dots',
'spotlight',
'ripple',
'none',
)
MAX_SETTINGS_KEYS = 20
MAX_SETTINGS_VALUE_LEN = 200
def _validate_settings(v: dict) -> dict:
"""Validate settings dict: flat structure, bounded size, no nested objects."""
if len(v) > MAX_SETTINGS_KEYS:
raise ValueError(f'Settings must have at most {MAX_SETTINGS_KEYS} keys')
for key, val in v.items():
if not isinstance(key, str) or len(key) > 50:
raise ValueError('Setting keys must be strings under 50 characters')
if isinstance(val, dict | list):
raise ValueError('Nested objects/arrays not allowed in settings')
if isinstance(val, str) and len(val) > MAX_SETTINGS_VALUE_LEN:
raise ValueError(f'String setting values must be under {MAX_SETTINGS_VALUE_LEN} characters')
return v
class AnimationConfigResponse(BaseModel):
"""Full animation config."""
enabled: bool = True
type: str = 'aurora'
settings: dict = Field(default_factory=dict)
opacity: float = Field(default=1.0, ge=0.0, le=1.0)
blur: float = Field(default=0, ge=0, le=100)
reducedOnMobile: bool = True
class AnimationConfigUpdate(BaseModel):
"""Request to update animation config (partial update)."""
enabled: bool | None = None
type: (
Literal[
'aurora',
'sparkles',
'vortex',
'shooting-stars',
'background-beams',
'background-beams-collision',
'gradient-animation',
'wavy',
'background-lines',
'boxes',
'meteors',
'grid',
'dots',
'spotlight',
'ripple',
'none',
]
| None
) = None
settings: dict | None = None
opacity: float | None = Field(default=None, ge=0.0, le=1.0)
blur: float | None = Field(default=None, ge=0, le=100)
reducedOnMobile: bool | None = None
@field_validator('settings')
@classmethod
def validate_settings(cls, v: dict | None) -> dict | None:
if v is None:
return v
return _validate_settings(v)
class FullscreenEnabledResponse(BaseModel):
"""Fullscreen enabled setting."""
@@ -137,6 +244,7 @@ class EmailAuthEnabledResponse(BaseModel):
"""Email auth enabled setting."""
enabled: bool = True
verification_enabled: bool = True
class EmailAuthEnabledUpdate(BaseModel):
@@ -145,6 +253,20 @@ class EmailAuthEnabledUpdate(BaseModel):
enabled: bool
class TelegramWidgetConfigResponse(BaseModel):
"""Public Telegram Login Widget configuration."""
bot_username: str
size: Literal['large', 'medium', 'small'] = 'large'
radius: int = Field(default=8, ge=0, le=20)
userpic: bool = True
request_access: bool = True
# OIDC fields (frontend decides which flow to use)
oidc_enabled: bool = False
oidc_client_id: str = ''
class LiteModeEnabledResponse(BaseModel):
"""Lite mode enabled setting."""
@@ -157,12 +279,36 @@ class LiteModeEnabledUpdate(BaseModel):
enabled: bool
class GiftEnabledResponse(BaseModel):
"""Gift feature enabled setting."""
enabled: bool = False
class GiftEnabledUpdate(BaseModel):
"""Request to update gift feature setting."""
enabled: bool
class OfflineConvGoal(BaseModel):
"""Yandex Metrika offline conversion goal descriptor."""
name: str
event_id: str
dedup: str
class AnalyticsCountersResponse(BaseModel):
"""Analytics counter settings."""
yandex_metrika_id: str = ''
google_ads_id: str = ''
google_ads_label: str = ''
offline_conv_enabled: bool = False
offline_conv_counter_id: str = ''
offline_conv_measurement_secret_masked: str = ''
offline_conv_goals: list[OfflineConvGoal] = []
class AnalyticsCountersUpdate(BaseModel):
@@ -198,13 +344,6 @@ def ensure_branding_dir():
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
async def get_setting_value(db: AsyncSession, key: str) -> str | None:
"""Get a setting value from database."""
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def set_setting_value(db: AsyncSession, key: str, value: str):
"""Set a setting value in database."""
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
@@ -276,7 +415,7 @@ async def get_logo():
"""
logo_path = get_logo_path()
if logo_path is None or not logo_path.exists():
if logo_path is None or not await asyncio.to_thread(logo_path.exists):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='No custom logo set')
# Determine media type from file extension
@@ -296,7 +435,7 @@ async def get_logo():
@router.put('/name', response_model=BrandingResponse)
async def update_branding_name(
payload: BrandingNameUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update the project name. Admin only. Empty name allowed (logo only mode)."""
@@ -324,7 +463,7 @@ async def update_branding_name(
@router.post('/logo', response_model=BrandingResponse)
async def upload_logo(
file: UploadFile = File(...),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Upload a custom logo. Admin only."""
@@ -345,7 +484,7 @@ async def upload_logo(
)
# Ensure directory exists
ensure_branding_dir()
await asyncio.to_thread(ensure_branding_dir)
# Determine file extension from content type
ext_map = {
@@ -358,12 +497,12 @@ async def upload_logo(
extension = ext_map.get(file.content_type, '.png')
# Remove old logo files with any extension
for old_file in BRANDING_DIR.glob('logo.*'):
old_file.unlink()
for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
await asyncio.to_thread(old_file.unlink)
# Save new logo
logo_path = BRANDING_DIR / f'logo{extension}'
logo_path.write_bytes(content)
await asyncio.to_thread(logo_path.write_bytes, content)
# Mark that we have a custom logo
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
@@ -387,13 +526,13 @@ async def upload_logo(
@router.delete('/logo', response_model=BrandingResponse)
async def delete_logo(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete custom logo and revert to letter. Admin only."""
# Remove logo files
for old_file in BRANDING_DIR.glob('logo.*'):
old_file.unlink()
for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
await asyncio.to_thread(old_file.unlink)
# Update setting
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
@@ -459,7 +598,7 @@ async def get_theme_colors(
@router.patch('/colors', response_model=ThemeColorsResponse)
async def update_theme_colors(
payload: ThemeColorsUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update theme colors. Admin only. Partial update supported."""
@@ -493,7 +632,7 @@ async def update_theme_colors(
@router.post('/colors/reset', response_model=ThemeColorsResponse)
async def reset_theme_colors(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset theme colors to defaults. Admin only."""
@@ -533,7 +672,7 @@ async def get_enabled_themes(
@router.patch('/themes', response_model=EnabledThemesResponse)
async def update_enabled_themes(
payload: EnabledThemesUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update which themes are enabled. Admin only. At least one theme must be enabled."""
@@ -587,7 +726,7 @@ async def get_animation_enabled(
@router.patch('/animation', response_model=AnimationEnabledResponse)
async def update_animation_enabled(
payload: AnimationEnabledUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update animation enabled setting. Admin only."""
@@ -598,6 +737,69 @@ async def update_animation_enabled(
return AnimationEnabledResponse(enabled=payload.enabled)
# ============ Animation Config Routes (new JSON-based) ============
@router.get('/animation-config', response_model=AnimationConfigResponse)
async def get_animation_config(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get full animation config. Public endpoint."""
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
if config_value is not None:
try:
config = json.loads(config_value)
return AnimationConfigResponse(**config)
except (json.JSONDecodeError, TypeError):
pass
# Auto-migrate from old ANIMATION_ENABLED_KEY
old_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
if old_value is not None:
config = {**DEFAULT_ANIMATION_CONFIG, 'enabled': old_value.lower() == 'true'}
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(config))
return AnimationConfigResponse(**config)
return AnimationConfigResponse(**DEFAULT_ANIMATION_CONFIG)
@router.patch('/animation-config', response_model=AnimationConfigResponse)
async def update_animation_config(
payload: AnimationConfigUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update animation config (partial update). Admin only."""
# Get current config
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
if config_value:
try:
current = json.loads(config_value)
except (json.JSONDecodeError, TypeError):
current = dict(DEFAULT_ANIMATION_CONFIG)
else:
current = dict(DEFAULT_ANIMATION_CONFIG)
# Merge only provided fields
update_data = payload.model_dump(exclude_none=True)
current.update(update_data)
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(current))
# Also sync old key for backwards compat
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(current.get('enabled', True)).lower())
logger.info(
'Admin updated animation config',
telegram_id=admin.telegram_id,
type=current.get('type'),
enabled=current.get('enabled'),
)
return AnimationConfigResponse(**current)
# ============ Fullscreen Routes ============
@@ -622,7 +824,7 @@ async def get_fullscreen_enabled(
@router.patch('/fullscreen', response_model=FullscreenEnabledResponse)
async def update_fullscreen_enabled(
payload: FullscreenEnabledUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update fullscreen enabled setting. Admin only."""
@@ -649,16 +851,22 @@ async def get_email_auth_enabled(
if email_auth_value is not None:
enabled = email_auth_value.lower() == 'true'
return EmailAuthEnabledResponse(enabled=enabled)
return EmailAuthEnabledResponse(
enabled=enabled,
verification_enabled=settings.is_cabinet_email_verification_enabled(),
)
# Default: check config setting
return EmailAuthEnabledResponse(enabled=settings.is_cabinet_email_auth_enabled())
return EmailAuthEnabledResponse(
enabled=settings.is_cabinet_email_auth_enabled(),
verification_enabled=settings.is_cabinet_email_verification_enabled(),
)
@router.patch('/email-auth', response_model=EmailAuthEnabledResponse)
async def update_email_auth_enabled(
payload: EmailAuthEnabledUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update email auth enabled setting. Admin only."""
@@ -666,7 +874,51 @@ async def update_email_auth_enabled(
logger.info('Admin set email auth enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return EmailAuthEnabledResponse(enabled=payload.enabled)
return EmailAuthEnabledResponse(
enabled=payload.enabled,
verification_enabled=settings.is_cabinet_email_verification_enabled(),
)
# ============ Telegram Widget Config Routes ============
@router.get('/telegram-widget', response_model=TelegramWidgetConfigResponse)
async def get_telegram_widget_config(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get Telegram Login Widget configuration.
This is a public endpoint - no authentication required.
Returns widget display settings and bot username for the login page.
"""
bot_username = settings.BOT_USERNAME or ''
size_val = await get_setting_value(db, TELEGRAM_WIDGET_SIZE_KEY)
radius_val = await get_setting_value(db, TELEGRAM_WIDGET_RADIUS_KEY)
userpic_val = await get_setting_value(db, TELEGRAM_WIDGET_USERPIC_KEY)
request_access_val = await get_setting_value(db, TELEGRAM_WIDGET_REQUEST_ACCESS_KEY)
oidc_enabled_val = await get_setting_value(db, TELEGRAM_OIDC_ENABLED_KEY)
oidc_client_id_val = await get_setting_value(db, TELEGRAM_OIDC_CLIENT_ID_KEY)
oidc_client_id = oidc_client_id_val or settings.TELEGRAM_OIDC_CLIENT_ID
oidc_enabled = (
oidc_enabled_val.lower() == 'true' if oidc_enabled_val is not None else settings.TELEGRAM_OIDC_ENABLED
) and bool(oidc_client_id)
return TelegramWidgetConfigResponse(
bot_username=bot_username,
size=size_val if size_val in ('large', 'medium', 'small') else settings.TELEGRAM_WIDGET_SIZE,
radius=max(0, min(int(radius_val), 20))
if radius_val and radius_val.isdigit()
else settings.TELEGRAM_WIDGET_RADIUS,
userpic=userpic_val.lower() == 'true' if userpic_val is not None else settings.TELEGRAM_WIDGET_USERPIC,
request_access=request_access_val.lower() == 'true'
if request_access_val is not None
else settings.TELEGRAM_WIDGET_REQUEST_ACCESS,
oidc_enabled=oidc_enabled,
oidc_client_id=oidc_client_id if oidc_enabled else '',
)
# ============ Analytics Counters Routes ============
@@ -684,17 +936,34 @@ async def get_analytics_counters(
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
# Yandex Metrika offline conversions snapshot from Settings
oc_enabled = bool(getattr(settings, 'YANDEX_OFFLINE_CONV_ENABLED', False))
oc_counter = str(getattr(settings, 'YANDEX_OFFLINE_CONV_COUNTER_ID', '') or '')
oc_secret = str(getattr(settings, 'YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET', '') or '')
oc_secret_masked = ('*' * 8 + oc_secret[-4:]) if len(oc_secret) > 4 else ('***' if oc_secret else '')
oc_goals: list[OfflineConvGoal] = []
if oc_enabled:
oc_goals = [
OfflineConvGoal(name='Registration', event_id='registration', dedup='user_id'),
OfflineConvGoal(name='Trial', event_id='trial-add', dedup='user_id'),
OfflineConvGoal(name='Purchase', event_id='purchase', dedup='order_id'),
]
return AnalyticsCountersResponse(
yandex_metrika_id=yandex_id,
google_ads_id=google_id,
google_ads_label=google_label,
offline_conv_enabled=oc_enabled,
offline_conv_counter_id=oc_counter,
offline_conv_measurement_secret_masked=oc_secret_masked,
offline_conv_goals=oc_goals,
)
@router.patch('/analytics', response_model=AnalyticsCountersResponse)
async def update_analytics_counters(
payload: AnalyticsCountersUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update analytics counter settings. Admin only. Partial update supported."""
@@ -726,13 +995,56 @@ async def update_analytics_counters(
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
oc_enabled = bool(getattr(settings, 'YANDEX_OFFLINE_CONV_ENABLED', False))
oc_counter = str(getattr(settings, 'YANDEX_OFFLINE_CONV_COUNTER_ID', '') or '')
oc_secret = str(getattr(settings, 'YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET', '') or '')
oc_secret_masked = ('*' * 8 + oc_secret[-4:]) if len(oc_secret) > 4 else ('***' if oc_secret else '')
oc_goals: list[OfflineConvGoal] = []
if oc_enabled:
oc_goals = [
OfflineConvGoal(name='Registration', event_id='registration', dedup='user_id'),
OfflineConvGoal(name='Trial', event_id='trial-add', dedup='user_id'),
OfflineConvGoal(name='Purchase', event_id='purchase', dedup='order_id'),
]
return AnalyticsCountersResponse(
yandex_metrika_id=yandex_id,
google_ads_id=google_id,
google_ads_label=google_label,
offline_conv_enabled=oc_enabled,
offline_conv_counter_id=oc_counter,
offline_conv_measurement_secret_masked=oc_secret_masked,
offline_conv_goals=oc_goals,
)
# ============ Yandex CID Sync ============
class YandexCidRequest(BaseModel):
cid: str = Field(max_length=128, pattern=r'^[A-Za-z0-9._:-]{4,128}$')
@router.post('/analytics/yandex-cid', status_code=204)
async def store_yandex_cid(
body: YandexCidRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Store Yandex Metrika ClientID for the authenticated cabinet user."""
try:
from app.services import yandex_offline_conv_service as yandex_conv
await yandex_conv.store_cid(db, user.id, body.cid, source='cabinet')
await db.commit()
except Exception as exc:
logger.warning('Failed to store yandex_cid', user_id=user.id, exc=str(exc))
try:
await db.rollback()
except Exception:
pass
# ============ Lite Mode Routes ============
@@ -758,7 +1070,7 @@ async def get_lite_mode_enabled(
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
async def update_lite_mode_enabled(
payload: LiteModeEnabledUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update lite mode enabled setting. Admin only."""
@@ -767,3 +1079,30 @@ async def update_lite_mode_enabled(
logger.info('Admin set lite mode enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return LiteModeEnabledResponse(enabled=payload.enabled)
# ============ Gift Feature Routes ============
@router.get('/gift-enabled', response_model=GiftEnabledResponse)
async def get_gift_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get gift feature enabled setting. Public endpoint."""
value = await get_setting_value(db, GIFT_ENABLED_KEY)
if value is not None:
enabled = value.lower() == 'true'
return GiftEnabledResponse(enabled=enabled)
return GiftEnabledResponse(enabled=False)
@router.patch('/gift-enabled', response_model=GiftEnabledResponse)
async def update_gift_enabled(
payload: GiftEnabledUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update gift feature enabled setting. Admin only."""
await set_setting_value(db, GIFT_ENABLED_KEY, str(payload.enabled).lower())
logger.info('Admin set gift enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return GiftEnabledResponse(enabled=payload.enabled)
+25 -7
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,
@@ -86,6 +100,7 @@ def _user_allowed(subscription) -> bool:
return subscription.status in {
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
SubscriptionStatus.LIMITED.value,
}
@@ -97,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'
@@ -121,7 +136,10 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
if not user:
return 'Error: user not found'
user.balance += amount
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
user.balance_kopeks += int(round(amount * 100))
await db.commit()
await db.refresh(user)
@@ -147,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)
@@ -179,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(
@@ -226,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(
@@ -346,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(
+806
View File
@@ -0,0 +1,806 @@
"""Gift subscription routes for cabinet."""
import asyncio
import re
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.system_setting import get_setting_value
from app.database.crud.tariff import get_tariff_by_id
from app.database.crud.transaction import create_transaction, emit_transaction_side_effects
from app.database.crud.user import subtract_user_balance
from app.database.models import (
GuestPurchase,
GuestPurchaseStatus,
PaymentMethod,
Tariff,
TransactionType,
User,
)
from app.services.guest_purchase_service import (
GuestPurchaseError,
create_purchase,
fulfill_purchase,
)
from app.services.payment_method_config_service import get_enabled_methods_for_user
from app.utils.cache import RateLimitCache
from app.utils.promo_offer import get_user_active_promo_discount_percent
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.gift import (
ActivateGiftRequest,
ActivateGiftResponse,
GiftConfigPaymentMethod,
GiftConfigResponse,
GiftConfigSubOption,
GiftConfigTariff,
GiftConfigTariffPeriod,
GiftPurchaseRequest,
GiftPurchaseResponse,
GiftPurchaseStatusResponse,
PendingGiftResponse,
ReceivedGiftResponse,
SentGiftResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/gift', tags=['Cabinet Gift'])
GIFT_ENABLED_KEY = 'CABINET_GIFT_ENABLED'
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{4,31}$')
async def _is_gift_enabled(db: AsyncSession) -> bool:
"""Check if the gift feature is enabled via system settings."""
value = await get_setting_value(db, GIFT_ENABLED_KEY)
if value is not None:
return value.lower() == 'true'
return False
@router.get('/config', response_model=GiftConfigResponse)
async def get_gift_config(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get gift subscription configuration: tariffs, payment methods, balance."""
enabled = await _is_gift_enabled(db)
if not enabled:
return GiftConfigResponse(
is_enabled=False,
balance_kopeks=user.balance_kopeks,
)
# Load active tariffs visible in gift section
result = await db.execute(
select(Tariff)
.where(Tariff.is_active.is_(True), Tariff.show_in_gift.is_(True))
.order_by(Tariff.display_order, Tariff.id)
)
tariffs_db = result.scalars().all()
# Get user's promo group for discount calculation
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_name = promo_group.name if promo_group else None
# Get active promo offer discount
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
tariffs: list[GiftConfigTariff] = []
for tariff in tariffs_db:
period_days_list = tariff.get_available_periods()
periods: list[GiftConfigTariffPeriod] = []
for days in period_days_list:
base_price = tariff.get_price_for_period(days)
if base_price is None:
continue
original_price = base_price
price = base_price
# Apply promo group discount
from app.services.pricing_engine import PricingEngine
promo_group_discount = 0
if promo_group:
promo_group_discount = promo_group.get_discount_percent('period', days)
if promo_group_discount > 0:
price = PricingEngine.apply_discount(price, promo_group_discount)
# Apply active promo offer discount (stacks on top)
if promo_offer_discount_percent > 0:
price = PricingEngine.apply_discount(price, promo_offer_discount_percent)
# Ensure minimum price of 1 kopek after all discounts
price = max(1, price)
# Calculate combined discount percent
combined_discount = 0
if original_price > 0 and original_price != price:
combined_discount = int((original_price - price) * 100 / original_price)
periods.append(
GiftConfigTariffPeriod(
days=days,
price_kopeks=price,
price_label=settings.format_price(price),
original_price_kopeks=original_price if combined_discount > 0 else None,
discount_percent=combined_discount if combined_discount > 0 else None,
)
)
if not periods:
continue
tariffs.append(
GiftConfigTariff(
id=tariff.id,
name=tariff.name,
description=tariff.description,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
periods=periods,
)
)
# Load payment methods available for this user
enabled_methods = await get_enabled_methods_for_user(db, user=user)
payment_methods: list[GiftConfigPaymentMethod] = []
for method_data in enabled_methods:
sub_options = None
raw_options = method_data.get('options')
if raw_options:
sub_options = [GiftConfigSubOption(id=opt['id'], name=opt.get('name', opt['id'])) for opt in raw_options]
payment_methods.append(
GiftConfigPaymentMethod(
method_id=method_data['id'],
display_name=method_data['name'],
min_amount_kopeks=method_data.get('min_amount_kopeks'),
max_amount_kopeks=method_data.get('max_amount_kopeks'),
sub_options=sub_options,
)
)
return GiftConfigResponse(
is_enabled=True,
tariffs=tariffs,
payment_methods=payment_methods,
balance_kopeks=user.balance_kopeks,
currency_symbol=getattr(settings, 'CURRENCY_SYMBOL', '\u20bd'),
promo_group_name=promo_group_name,
active_discount_percent=promo_offer_discount_percent if promo_offer_discount_percent > 0 else None,
active_discount_expires_at=(
getattr(user, 'promo_offer_discount_expires_at', None) if promo_offer_discount_percent > 0 else None
),
)
@router.post('/purchase', response_model=GiftPurchaseResponse)
async def create_gift_purchase(
body: GiftPurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a gift subscription purchase from the cabinet."""
enabled = await _is_gift_enabled(db)
if not enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Gift feature is not enabled',
)
# Rate limit: 5 gift purchases per 60 seconds per user
is_limited = await RateLimitCache.is_rate_limited(user.id, 'gift_purchase', limit=5, window=60)
if is_limited:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
# Check if user has purchase restrictions
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Purchases are restricted for this account',
)
# Recipient is optional — when omitted, buyer gets a code to share manually
has_recipient = bool(body.recipient_type and body.recipient_value)
if has_recipient:
# Validate recipient format
if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid email format',
)
if body.recipient_type == 'telegram' and not _TELEGRAM_RE.match(body.recipient_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid Telegram username format',
)
# Prevent self-gift
if body.recipient_type == 'telegram':
normalized_recipient = body.recipient_value.lstrip('@').lower()
if user.username and user.username.lower() == normalized_recipient:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot gift to yourself',
)
elif body.recipient_type == 'email':
if user.email and user.email.lower() == body.recipient_value.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot gift to yourself',
)
# Find tariff and validate period
tariff = await get_tariff_by_id(db, body.tariff_id)
if tariff is None or not tariff.is_active or not tariff.show_in_gift:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found or inactive',
)
# Validate that period has a configured price before locking
if tariff.get_price_for_period(body.period_days) is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Price is not configured for this period',
)
# 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)
from app.services.pricing_engine import pricing_engine
pricing_result = await pricing_engine.calculate_tariff_purchase_price(
tariff,
body.period_days,
device_limit=tariff.device_limit,
user=user,
)
price_kopeks = max(1, pricing_result.final_total)
consume_promo = pricing_result.promo_offer_discount > 0
# Determine buyer contact info
if user.email:
buyer_contact_type = 'email'
buyer_contact_value = user.email
elif user.username:
buyer_contact_type = 'telegram'
buyer_contact_value = f'@{user.username}'
else:
buyer_contact_type = 'telegram'
buyer_contact_value = f'id:{user.telegram_id or user.id}'
# Pre-check: try to resolve Telegram username — DB first, then Bot API.
# Only relevant when a recipient is explicitly specified.
recipient_warning: str | None = None
pre_resolved_telegram_id: int | None = None
if has_recipient and body.recipient_type == 'telegram':
tg_username = body.recipient_value.lstrip('@')
normalized_username = tg_username.lower()
# 1) Check local DB — user may already be registered in the bot
db_result = await db.execute(
select(User.telegram_id).where(
func.lower(User.username) == normalized_username,
User.telegram_id.isnot(None),
)
)
db_telegram_id = db_result.scalar_one_or_none()
if db_telegram_id is not None:
pre_resolved_telegram_id = db_telegram_id
else:
# 2) Fall back to Bot API (works for public usernames the bot has seen)
try:
from app.bot_factory import create_bot
async with create_bot() as bot:
chat = await asyncio.wait_for(bot.get_chat(chat_id=f'@{tg_username}'), timeout=5.0)
pre_resolved_telegram_id = chat.id
except Exception:
recipient_warning = 'telegram_unresolvable'
logger.warning(
'Telegram username not resolvable for gift',
username=tg_username,
buyer_id=user.id,
)
# Gateway mode: create payment via external provider
if body.payment_mode == 'gateway':
if not body.payment_method:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='payment_method is required for gateway mode',
)
purchase_kwargs: dict = (
{
'gift_recipient_type': body.recipient_type,
'gift_recipient_value': body.recipient_value,
'gift_message': body.gift_message,
}
if has_recipient
else {
'gift_message': body.gift_message,
}
)
try:
purchase = await create_purchase(
db,
landing=None,
tariff=tariff,
period_days=body.period_days,
amount_kopeks=price_kopeks,
contact_type=buyer_contact_type,
contact_value=buyer_contact_value,
payment_method=body.payment_method,
is_gift=True,
source='cabinet',
buyer_user_id=user.id,
commit=False,
**purchase_kwargs,
)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
# Persist warning so it survives the gateway redirect
if recipient_warning:
purchase.recipient_warning = recipient_warning
# Build return URL for after payment
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
return_url = f'{cabinet_base}/gift/result?token={purchase.token[:12]}'
from app.services.payment_service import PaymentService
# Stars payments need a Bot instance to create invoice links
bot = None
if body.payment_method == 'telegram_stars':
from app.bot_factory import create_bot
bot = create_bot()
try:
payment_service = PaymentService(bot=bot)
payment_result = await payment_service.create_guest_payment(
db=db,
amount_kopeks=price_kopeks,
payment_method=body.payment_method,
description=f'Gift: {tariff.name} ({body.period_days}d)',
purchase_token=purchase.token,
return_url=return_url,
)
finally:
if bot:
await bot.session.close()
if payment_result is None:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Payment provider is unavailable, please try again later',
)
payment_url = payment_result.get('payment_url')
if not payment_url:
await db.rollback()
logger.error(
'Gift payment created but no payment_url returned',
purchase_token=purchase.token[:5],
provider=payment_result.get('provider'),
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Payment provider returned an invalid response',
)
# Consume promo offer discount before committing gateway purchase
if consume_promo and getattr(user, 'promo_offer_discount_percent', 0):
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
await db.commit()
await db.refresh(purchase)
return GiftPurchaseResponse(
status='created',
purchase_token=purchase.token[:12],
payment_url=payment_url,
warning=recipient_warning,
)
# 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',
)
# Create purchase record
balance_purchase_kwargs: dict = (
{
'gift_recipient_type': body.recipient_type,
'gift_recipient_value': body.recipient_value,
'gift_message': body.gift_message,
}
if has_recipient
else {
'gift_message': body.gift_message,
}
)
try:
purchase = await create_purchase(
db,
landing=None,
tariff=tariff,
period_days=body.period_days,
amount_kopeks=price_kopeks,
contact_type=buyer_contact_type,
contact_value=buyer_contact_value,
payment_method='balance',
is_gift=True,
source='cabinet',
buyer_user_id=user.id,
commit=False,
**balance_purchase_kwargs,
)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
# Persist warning on purchase record
if recipient_warning:
purchase.recipient_warning = recipient_warning
# Subtract balance (consume promo offer if one was applied)
balance_ok = await subtract_user_balance(
db,
user,
price_kopeks,
description=f'Gift: {tariff.name} ({body.period_days}d)',
create_transaction=False,
consume_promo_offer=consume_promo,
)
if not balance_ok:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Insufficient balance',
)
# Transaction description: include recipient when specified
tx_description = f'Gift: {tariff.name} ({body.period_days}d)'
if has_recipient:
tx_description += f' -> {body.recipient_value}'
# Create transaction record
transaction = await create_transaction(
db,
user_id=user.id,
type=TransactionType.GIFT_PAYMENT,
amount_kopeks=price_kopeks,
description=tx_description,
payment_method=PaymentMethod.BALANCE,
commit=False,
)
# Mark purchase as paid
purchase.status = GuestPurchaseStatus.PAID.value
purchase.paid_at = datetime.now(UTC)
await db.commit()
# Emit deferred side-effects after atomic commit
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=price_kopeks,
user_id=user.id,
type=TransactionType.GIFT_PAYMENT,
payment_method=PaymentMethod.BALANCE,
description=tx_description,
)
# Capture token before fulfill_purchase — session state may change after rollback inside fulfill
purchase_token = purchase.token
# Only fulfill immediately when a specific recipient was provided.
# Code-only gifts (no recipient) stay in PAID status until someone activates via code.
if has_recipient:
try:
await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id)
except Exception:
logger.exception(
'Gift purchase fulfillment failed (purchase is paid, will retry)',
purchase_id=purchase.id,
)
return GiftPurchaseResponse(
status='ok',
purchase_token=purchase_token[:12],
warning=recipient_warning,
)
@router.get('/pending', response_model=list[PendingGiftResponse])
async def get_pending_gifts(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get pending gift purchases that the current user can activate."""
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff))
.where(
GuestPurchase.user_id == user.id,
GuestPurchase.is_gift.is_(True),
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
)
.order_by(GuestPurchase.created_at.desc())
.limit(100)
)
purchases = result.scalars().all()
pending: list[PendingGiftResponse] = []
for p in purchases:
# Determine sender display name
sender_display = None
if p.contact_value:
sender_display = p.contact_value
pending.append(
PendingGiftResponse(
token=p.token[:12],
tariff_name=p.tariff.name if p.tariff else None,
period_days=p.period_days,
gift_message=p.gift_message,
sender_display=sender_display,
created_at=p.created_at,
)
)
return pending
@router.get('/purchase/{token}', response_model=GiftPurchaseStatusResponse)
async def get_gift_purchase_status(
token: str,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get the status of a cabinet gift purchase."""
if len(token) >= 64:
token_filter = GuestPurchase.token == token
else:
token_filter = GuestPurchase.token.startswith(token)
result = await db.execute(select(GuestPurchase).options(selectinload(GuestPurchase.tariff)).where(token_filter))
purchase = result.scalars().first()
if purchase is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Purchase not found',
)
# Uniform 404 prevents token existence oracle
if purchase.buyer_user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Purchase not found',
)
tariff_name = purchase.tariff.name if purchase.tariff else None
recipient_contact_value = None
if purchase.gift_recipient_value:
recipient_contact_value = purchase.gift_recipient_value
is_code_only = purchase.is_gift and not purchase.gift_recipient_type
return GiftPurchaseStatusResponse(
status=purchase.status,
is_gift=True,
is_code_only=is_code_only,
purchase_token=purchase.token[:12] if is_code_only else None,
recipient_contact_value=recipient_contact_value,
gift_message=purchase.gift_message,
tariff_name=tariff_name,
period_days=purchase.period_days,
warning=purchase.recipient_warning,
)
@router.get('/sent', response_model=list[SentGiftResponse])
async def get_sent_gifts(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all gifts the current user has sent."""
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff), selectinload(GuestPurchase.user))
.where(
GuestPurchase.buyer_user_id == user.id,
GuestPurchase.is_gift.is_(True),
)
.order_by(GuestPurchase.created_at.desc())
.limit(100)
)
purchases = result.scalars().all()
sent: list[SentGiftResponse] = []
for p in purchases:
activated_by_username = None
if p.status == GuestPurchaseStatus.DELIVERED.value and p.user and p.user.username:
activated_by_username = f'@{p.user.username}'
sent.append(
SentGiftResponse(
token=p.token[:12],
tariff_name=p.tariff.name if p.tariff else None,
period_days=p.period_days,
device_limit=p.tariff.device_limit if p.tariff else 1,
status=p.status,
gift_recipient_value=p.gift_recipient_value,
gift_message=p.gift_message,
activated_by_username=activated_by_username,
created_at=p.created_at,
)
)
return sent
@router.get('/received', response_model=list[ReceivedGiftResponse])
async def get_received_gifts(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all gifts the current user has received."""
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff), selectinload(GuestPurchase.buyer))
.where(
GuestPurchase.user_id == user.id,
GuestPurchase.is_gift.is_(True),
)
.order_by(GuestPurchase.created_at.desc())
.limit(100)
)
purchases = result.scalars().all()
received: list[ReceivedGiftResponse] = []
for p in purchases:
sender_display = None
if p.buyer and p.buyer.username:
sender_display = f'@{p.buyer.username}'
elif p.contact_value:
sender_display = p.contact_value
received.append(
ReceivedGiftResponse(
token=p.token[:12],
tariff_name=p.tariff.name if p.tariff else None,
period_days=p.period_days,
device_limit=p.tariff.device_limit if p.tariff else 1,
status=p.status,
sender_display=sender_display,
gift_message=p.gift_message,
created_at=p.created_at,
)
)
return received
@router.post('/activate', response_model=ActivateGiftResponse)
async def activate_gift_by_code(
body: ActivateGiftRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Activate a gift subscription by its code (token)."""
from app.services.guest_purchase_service import activate_purchase as svc_activate
# Bug 2 fix: rate limit activation attempts to prevent brute-force token enumeration
is_limited = await RateLimitCache.is_rate_limited(user.id, 'gift_activate', limit=10, window=60)
if is_limited:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
code = body.code.strip()
if code.upper().startswith('GIFT-') or code.upper().startswith('GIFT_'):
code = code[5:]
if len(code) < 8:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Code too short')
# Support both full token and prefix-based lookup (displayed codes are truncated)
if len(code) >= 64:
# Full token — exact match
token_filter = GuestPurchase.token == code
else:
# Prefix match — for short display codes like GIFT-XXXXXXXXXXXX
token_filter = GuestPurchase.token.startswith(code)
result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff))
.where(token_filter, GuestPurchase.is_gift.is_(True))
.with_for_update()
)
purchase = result.scalars().first()
if purchase is None or not purchase.is_gift:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Gift not found',
)
# Bug 1 fix: check ownership BEFORE leaking any status/tariff info
if purchase.user_id is not None and purchase.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Gift not found',
)
# Prevent self-activation: buyer cannot activate their own gift
if purchase.buyer_user_id is not None and purchase.buyer_user_id == user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot activate your own gift',
)
if purchase.status == GuestPurchaseStatus.DELIVERED.value:
return ActivateGiftResponse(
status='activated',
tariff_name=purchase.tariff.name if purchase.tariff else None,
period_days=purchase.period_days,
)
# Code-only gifts are in PAID status; directed gifts are in PENDING_ACTIVATION
activatable_statuses = {
GuestPurchaseStatus.PENDING_ACTIVATION.value,
GuestPurchaseStatus.PAID.value,
}
if purchase.status not in activatable_statuses:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This gift cannot be activated',
)
# For code-only gifts (user_id is None), link the purchase to the activating user
if purchase.user_id is None:
purchase.user_id = user.id
# Transition PAID → PENDING_ACTIVATION so activate_purchase() accepts it
if purchase.status == GuestPurchaseStatus.PAID.value:
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
await db.flush()
try:
await svc_activate(db, purchase.token, skip_notification=True)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
return ActivateGiftResponse(
status='activated',
tariff_name=purchase.tariff.name if purchase.tariff else None,
period_days=purchase.period_days,
)
+3 -3
View File
@@ -91,7 +91,7 @@ class SupportConfigResponse(BaseModel):
"""Support/tickets configuration for miniapp."""
tickets_enabled: bool
support_type: str # "tickets", "profile", "url"
support_type: str # "tickets", "profile", "url", "both"
support_url: str | None = None
support_username: str | None = None
@@ -160,7 +160,7 @@ async def get_rules(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get service rules - uses same function as bot."""
requested_lang = language.split('-')[0].lower()
requested_lang = language.split('-', maxsplit=1)[0].lower()
# Use the same function as bot to ensure consistent content
content = await get_current_rules_content(db, requested_lang)
@@ -299,7 +299,7 @@ async def get_support_config():
support_type = 'profile'
else: # both
tickets_enabled = True
support_type = 'tickets'
support_type = 'both'
return SupportConfigResponse(
tickets_enabled=tickets_enabled,
+68
View File
@@ -0,0 +1,68 @@
"""Public info page routes for cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.info_pages import get_all_info_pages, get_info_page_by_slug, get_tab_replacements
from ..dependencies import get_cabinet_db
from ..schemas.info_pages import InfoPageListItem, InfoPageResponse
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/info-pages', tags=['Cabinet Info Pages'])
@router.get('', response_model=list[InfoPageListItem])
async def list_active_info_pages(
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
db: AsyncSession = Depends(get_cabinet_db),
) -> list[InfoPageListItem]:
"""Get all active info pages (public, no auth required)."""
try:
pages = await get_all_info_pages(db, include_inactive=False, page_type=page_type)
return [InfoPageListItem.model_validate(p) for p in pages]
except Exception:
logger.exception('Failed to list active info pages')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load info pages',
)
@router.get('/tab-replacements')
async def get_info_page_tab_replacements(
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, str | None]:
"""Get tab replacement mapping (public, no auth required).
Returns a dict mapping each replaceable tab to the info page slug that replaces it,
or null if no replacement is set: ``{faq: slug_or_null, ...}``.
"""
try:
return await get_tab_replacements(db)
except Exception:
logger.exception('Failed to get tab replacements')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load tab replacements',
)
@router.get('/{slug}', response_model=InfoPageResponse)
async def get_info_page_by_slug_public(
slug: str = Path(..., max_length=200, pattern=r'^[a-z0-9\-]+$'),
db: AsyncSession = Depends(get_cabinet_db),
) -> InfoPageResponse:
"""Get a single info page by slug (public, no auth required)."""
page = await get_info_page_by_slug(db, slug)
if not page or not page.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Info page not found',
)
return InfoPageResponse.model_validate(page)
+731
View File
@@ -0,0 +1,731 @@
"""Public landing page routes for guest quick-purchase flow."""
import re
from datetime import UTC, datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.cabinet.dependencies import get_cabinet_db
from app.cabinet.ip_utils import get_client_ip
from app.cabinet.utils.locale import DEFAULT_LOCALE, resolve_locale_text
from app.config import settings
from app.database.crud.landing import get_active_landing_by_slug, get_purchase_by_token
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage, Tariff
from app.services.guest_purchase_service import (
GuestPurchaseError,
activate_purchase as activate_guest_purchase,
create_purchase,
validate_and_calculate,
)
from app.services.payment_method_config_service import _get_method_defaults
from app.services.payment_service import PaymentService
from app.utils.cache import RateLimitCache, cache
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/landing', tags=['Landing Pages'])
# ============ Schemas ============
class LandingFeature(BaseModel):
icon: str = ''
title: str = ''
description: str = ''
class LandingTariffPeriod(BaseModel):
days: int
label: str
price_kopeks: int
price_label: str
original_price_kopeks: int | None = None # set if discount active
original_price_label: str | None = None
discount_percent: int | None = None # effective discount for this tariff
class LandingTariff(BaseModel):
id: int
name: str
description: str | None = None
traffic_limit_gb: int
device_limit: int
tier_level: int
periods: list[LandingTariffPeriod]
class LandingPaymentMethodSubOption(BaseModel):
id: str
name: str
class LandingPaymentMethod(BaseModel):
method_id: str
display_name: str
description: str | None = None
icon_url: str | None = None
sort_order: int = 0
min_amount_kopeks: int | None = None
max_amount_kopeks: int | None = None
currency: str | None = None
# Enabled sub-options with display labels (e.g. СБП, Карта).
# None or empty means no sub-option selection needed.
sub_options: list[LandingPaymentMethodSubOption] | None = None
class LandingDiscountInfo(BaseModel):
percent: int # default discount
ends_at: str # ISO datetime
badge_text: str | None = None # resolved locale text
class LandingConfigResponse(BaseModel):
slug: str
title: str
subtitle: str | None = None
features: list[LandingFeature]
footer_text: str | None = None
tariffs: list[LandingTariff]
payment_methods: list[LandingPaymentMethod]
gift_enabled: bool
custom_css: str | None = None
meta_title: str | None = None
meta_description: str | None = None
discount: LandingDiscountInfo | None = None # null if no active discount
background_config: dict | None = None
sticky_pay_button: bool = False
analytics_view_enabled: bool = False
analytics_view_goal: str | None = None
analytics_click_enabled: bool = False
analytics_click_goal: str | None = None
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{4,31}$')
def _validate_contact(contact_type: str, contact_value: str) -> None:
"""Validate contact value matches the declared type format."""
if contact_type == 'email' and not _EMAIL_RE.match(contact_value):
raise ValueError('Invalid email format')
if contact_type == 'telegram' and not _TELEGRAM_RE.match(contact_value):
raise ValueError('Invalid Telegram username format')
class PurchaseRequest(BaseModel):
tariff_id: int
period_days: int
contact_type: str = Field(pattern=r'^(email|telegram)$')
contact_value: str = Field(min_length=1, max_length=255)
payment_method: str = Field(min_length=1, max_length=50, pattern=r'^[a-z0-9_]+$')
is_gift: bool = False
gift_recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
gift_recipient_value: str | None = Field(default=None, max_length=255)
gift_message: str | None = Field(default=None, max_length=1000)
yandex_cid: str | None = Field(default=None, max_length=128, pattern=r'^[A-Za-z0-9._:-]{4,128}$')
referrer: str | None = Field(default=None, max_length=500)
subid: str | None = Field(default=None, max_length=255)
@model_validator(mode='after')
def validate_contacts(self) -> 'PurchaseRequest':
_validate_contact(self.contact_type, self.contact_value)
if self.is_gift:
if not self.gift_recipient_type or not self.gift_recipient_value:
raise ValueError('Gift recipient type and value are required for gift purchases')
_validate_contact(self.gift_recipient_type, self.gift_recipient_value)
return self
class PurchaseResponse(BaseModel):
purchase_token: str
payment_url: str
class PurchaseStatusResponse(BaseModel):
status: str
subscription_url: str | None = None
subscription_crypto_link: str | None = None
is_gift: bool = False
contact_value: str | None = None
recipient_contact_value: str | None = None
period_days: int | None = None
tariff_name: str | None = None
gift_message: str | None = None
contact_type: str | None = None
cabinet_email: str | None = None
cabinet_password: str | None = None
auto_login_token: str | None = None
recipient_in_bot: bool | None = None
bot_link: str | None = None
# ============ Helpers ============
def _mask_contact(value: str) -> str:
"""Mask contact value to avoid leaking PII in API responses."""
if '@' in value and not value.startswith('@'):
# Email: show first 2 chars + mask + domain
local, domain = value.rsplit('@', 1)
return f'{local[:2]}***@{domain}'
if value.startswith('@'):
# Telegram: show first 3 chars + mask
return f'{value[:3]}***'
return value[:3] + '***'
_SUBSCRIPTION_URL_EXPIRY_HOURS = 24
def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusResponse:
"""Build a PurchaseStatusResponse from a GuestPurchase record."""
tariff_name = purchase.tariff.name if purchase.tariff else None
within_ttl = False
subscription_url = None
subscription_crypto_link = None
if purchase.delivered_at and purchase.subscription_url and not purchase.is_gift:
age = datetime.now(UTC) - purchase.delivered_at
if age < timedelta(hours=_SUBSCRIPTION_URL_EXPIRY_HOURS):
within_ttl = True
subscription_url = purchase.subscription_url
subscription_crypto_link = purchase.subscription_crypto_link
masked_contact = _mask_contact(purchase.contact_value) if purchase.contact_value else None
recipient_contact_value = None
gift_message = None
if purchase.is_gift:
if purchase.gift_recipient_value:
recipient_contact_value = _mask_contact(purchase.gift_recipient_value)
gift_message = purchase.gift_message
# Determine effective contact type for the recipient
if purchase.is_gift and purchase.gift_recipient_type:
effective_contact_type = purchase.gift_recipient_type
else:
effective_contact_type = purchase.contact_type
# Cabinet credentials for email self-purchases (not gifts)
cabinet_email = None
cabinet_password = None
auto_login_token = None
is_terminal = purchase.status in (GuestPurchaseStatus.DELIVERED.value, GuestPurchaseStatus.PENDING_ACTIVATION.value)
is_email_self_purchase = effective_contact_type == 'email' and not purchase.is_gift
if is_terminal and is_email_self_purchase:
cabinet_email = purchase.contact_value
# For PENDING_ACTIVATION: cap credential exposure at 72h from paid_at
pending_within_ttl = (
purchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value
and purchase.paid_at
and (datetime.now(UTC) - purchase.paid_at) < timedelta(hours=72)
)
if within_ttl or pending_within_ttl:
cabinet_password = purchase.cabinet_password
auto_login_token = purchase.auto_login_token
# For telegram gifts: indicate whether recipient is known to the bot
recipient_in_bot: bool | None = None
bot_link: str | None = None
if purchase.is_gift and effective_contact_type == 'telegram':
recipient_in_bot = purchase.user is not None and purchase.user.telegram_id is not None
if not recipient_in_bot:
bot_username = settings.get_bot_username()
if bot_username:
bot_link = f'https://t.me/{bot_username}'
return PurchaseStatusResponse(
status=purchase.status,
subscription_url=subscription_url,
subscription_crypto_link=subscription_crypto_link,
is_gift=purchase.is_gift,
contact_value=masked_contact,
recipient_contact_value=recipient_contact_value,
period_days=purchase.period_days,
tariff_name=tariff_name,
gift_message=gift_message,
contact_type=effective_contact_type,
cabinet_email=cabinet_email,
cabinet_password=cabinet_password,
auto_login_token=auto_login_token,
recipient_in_bot=recipient_in_bot,
bot_link=bot_link,
)
def _period_label(days: int) -> str:
"""Human-readable label for a period in days."""
if days == 1:
return '1 day'
if days <= 6:
return f'{days} days'
if days == 7:
return '1 week'
if days == 14:
return '2 weeks'
if days == 30:
return '1 month'
if days == 60:
return '2 months'
if days == 90:
return '3 months'
if days == 180:
return '6 months'
if days == 365:
return '1 year'
if days == 456:
return '1 year + 3 mo.'
months = days // 30
remainder = days % 30
if months > 0 and remainder == 0:
return f'{months} mo.'
if months > 0:
return f'{months} mo. + {remainder} d.'
return f'{days} days'
def _get_active_discount(landing: LandingPage, lang: str) -> LandingDiscountInfo | None:
"""Return discount info if currently active, else None."""
if not landing.discount_percent or not landing.discount_starts_at or not landing.discount_ends_at:
return None
now = datetime.now(UTC)
if not (landing.discount_starts_at <= now < landing.discount_ends_at):
return None
badge = resolve_locale_text(landing.discount_badge_text, lang) if landing.discount_badge_text else None
return LandingDiscountInfo(
percent=landing.discount_percent,
ends_at=landing.discount_ends_at.isoformat(),
badge_text=badge or None,
)
async def _load_landing_tariffs(
db: AsyncSession, landing: LandingPage, discount: LandingDiscountInfo | None = None
) -> list[LandingTariff]:
"""Load tariffs for a landing page, filtered by allowed IDs and periods."""
allowed_ids = landing.allowed_tariff_ids or []
if not allowed_ids:
return []
result = await db.execute(
select(Tariff)
.where(Tariff.id.in_(allowed_ids), Tariff.is_active.is_(True))
.order_by(Tariff.display_order, Tariff.id)
)
tariffs = result.scalars().all()
allowed_periods = landing.allowed_periods or {}
landing_tariffs = []
for tariff in tariffs:
# Determine which periods to show
tariff_period_override = allowed_periods.get(str(tariff.id))
if tariff_period_override is not None:
period_days_list = sorted(tariff_period_override)
else:
period_days_list = tariff.get_available_periods()
periods = []
for days in period_days_list:
price = tariff.get_price_for_period(days)
if price is None:
continue
original_price_kopeks = None
original_price_label = None
effective_discount = None
if discount:
# Per-tariff override takes priority (read from landing model, not response DTO)
overrides = landing.discount_overrides or {}
tariff_override = overrides.get(str(tariff.id))
effective_discount = tariff_override if tariff_override is not None else discount.percent
original_price_kopeks = price
original_price_label = settings.format_price(price)
from app.services.pricing_engine import PricingEngine
price = max(1, PricingEngine.apply_discount(price, effective_discount))
periods.append(
LandingTariffPeriod(
days=days,
label=_period_label(days),
price_kopeks=price,
price_label=settings.format_price(price),
original_price_kopeks=original_price_kopeks,
original_price_label=original_price_label,
discount_percent=effective_discount,
)
)
if not periods:
continue
landing_tariffs.append(
LandingTariff(
id=tariff.id,
name=tariff.name,
description=tariff.description,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
tier_level=tariff.tier_level,
periods=periods,
)
)
return landing_tariffs
# ============ Routes ============
# IMPORTANT: /purchase/{token} must come BEFORE /{slug} to avoid shadowing
# (FastAPI checks routes in definition order; "purchase" would match {slug})
@router.get('/purchase/{token}', response_model=PurchaseStatusResponse)
async def get_purchase_status(
token: str,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get the status of a guest purchase by token.
No authentication required.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'purchase_status', limit=30, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
purchase = await get_purchase_by_token(db, token)
if purchase is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Purchase not found',
)
response = _build_purchase_status_response(purchase)
# Cleanup: null expired credentials from DB
needs_cleanup = False
if purchase.delivered_at and (purchase.cabinet_password or purchase.auto_login_token):
age = datetime.now(UTC) - purchase.delivered_at
if age >= timedelta(hours=_SUBSCRIPTION_URL_EXPIRY_HOURS):
needs_cleanup = True
elif (
purchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value
and purchase.paid_at
and (purchase.cabinet_password or purchase.auto_login_token)
and (datetime.now(UTC) - purchase.paid_at) >= timedelta(hours=72)
):
needs_cleanup = True
if needs_cleanup:
purchase.cabinet_password = None
purchase.auto_login_token = None
await db.commit()
return response
@router.post('/activate/{token}', response_model=PurchaseStatusResponse)
async def activate_purchase(
token: str,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Activate a pending guest purchase, replacing the user's current subscription.
No authentication required (token is the secret).
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'activate_purchase', limit=5, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
try:
purchase = await activate_guest_purchase(db, token)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
return _build_purchase_status_response(purchase)
@router.get('/{slug}', response_model=LandingConfigResponse)
async def get_landing_config(
raw_request: Request,
slug: str = Path(max_length=100),
lang: str = Query(DEFAULT_LOCALE, max_length=5, description='Locale: ru, en, zh, fa'),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get public landing page configuration with tariffs and payment methods.
No authentication required. Pass ``?lang=en`` to get localized text.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_config', limit=60, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
landing = await get_active_landing_by_slug(db, slug)
if landing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Landing page not found',
)
discount = _get_active_discount(landing, lang)
tariffs = await _load_landing_tariffs(db, landing, discount)
# Build payment methods from landing config
raw_methods = landing.payment_methods or []
method_defaults = _get_method_defaults()
payment_methods: list[LandingPaymentMethod] = []
for m in raw_methods:
method_id = m.get('method_id', '')
raw_sub_options = m.get('sub_options') # dict[str, bool] | None
# Resolve sub-options: filter enabled ones and attach display names
resolved_sub_options: list[LandingPaymentMethodSubOption] | None = None
method_def = method_defaults.get(method_id)
available = method_def.get('available_sub_options') if method_def else None
if available:
resolved = []
for opt in available:
opt_id = opt['id']
# If landing has explicit sub_options config, respect it; otherwise all enabled
if raw_sub_options is None or raw_sub_options.get(opt_id, True):
resolved.append(LandingPaymentMethodSubOption(id=opt_id, name=opt['name']))
if resolved:
resolved_sub_options = resolved
payment_methods.append(
LandingPaymentMethod(
method_id=method_id,
display_name=m.get('display_name', ''),
description=m.get('description'),
icon_url=m.get('icon_url'),
sort_order=m.get('sort_order', 0),
min_amount_kopeks=m.get('min_amount_kopeks'),
max_amount_kopeks=m.get('max_amount_kopeks'),
currency=m.get('currency'),
sub_options=resolved_sub_options,
)
)
# Resolve locale dicts to flat strings for the requested language
features = [
LandingFeature(
icon=f.get('icon', ''),
title=resolve_locale_text(f.get('title'), lang),
description=resolve_locale_text(f.get('description'), lang),
)
for f in (landing.features or [])
]
return LandingConfigResponse(
slug=landing.slug,
title=resolve_locale_text(landing.title, lang),
subtitle=resolve_locale_text(landing.subtitle, lang) or None,
features=features,
footer_text=resolve_locale_text(landing.footer_text, lang) or None,
tariffs=tariffs,
payment_methods=payment_methods,
gift_enabled=landing.gift_enabled,
custom_css=landing.custom_css,
meta_title=resolve_locale_text(landing.meta_title, lang) or None,
meta_description=resolve_locale_text(landing.meta_description, lang) or None,
discount=discount,
background_config=landing.background_config,
sticky_pay_button=landing.sticky_pay_button,
analytics_view_enabled=landing.analytics_view_enabled,
analytics_view_goal=landing.analytics_view_goal,
analytics_click_enabled=landing.analytics_click_enabled,
analytics_click_goal=landing.analytics_click_goal,
)
@router.post('/{slug}/purchase', response_model=PurchaseResponse)
async def create_landing_purchase(
slug: str,
body: PurchaseRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a guest purchase on a landing page.
No authentication required.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=30, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many purchase attempts, please try again later',
)
landing = await get_active_landing_by_slug(db, slug)
if landing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Landing page not found',
)
if body.is_gift and not landing.gift_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Gift purchases are not enabled for this landing page',
)
# Validate payment method is available on this landing.
# The frontend may send a suffixed method ID (e.g. "platega_2", "yookassa_sbp")
# to select a specific sub-option. We match against the base method_id and
# validate the suffix against known & enabled sub-options.
raw_methods = landing.payment_methods or []
method_defaults = _get_method_defaults()
method_config = next((m for m in raw_methods if m.get('method_id') == body.payment_method), None)
if method_config is None:
# Try matching by prefix: "platega_2" → base "platega"
# Sort by length descending so "freekassa_sbp" is checked before "freekassa"
sorted_methods = sorted(raw_methods, key=lambda m: len(m.get('method_id', '')), reverse=True)
for m in sorted_methods:
mid = m.get('method_id', '')
if body.payment_method.startswith(mid + '_'):
suffix = body.payment_method[len(mid) + 1 :]
# Validate suffix is a known sub-option
method_def = method_defaults.get(mid)
available = (method_def.get('available_sub_options') if method_def else None) or []
valid_ids = {opt['id'] for opt in available}
if suffix not in valid_ids:
break # invalid suffix → reject
# Validate suffix is enabled on this landing
raw_sub_options = m.get('sub_options') # dict[str, bool] | None
if raw_sub_options is not None and not raw_sub_options.get(suffix, True):
break # disabled sub-option → reject
method_config = m
break
if method_config is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Payment method is not available on this landing page',
)
# Validate tariff + period + calculate price
try:
tariff, amount_kopeks = await validate_and_calculate(db, landing, body.tariff_id, body.period_days)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
# Gift purchases require the tariff to be visible in the gift section
if body.is_gift and not tariff.show_in_gift:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This tariff is not available for gift purchases',
)
# Validate amount against per-method min/max limits (before creating purchase record)
min_amount = method_config.get('min_amount_kopeks')
max_amount = method_config.get('max_amount_kopeks')
if min_amount is not None and amount_kopeks < min_amount:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Amount is below the minimum ({settings.format_price(min_amount)}) for this payment method',
)
if max_amount is not None and amount_kopeks > max_amount:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Amount exceeds the maximum ({settings.format_price(max_amount)}) for this payment method',
)
# Create purchase record (no commit yet — wait for payment creation)
purchase = await create_purchase(
db,
landing=landing,
tariff=tariff,
period_days=body.period_days,
amount_kopeks=amount_kopeks,
contact_type=body.contact_type,
contact_value=body.contact_value,
payment_method=body.payment_method,
is_gift=body.is_gift,
gift_recipient_type=body.gift_recipient_type,
gift_recipient_value=body.gift_recipient_value,
gift_message=body.gift_message,
subid=body.subid,
referrer=body.referrer,
commit=False,
)
# Fallback to HTTP Referer header if body did not supply one
if not purchase.referrer:
http_referrer = raw_request.headers.get('referer') or raw_request.headers.get('referrer')
if http_referrer and len(http_referrer) <= 500:
purchase.referrer = http_referrer
# Determine return URL: per-method override → default cabinet URL
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
default_return_url = f'{cabinet_base}/buy/success/{purchase.token}'
method_return_url = method_config.get('return_url')
if method_return_url:
# Allow {token} placeholder in custom return URLs
return_url = method_return_url.replace('{token}', purchase.token)
else:
return_url = default_return_url
payment_service = PaymentService()
payment_result = await payment_service.create_guest_payment(
db=db,
amount_kopeks=amount_kopeks,
payment_method=body.payment_method,
description=f'{tariff.name}{body.period_days}d',
purchase_token=purchase.token,
return_url=return_url,
)
if payment_result is None:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Payment provider is unavailable, please try again later',
)
payment_url = payment_result.get('payment_url')
if not payment_url:
await db.rollback()
logger.error(
'Payment created but no payment_url returned',
purchase_token=purchase.token[:5],
provider=payment_result.get('provider'),
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Payment provider returned an invalid response',
)
await db.commit()
await db.refresh(purchase)
# Persist Yandex CID in cache so fulfill_purchase can link it to the user later
if body.yandex_cid and settings.YANDEX_OFFLINE_CONV_ENABLED:
try:
await cache.set(f'yacid:purchase:{purchase.token}', body.yandex_cid, expire=86400)
except Exception:
pass
# Persist subid in cache for S2S postback
if body.subid:
try:
await cache.set(f'subid:purchase:{purchase.token}', body.subid, expire=86400)
except Exception:
pass
return PurchaseResponse(
purchase_token=purchase.token,
payment_url=payment_url,
)
+13 -11
View File
@@ -3,13 +3,11 @@
import mimetypes
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
from pydantic import BaseModel
from app.bot_factory import create_bot
from app.config import settings
from app.database.models import User
@@ -98,31 +96,38 @@ async def upload_media(
target_chat_id = _resolve_target_chat_id()
upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload')
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
# Send with disable_notification to avoid pinging admins — this is just staging
if media_type_normalized == 'photo':
message = await bot.send_photo(
chat_id=target_chat_id,
photo=upload,
disable_notification=True,
)
media = message.photo[-1]
elif media_type_normalized == 'video':
message = await bot.send_video(
chat_id=target_chat_id,
video=upload,
disable_notification=True,
)
media = message.video
else:
message = await bot.send_document(
chat_id=target_chat_id,
document=upload,
disable_notification=True,
)
media = message.document
# Delete the staging message immediately — file_id persists after deletion
try:
await bot.delete_message(chat_id=target_chat_id, message_id=message.message_id)
except Exception:
pass # Best-effort cleanup — file_id is already captured
media_url = _build_media_url(request, media.file_id)
logger.info(
@@ -158,10 +163,7 @@ async def download_media(
Download media file by file_id.
Used to display images/documents in ticket messages.
"""
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
file = await bot.get_file(file_id)
+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)
+2 -2
View File
@@ -28,7 +28,7 @@ class NotificationSettingsResponse(BaseModel):
subscription_expiry_days: int = 3
traffic_warning_enabled: bool = True
traffic_warning_percent: int = 80
balance_low_enabled: bool = True
balance_low_enabled: bool = False
balance_low_threshold: int = 100 # kopeks
news_enabled: bool = True
promo_offers_enabled: bool = True
@@ -60,7 +60,7 @@ def _get_notification_settings(user: User) -> dict[str, Any]:
'subscription_expiry_days': settings_data.get('subscription_expiry_days', 3),
'traffic_warning_enabled': settings_data.get('traffic_warning_enabled', True),
'traffic_warning_percent': settings_data.get('traffic_warning_percent', 80),
'balance_low_enabled': settings_data.get('balance_low_enabled', True),
'balance_low_enabled': settings_data.get('balance_low_enabled', False),
'balance_low_threshold': settings_data.get('balance_low_threshold', 100),
'news_enabled': settings_data.get('news_enabled', True),
'promo_offers_enabled': settings_data.get('promo_offers_enabled', True),
+98 -24
View File
@@ -12,6 +12,7 @@ from app.database.crud.user import (
create_user_by_oauth,
get_user_by_email,
get_user_by_oauth_provider,
get_user_by_referral_code,
set_user_oauth_provider_id,
)
from app.database.models import User
@@ -23,6 +24,7 @@ from ..auth.oauth_providers import (
validate_oauth_state,
)
from ..dependencies import get_cabinet_db
from ..routes.account_linking import OAuthProviderName
from ..schemas.auth import AuthResponse
from .auth import _create_auth_response, _process_campaign_bonus, _store_refresh_token
@@ -37,16 +39,23 @@ async def _finalize_oauth_login(
user: User,
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)
await db.commit()
auth_response = _create_auth_response(user)
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 (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, is_new_user=is_new_user)
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
if auth_response.campaign_bonus:
from .auth import _user_to_response
auth_response.user = _user_to_response(user)
return auth_response
@@ -69,11 +78,15 @@ class OAuthAuthorizeResponse(BaseModel):
class OAuthCallbackRequest(BaseModel):
code: str = Field(..., description='Authorization code from provider')
state: str = Field(..., description='CSRF state token')
code: str = Field(..., min_length=1, max_length=2048, description='Authorization code from provider')
state: str = Field(..., min_length=1, max_length=128, description='CSRF state token')
device_id: str | None = Field(None, max_length=256, description='Device ID from VK ID callback')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
# --- Endpoints ---
@@ -92,48 +105,68 @@ async def get_oauth_providers():
@router.get('/{provider}/authorize', response_model=OAuthAuthorizeResponse)
async def get_oauth_authorize_url(provider: str):
async def get_oauth_authorize_url(provider: OAuthProviderName):
"""Get authorization URL for an OAuth provider."""
oauth_provider = get_provider(provider)
if not oauth_provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'OAuth provider "{provider}" is not enabled',
detail='Requested OAuth provider is not available',
)
state = await generate_oauth_state(provider)
authorize_url = oauth_provider.get_authorization_url(state)
# Generate extra state data (e.g., PKCE code_verifier for VK)
auth_extra = oauth_provider.prepare_auth_state()
state = await generate_oauth_state(provider, extra_data=auth_extra or None)
# Only pass URL-safe params (prefixed with _) to authorize URL; exclude secrets like code_verifier
url_params = {k: v for k, v in auth_extra.items() if k.startswith('_')} if auth_extra else {}
authorize_url = oauth_provider.get_authorization_url(state, **url_params)
return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state)
@router.post('/{provider}/callback', response_model=AuthResponse)
async def oauth_callback(
provider: str,
provider: OAuthProviderName,
request: OAuthCallbackRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Handle OAuth callback: exchange code, find/create user, return JWT."""
# 1. Validate CSRF state
if not await validate_oauth_state(request.state, provider):
# 1. Validate CSRF state and retrieve stored data (e.g., PKCE code_verifier)
state_data = await validate_oauth_state(request.state, provider)
if not state_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid or expired OAuth state',
)
# 1b. Reject linking-flow state tokens (must use link_provider_callback instead)
if state_data.get('linking') == 'true':
logger.warning('Linking-flow state token used in login callback', provider=provider)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='OAuth state was initiated for account linking, not login',
)
# 2. Get provider instance
oauth_provider = get_provider(provider)
if not oauth_provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'OAuth provider "{provider}" is not enabled',
detail='Requested OAuth provider is not available',
)
# 3. Exchange code for tokens
# 3. Exchange code for tokens (pass PKCE code_verifier and device_id if present)
exchange_kwargs: dict[str, str] = {'state': request.state}
code_verifier = state_data.get('code_verifier')
if code_verifier:
exchange_kwargs['code_verifier'] = code_verifier
if request.device_id:
exchange_kwargs['device_id'] = request.device_id
try:
token_data = await oauth_provider.exchange_code(request.code)
token_data = await oauth_provider.exchange_code(request.code, **exchange_kwargs)
except Exception as exc:
logger.error('OAuth code exchange failed for', provider=provider, exc=exc)
logger.error('OAuth code exchange failed', provider=provider, exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to exchange authorization code',
@@ -143,7 +176,7 @@ async def oauth_callback(
try:
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
except Exception as exc:
logger.error('OAuth user info fetch failed for', provider=provider, exc=exc)
logger.error('OAuth user info fetch failed', provider=provider, exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to fetch user information from provider',
@@ -152,18 +185,43 @@ async def oauth_callback(
# 5. Find user by provider ID
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
if user:
logger.info('OAuth login via for existing user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
logger.info('OAuth login for existing user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
# 6. Find user by email (if verified) and link provider
if user_info.email and user_info.email_verified:
user = await get_user_by_email(db, user_info.email)
if user:
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
logger.info('OAuth provider linked to existing email user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
# 7. Create new user
# 7. Resolve referral code for new user
referrer_id = None
if request.referral_code:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
# Self-referral protection by email
if (
user_info.email
and user_info.email_verified
and referrer.email
and referrer.email.lower() == user_info.email.lower()
):
logger.warning(
'Self-referral attempt blocked via OAuth',
referral_code=request.referral_code,
email=user_info.email,
)
else:
referrer_id = referrer.id
except Exception:
logger.warning(
'Failed to resolve referral code during OAuth', referral_code=request.referral_code, exc_info=True
)
# 8. Create new user
user = await create_user_by_oauth(
db=db,
provider=provider,
@@ -173,6 +231,22 @@ async def oauth_callback(
first_name=user_info.first_name,
last_name=user_info.last_name,
username=user_info.username,
referred_by_id=referrer_id,
)
logger.info('New OAuth user created', provider=provider, user_id=user.id)
# 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
)
logger.info('OAuth new user created via with id', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
+217
View File
@@ -0,0 +1,217 @@
"""User-facing partner application routes for cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.cabinet.utils.links import get_campaign_deep_link, get_campaign_web_link
from app.config import settings
from app.database.models import AdvertisingCampaign, User
from app.services.partner_application_service import partner_application_service
from app.services.partner_stats_service import PartnerStatsService
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.partners import (
CampaignReferralItem,
DailyStatItem,
PartnerApplicationInfo,
PartnerApplicationRequest,
PartnerCampaignDetailedStats,
PartnerCampaignInfo,
PartnerStatusResponse,
PeriodChange,
PeriodComparison,
PeriodStats,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/referral/partner', tags=['Cabinet Partner'])
@router.get('/status', response_model=PartnerStatusResponse)
async def get_partner_status(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get partner status and latest application for current user."""
latest_app = await partner_application_service.get_latest_application(db, user.id)
app_info = None
if latest_app:
app_info = PartnerApplicationInfo(
id=latest_app.id,
status=latest_app.status,
company_name=latest_app.company_name,
website_url=latest_app.website_url,
telegram_channel=latest_app.telegram_channel,
description=latest_app.description,
expected_monthly_referrals=latest_app.expected_monthly_referrals,
desired_commission_percent=latest_app.desired_commission_percent,
admin_comment=latest_app.admin_comment,
approved_commission_percent=latest_app.approved_commission_percent,
created_at=latest_app.created_at,
processed_at=latest_app.processed_at,
)
commission = user.referral_commission_percent
if commission is None and user.is_partner:
commission = settings.REFERRAL_COMMISSION_PERCENT
# Fetch campaigns assigned to this partner
campaigns: list[PartnerCampaignInfo] = []
if user.is_partner:
result = await db.execute(
select(AdvertisingCampaign).where(
AdvertisingCampaign.partner_user_id == user.id,
AdvertisingCampaign.is_active.is_(True),
)
)
campaign_models = result.scalars().all()
# Fetch per-campaign stats in one batch
campaign_ids = [c.id for c in campaign_models]
campaign_stats = await PartnerStatsService.get_per_campaign_stats(db, user.id, campaign_ids)
for c in campaign_models:
stats = campaign_stats.get(c.id, {})
campaigns.append(
PartnerCampaignInfo(
id=c.id,
name=c.name,
start_parameter=c.start_parameter,
bonus_type=c.bonus_type,
balance_bonus_kopeks=c.balance_bonus_kopeks or 0,
subscription_duration_days=c.subscription_duration_days,
subscription_traffic_gb=c.subscription_traffic_gb,
deep_link=get_campaign_deep_link(c.start_parameter),
web_link=get_campaign_web_link(c.start_parameter),
registrations_count=stats.get('registrations_count', 0),
referrals_count=stats.get('referrals_count', 0),
earnings_kopeks=stats.get('earnings_kopeks', 0),
)
)
return PartnerStatusResponse(
partner_status=user.partner_status,
commission_percent=commission,
latest_application=app_info,
campaigns=campaigns,
)
@router.get('/campaigns/{campaign_id}/stats', response_model=PartnerCampaignDetailedStats)
async def get_campaign_stats(
campaign_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed stats for a single campaign belonging to the current partner."""
if not user.is_partner:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Partner status required',
)
# Verify campaign belongs to this partner
campaign_result = await db.execute(
select(AdvertisingCampaign).where(
AdvertisingCampaign.id == campaign_id,
AdvertisingCampaign.partner_user_id == user.id,
)
)
campaign = campaign_result.scalar_one_or_none()
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Campaign not found or not assigned to you',
)
raw = await PartnerStatsService.get_campaign_detailed_stats(db, user.id, campaign_id)
return PartnerCampaignDetailedStats(
campaign_id=raw['campaign_id'],
campaign_name=campaign.name,
registrations_count=raw['registrations_count'],
referrals_count=raw['referrals_count'],
earnings_kopeks=raw['earnings_kopeks'],
conversion_rate=raw['conversion_rate'],
earnings_today=raw['earnings_today'],
earnings_week=raw['earnings_week'],
earnings_month=raw['earnings_month'],
daily_stats=[DailyStatItem(**d) for d in raw['daily_stats']],
period_comparison=PeriodComparison(
current=PeriodStats(**raw['period_comparison']['current']),
previous=PeriodStats(**raw['period_comparison']['previous']),
referrals_change=PeriodChange(**raw['period_comparison']['referrals_change']),
earnings_change=PeriodChange(**raw['period_comparison']['earnings_change']),
),
top_referrals=[CampaignReferralItem(**r) for r in raw['top_referrals']],
)
@router.post('/apply', response_model=PartnerApplicationInfo)
async def apply_for_partner(
request: PartnerApplicationRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Submit partner application."""
application, error = await partner_application_service.submit_application(
db,
user_id=user.id,
company_name=request.company_name,
website_url=request.website_url,
telegram_channel=request.telegram_channel,
description=request.description,
expected_monthly_referrals=request.expected_monthly_referrals,
desired_commission_percent=request.desired_commission_percent,
)
if not application:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Уведомляем админов о новой заявке
try:
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_partner_application_notification(
user=user,
application_data={
'company_name': request.company_name,
'telegram_channel': request.telegram_channel,
'website_url': request.website_url,
'description': request.description,
'expected_monthly_referrals': request.expected_monthly_referrals,
'desired_commission_percent': request.desired_commission_percent,
},
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for partner application', error=e)
return PartnerApplicationInfo(
id=application.id,
status=application.status,
company_name=application.company_name,
website_url=application.website_url,
telegram_channel=application.telegram_channel,
description=application.description,
expected_monthly_referrals=application.expected_monthly_referrals,
desired_commission_percent=application.desired_commission_percent,
admin_comment=application.admin_comment,
approved_commission_percent=application.approved_commission_percent,
created_at=application.created_at,
processed_at=application.processed_at,
)
+1 -1
View File
@@ -144,7 +144,7 @@ async def get_available_polls(
selectinload(PollResponse.poll).selectinload(Poll.questions),
selectinload(PollResponse.answers),
)
.order_by(PollResponse.created_at.desc())
.order_by(PollResponse.sent_at.desc())
)
responses = result.scalars().all()
+17 -12
View File
@@ -204,15 +204,11 @@ async def get_loyalty_tiers(
total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id)
total_spent_rubles = total_spent_kopeks / 100
# Get user's current promo group
await db.refresh(user, ['promo_group', 'user_promo_groups'])
current_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
current_tier_name = current_promo_group.name if current_promo_group else None
# Get all auto-assign promo groups (sorted by threshold ascending)
auto_groups = await get_auto_assign_promo_groups(db)
tiers: list[LoyaltyTierInfo] = []
current_tier_name: str | None = None
next_tier_name: str | None = None
next_tier_threshold: float | None = None
@@ -220,7 +216,15 @@ async def get_loyalty_tiers(
threshold_kopeks = group.auto_assign_total_spent_kopeks or 0
threshold_rubles = threshold_kopeks / 100
is_achieved = total_spent_kopeks >= threshold_kopeks
is_current = current_promo_group and current_promo_group.id == group.id
# Track highest achieved tier as "current" (by spending, not by assignment)
if is_achieved:
current_tier_name = group.name
# Find next tier (first not achieved)
if not is_achieved and next_tier_name is None:
next_tier_name = group.name
next_tier_threshold = threshold_rubles
# Get period discounts
period_discounts = {}
@@ -241,15 +245,16 @@ async def get_loyalty_tiers(
traffic_discount_percent=group.traffic_discount_percent or 0,
device_discount_percent=group.device_discount_percent or 0,
period_discounts=period_discounts,
is_current=is_current,
is_current=False,
is_achieved=is_achieved,
)
)
# Find next tier (first not achieved)
if not is_achieved and next_tier_name is None:
next_tier_name = group.name
next_tier_threshold = threshold_rubles
# Mark only the highest achieved tier as "current"
for tier in reversed(tiers):
if tier.is_achieved:
tier.is_current = True
break
# Calculate progress to next tier
progress_percent = 0.0
@@ -304,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,
+42 -2
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.promocode_service import PromoCodeService
@@ -20,6 +21,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 +43,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,12 +52,45 @@ 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
balance_after_rubles = result.get('balance_after_kopeks', 0) / 100
# Send admin notification (same as bot handler)
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_promocode_activation_notification(
db,
user,
result.get('promocode', {'code': request.code.strip()}),
result.get('description', ''),
result.get('balance_before_kopeks'),
result.get('balance_after_kopeks'),
)
finally:
await bot.session.close()
except Exception:
pass
return PromocodeActivateResponse(
success=True,
message='Promo code activated successfully',
@@ -68,10 +103,15 @@ 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',
'server_error': 'Server error occurred',
}
+71 -15
View File
@@ -9,7 +9,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import ReferralEarning, User
from app.database.models import (
AdvertisingCampaign,
ReferralEarning,
Subscription,
SubscriptionStatus,
User,
WithdrawalRequest,
WithdrawalRequestStatus,
)
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.referral import (
@@ -38,12 +46,15 @@ async def get_referral_info(
total_result = await db.execute(total_query)
total_referrals = total_result.scalar() or 0
# Get active referrals (with subscription)
# Get active referrals (with active subscription right now)
active_query = (
select(func.count())
.select_from(User)
.where(User.referred_by_id == user.id)
.where(User.has_had_paid_subscription == True)
select(func.count(func.distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
User.referred_by_id == user.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > func.now(),
)
)
active_result = await db.execute(active_query)
active_referrals = active_result.scalar() or 0
@@ -60,18 +71,42 @@ async def get_referral_info(
if commission_percent is None:
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
# Build referral link
bot_username = settings.get_bot_username() or 'bot'
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
# Get withdrawn amount (approved + completed withdrawal requests)
withdrawn_query = select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
WithdrawalRequest.user_id == user.id,
WithdrawalRequest.status.in_([WithdrawalRequestStatus.APPROVED.value, WithdrawalRequestStatus.COMPLETED.value]),
)
withdrawn_result = await db.execute(withdrawn_query)
withdrawn = withdrawn_result.scalar() or 0
# Get pending withdrawal amount
pending_query = select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
WithdrawalRequest.user_id == user.id,
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value,
)
pending_result = await db.execute(pending_query)
pending = pending_result.scalar() or 0
# Доступный баланс: мин(кошелёк, заработано - выведено - в ожидании)
referral_entitlement = max(0, total_earnings - withdrawn - pending)
available_balance = min(user.balance_kopeks, referral_entitlement)
# Build referral links
referral_link = (settings.get_cabinet_referral_link(user.referral_code) or '') if user.referral_code else ''
bot_referral_link = settings.get_bot_referral_link(user.referral_code) if user.referral_code else ''
return ReferralInfoResponse(
referral_code=user.referral_code or '',
referral_link=referral_link,
bot_referral_link=bot_referral_link,
total_referrals=total_referrals,
active_referrals=active_referrals,
total_earnings_kopeks=total_earnings,
total_earnings_rubles=total_earnings / 100,
commission_percent=commission_percent,
available_balance_kopeks=available_balance,
available_balance_rubles=available_balance / 100,
withdrawn_kopeks=withdrawn,
)
@@ -84,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)
@@ -104,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
@@ -150,12 +189,26 @@ async def get_referral_earnings(
result = await db.execute(query)
earnings = result.scalars().all()
# Batch-fetch referral users to avoid N+1
referral_ids = list({e.referral_id for e in earnings if e.referral_id})
if referral_ids:
referral_users_result = await db.execute(select(User).where(User.id.in_(referral_ids)))
referral_users_map = {u.id: u for u in referral_users_result.scalars().all()}
else:
referral_users_map = {}
# Batch-fetch campaigns to avoid N+1
campaign_ids = list({e.campaign_id for e in earnings if e.campaign_id})
if campaign_ids:
campaigns_result = await db.execute(select(AdvertisingCampaign).where(AdvertisingCampaign.id.in_(campaign_ids)))
campaigns_map = {c.id: c for c in campaigns_result.scalars().all()}
else:
campaigns_map = {}
items = []
for e in earnings:
# Get referral user info
referral_query = select(User).where(User.id == e.referral_id)
referral_result = await db.execute(referral_query)
referral_user = referral_result.scalar_one_or_none()
referral_user = referral_users_map.get(e.referral_id) if e.referral_id else None
campaign = campaigns_map.get(e.campaign_id) if e.campaign_id else None
items.append(
ReferralEarningResponse(
@@ -165,6 +218,7 @@ async def get_referral_earnings(
reason=e.reason or 'Referral commission',
referral_username=referral_user.username if referral_user else None,
referral_first_name=referral_user.first_name if referral_user else None,
campaign_name=campaign.name if campaign else None,
created_at=e.created_at,
)
)
@@ -194,4 +248,6 @@ async def get_referral_terms():
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
max_commission_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
"""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 .revoke import router as revoke_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',
'revoke_router',
'servers_router',
'status_router',
'tariff_switch_router',
'traffic_router',
]
@@ -0,0 +1,79 @@
"""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.',
)
# Триальные подписки — пробник, автопродление не имеет смысла
# NULL-safe: is_trial can be None in legacy rows — treat as trial
if subscription.is_trial is not False:
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,281 @@
"""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.is_active
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.is_active
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,92 @@
"""Cabinet API endpoint for subscription reissue.
POST /subscription/revoke
"""
from __future__ import annotations
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
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('/revoke')
async def revoke_subscription(
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict:
"""Revoke and reissue subscription (generate new connection link)."""
if not settings.is_subscription_revoke_enabled():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Subscription reissue is not available',
)
# Reload user from current session
from app.database.crud.user import get_user_by_id
fresh_user = await get_user_by_id(db, user.id)
if not fresh_user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
subscription = await resolve_subscription(db, fresh_user, subscription_id)
if not subscription:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Subscription not found')
if not subscription.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Only active subscriptions can be reissued',
)
# Check cooldown
if subscription.last_revoke_at:
elapsed = (datetime.now(UTC) - subscription.last_revoke_at).total_seconds()
cooldown = settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS
if elapsed < cooldown:
remaining = int(cooldown - elapsed)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f'Cooldown active. Try again in {remaining} seconds.',
headers={'Retry-After': str(remaining)},
)
# Execute revoke
sub_service = SubscriptionService()
new_url = await sub_service.revoke_subscription(db, subscription)
if not new_url:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to reissue subscription',
)
# Update cooldown timestamp
subscription.last_revoke_at = datetime.now(UTC)
await db.commit()
logger.info(
'Subscription revoked via cabinet API',
user_id=user.id,
subscription_id=subscription.id,
)
return {
'success': True,
'cooldown_seconds': settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS,
}
@@ -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,532 @@
"""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
# Проверяем разрешение на смену в данном направлении
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Повышение тарифа недоступно',
)
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Понижение тарифа недоступно',
)
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
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
new_period_days = switch_result.new_period_days
# Проверяем разрешение на смену в данном направлении
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Повышение тарифа недоступно',
)
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Понижение тарифа недоступно',
)
# 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,792 @@
"""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
import math
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()
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(db, subscription)
else:
await subscription_service.update_remnawave_user(db, subscription)
if subscription.status == 'active':
_enable_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled()
else getattr(user, 'remnawave_uuid', None)
)
if _enable_uuid:
await subscription_service.enable_remnawave_user(_enable_uuid)
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='create' if _should_create else 'update',
)
# Создаём транзакцию
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, math.ceil((subscription.end_date - now).total_seconds() / 86400))
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()
if settings.is_multi_tariff_enabled():
_should_create = not subscription.remnawave_uuid
else:
_should_create = not getattr(user, 'remnawave_uuid', None)
if _should_create:
await subscription_service.create_remnawave_user(db, subscription)
else:
await subscription_service.update_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='create' if _should_create else 'update',
)
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 -6
View File
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user
from ..dependencies import get_cabinet_db, get_current_cabinet_user, require_permission
logger = structlog.get_logger(__name__)
@@ -132,7 +132,7 @@ async def get_admin_notifications(
unread_only: bool = Query(False, description='Only return unread notifications'),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket notifications for admins."""
@@ -149,7 +149,7 @@ async def get_admin_notifications(
@admin_router.get('/unread-count', response_model=UnreadCountResponse)
async def get_admin_unread_count(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get unread notifications count for admins."""
@@ -160,7 +160,7 @@ async def get_admin_unread_count(
@admin_router.post('/{notification_id}/read')
async def mark_admin_notification_as_read(
notification_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark an admin notification as read."""
@@ -185,7 +185,7 @@ async def mark_admin_notification_as_read(
@admin_router.post('/read-all')
async def mark_all_admin_notifications_as_read(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all admin notifications as read."""
@@ -196,7 +196,7 @@ async def mark_all_admin_notifications_as_read(
@admin_router.post('/ticket/{ticket_id}/read')
async def mark_admin_ticket_notifications_as_read(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all admin notifications for a specific ticket as read."""

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