Compare commits

...

145 Commits

Author SHA1 Message Date
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
171 changed files with 12798 additions and 6843 deletions
+8 -5
View File
@@ -116,10 +116,8 @@ BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновле
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
# Обязательная подписка на канал
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
# Channel subscription settings (channels are managed via admin panel)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
@@ -632,6 +630,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
@@ -805,8 +810,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
-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.16.2"
".": "3.21.0"
}
+182
View File
@@ -1,5 +1,187 @@
# Changelog
## [3.21.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.20.1...v3.21.0) (2026-03-02)
### New Features
* add admin campaign chart data endpoint with deposits/spending split ([fa7de58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa7de589c1bd0ae37ebaaa07bae0ed3d68e01720))
* add admin sales statistics API with 6 analytics endpoints ([58faf9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58faf9eaeca63c458093d2a5e74a860f57712ab0))
* add daily deposits by payment method breakdown ([d33c5d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d33c5d6c07ce4a9efaf3c5aceb448e968e1b8ed7))
* add daily device purchases chart to addons stats ([2449a5c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2449a5cbbe5179a762197414a5752896383a6ee4))
* add desired commission percent to partner application ([7ea8fbd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ea8fbd584aff2127595001094ef69acb52f847f))
* add RESET_TRAFFIC_ON_TARIFF_SWITCH admin setting ([4eaedd3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4eaedd33bf697469fe9ed6a1bfe8b59ca43b46fb))
* enhance sales stats with device purchases, per-tariff daily breakdown, and registration tracking ([31c7e2e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31c7e2e9c14cb88762a62a72e4f65051e0c6c1fd))
### Bug Fixes
* add exc_info traceback to sync user error log ([efdf2a3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efdf2a3189a2f790e570f9a6e19d91469be4ea4f))
* add local traffic_used_gb reset in all tariff switch handlers ([2cdbbc0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cdbbc09ba9a19dcb720049ffde08ba780ac5751))
* add min_length to state field, use exc_info for referral warning ([062c486](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/062c4865db194f9d2242772044402fa2711a69bd))
* add missing subscription columns migration ([b96e819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b96e819da4cc37710e9fc17467045b33bcffac4d))
* address review findings from agent verification ([cc5be70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc5be7059fdf4cefb01e97196c825b217f8b54b3))
* correct cart notification after balance top-up ([2fab50c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fab50c340c885fc92a4bf797a4b03da6e44af31))
* correct referral withdrawal balance formula and commission transaction type ([83c6db4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83c6db48349440447305604e944fa440bdceb3fb))
* count sales from completed payment transactions instead of subscription created_at ([06c3996](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06c3996da4fa14eafb294651158068c7cda51e52))
* eliminate double panel API call on tariff change, harden cart notification ([b2cf4aa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2cf4aaa91f3fb63dca7e70645cadb75aa158cfe))
* eliminate referral system inconsistencies ([60c97f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/60c97f778bc4cc18aaf4d8a31826bc831c3b3f8f))
* email verification bypass, ban-notifications size limit, referral balance API ([256cbfc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/256cbfcadfd2fc88d8de69557c78618639af157d))
* enforce user restrictions in cabinet API and fix poll history crash ([faba3a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/faba3a8ed6d428305f9ca7d7fd9bdcc1fd72ba52))
* freekassa OP-SP-7 error and missing telegram notification ([200f91e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/200f91ef1748bb6213d1ef3a8e83ae976290a8a7))
* generate missing crypto link on the fly and skip unresolved templates ([4c72058](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c72058d4ad8b0594991b17323928d9004803bfa))
* handle expired callback queries and harden middleware error handling ([f52e6ae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f52e6aedac3de1c9bb2ad1a5a16b06d38b79ab63))
* handle expired ORM attributes in sync UUID mutation ([9ae5d7b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ae5d7bb60c57e2c29d6f3c5098c23450d5feb61))
* handle NULL used_promocodes for migrated users ([cdcabee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cdcabee80d1d7f0b367a97cdec20bb49e8592115))
* hide traffic topup button when tariff doesn't support it ([399ca86](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/399ca86561f4271e9c542bac87c0dd2931a223e0))
* improve campaign routes, schemas, and add database indexes ([ded5c89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ded5c899f7425707b17fef4d0d5ceafac777ef08))
* include desired_commission_percent in admin notification ([dc3d22f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc3d22f52db40150d595bccf524d38790e5725d9))
* migrate VK OAuth to VK ID OAuth 2.1 with PKCE ([1dfa780](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1dfa78013c4fb926a2b32bf4d63baa28215e7340))
* partner system — CRUD nullable fields, per-campaign stats, atomic unassign, diagnostic logging ([ed3ae14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed3ae14d0c378fa0dc2d442c3aa5a70172f3132c))
* prevent squad drop on admin subscription type change, require subscription for wheel spins ([59f0e42](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59f0e42be7e3c679d15cf2fc6820ab7097cd2201))
* prevent sync from overwriting subscription URLs with empty strings ([9c00479](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9c004791f28fbcf314b93c1b2a38593069605239))
* reject promo codes for days when user has no subscription or trial ([e32e2f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e32e2f779d014d587b58d63b513fd913ae1b7a41))
* remove premature tariff_id assignment in _apply_extension_updates ([b47678c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b47678cfb0ba5897b37dfe1f94e3d1336af5698e))
* renewals stats empty on all-time filter ([e25fcfc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e25fcfc6ef941465b83f368f152304ea5a6747d9))
* resolve GROUP BY mismatch for daily_by_tariff query ([e5f29eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e5f29eb041e88bc6315f0b4da3b78898d9dd7fff))
* restore panel user discovery on admin tariff change, localize cart reminder ([1256ddc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1256ddcd1a772f90e7bdf9437043a47ea9d84d53))
* separate base and purchased traffic in renewal pricing ([739ba29](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/739ba2986f41b04058eb14e8b87b0699fe96f922))
* sync traffic reset across all tariff switch code paths ([d708365](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d708365aca9dfd5c3afda1a1de4303e0bd1d263e))
* use .is_(True) and add or 0 guards per code review ([69b5ca0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69b5ca06701e7381c39448e2bf6b927f0558058c))
* use direct is_trial access, add missing error codes to promo APIs ([69a9899](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69a9899d40dda83e83cbdba1aa43d9d1f756704b))
* use float instead of int | float (PYI041) ([310edae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/310edae013973d8533051088f3720cc5da3651b5))
* use SAVEPOINT instead of full rollback in sync user creation ([2a90f87](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2a90f871b97b2b7ee8289e62294c65f8becb2539))
## [3.20.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.20.0...v3.20.1) (2026-02-25)
### Bug Fixes
* make migrations 0010/0011 idempotent, escape HTML in crash notification ([a696896](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a696896d2c4a3d0d6026398fcdc76ded9575375d))
* prevent race condition expiring active daily subscriptions ([bfef7cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfef7cc6296e296f17068e519469c3deaddc1b3b))
## [3.20.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.19.0...v3.20.0) (2026-02-25)
### New Features
* add separate Freekassa SBP and card payment methods ([0da0c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0da0c5547d0648a70f848fe77c13d583f4868a52))
* add validation to animation config API ([a15403b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a15403b8b6e1ec1bb5c37fdde646e7790373e860))
### Bug Fixes
* initialize logger in bot_configuration.py ([988d0e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/988d0e5c2f27538135d757187a0b6770f078b1d9))
* remove gemini-effect and noise from allowed background types ([731eb24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/731eb2436428d0e12f1e5ccdebc72cd74fd7c65e))
* resolve ruff lint errors (import sorting, unused variable) ([b2d7abf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2d7abf5bd10a98fd7ad1da50b5072afc65a5b48))
* resolve sync 404 errors, user deletion FK constraint, and device limit not sent to RemnaWave ([1ce9174](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ce91749aa12ffcefcf66bea714cea218739f3fe))
## [3.19.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.18.0...v3.19.0) (2026-02-25)
### New Features
* add granular user permissions (balance, subscription, promo_group, referral, send_offer) ([60c4fe2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/60c4fe2e239d8fef7726cac769711c8fcce789eb))
* add per-channel disable settings and fix CHANNEL_REQUIRED_FOR_ALL bug ([3642462](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3642462670c876052aa668c1515af8c04234cb34))
* add RBAC + ABAC permission system for admin cabinet ([3fee54f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3fee54f657dc6e0db1ec36697850ada2235e6968))
* add resource_type and request body to audit log entries ([388fc7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/388fc7ee67f5fc0edf6b7b64b977e12a2d8f0566))
* allow editing system roles ([f6b6e22](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f6b6e22a9528dc05b7fbfa80b63051a75c8e73cd))
* capture query params in audit log details for all requests ([bea9da9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bea9da96d44965fcee5e2eba448960443152d4ea))
### Bug Fixes
* address RBAC review findings (CRITICAL + HIGH) ([1646f04](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1646f04bde47a08f3fd782b7831d40760bd1ba60))
* align RBAC route prefixes with frontend API paths ([5a7dd3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a7dd3f16408f3497a9765e79a540ccdabc50e69))
* always include details in successful audit log entries ([3dc0b93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3dc0b93bdfc85fb97f371dc34e024272766afc65))
* extract real client IP from X-Forwarded-For/X-Real-IP headers ([af6686c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af6686ccfae12876e867cdabe729d0c893bd85a1))
* grant legacy config-based admins full RBAC access ([8893fc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8893fc128e3d8927054f1df1647e896e780c69e7))
* improve campaign notifications and ticket media in admin topics ([a594a0f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a594a0f79f48227f75d6102b4586179102c4d344))
* RBAC API response format fixes and audit log user info ([4598c27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4598c2785a42773ee8be04ada1c00d14824e07e0))
* RBAC audit log action filter and legacy admin level ([c1da8a4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c1da8a4dba5d0c993d3e15b2866bdcfa09de1752))
* restore subscription_url and crypto_link after panel sync ([26efb15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26efb157e476a18b036d09167628a295d7e4c10b))
* specify foreign_keys on User.admin_roles_rel to resolve ambiguous join ([bc7d061](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc7d0612f1476f2fdb498cd76a9374b41fd9440a))
* stack promo group + promo offer discounts in bot (matching cabinet) ([628997f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/628997fb48413cc4fae9ac491d1c7f6185877200))
## [3.18.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.17.1...v3.18.0) (2026-02-24)
### New Features
* add ChatTypeFilterMiddleware to ignore group/forum messages ([25f014f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25f014fd8988b5513fba8fec4483981384687e96))
* add multi-channel mandatory subscription system ([8375d7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8375d7ecc5e54ea935a00175dd26f667eab95346))
* add required channels button to admin settings submenu in bot ([3af07ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3af07ff627fc354da4f8c41b0bd0575dddd9afa5))
* colored channel subscription buttons via Bot API 9.4 style ([0b3b2e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b3b2e5dc54d8b6b3ede883d5c0f5b91791b7b9b))
* rework guide mode with Remnawave API integration ([5a269b2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a269b249e8e6cad266822095676937481613f5f))
### Bug Fixes
* add missing CHANNEL_CHECK_NOT_SUBSCRIBED localization key ([a47ef67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a47ef67090c4e48f466286f7c676eeee0c61a4fb))
* address code review issues in guide mode rework ([fae6f71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fae6f71def421e319733e4edcf1ca80a2831b2ec))
* address security review findings ([6feec1e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6feec1eaa847644ba3402763a2ffefd8f770cc01))
* callback routing safety and cache invalidation order ([6a50013](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a50013c21de199df0ba0dab3600b693548b6c1e))
* correct broadcast button deep-links for cabinet mode ([e5fa45f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e5fa45f74f969b84f9f1388f8d4888d22c46d7e8))
* HTML-escape all externally-sourced text in guide messages ([711ec34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/711ec344c646844401f355695a7e8c0d4fb401ee))
* improve deduplication log message wording in monitoring service ([2aead9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2aead9a68b6bf274c8d1497c85f2ed4d4fc9c70b))
* invalidate app config cache on local file saves ([978726a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/978726a7856cf56257c49491afe569fa8c395eac))
* pre-existing bugs found during review ([1bb939f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bb939f63a360a687fafba26bc363024df0f6be0))
* remove [@username](https://github.com/username) channel ID input, auto-prefix -100 for bare digits ([a7db469](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7db469fd7603e7d8dac3076f5d633da654a3a57))
* restore RemnaWave config management endpoints ([6f473de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f473defef32a6d81cee55ef2cd397d536a784a7))
* translate required channels handler to Russian, add localization keys ([1bc9074](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bc9074c1bcdaba7215065c77aac9dd51db4d7c8))
### Refactoring
* remove legacy app-config.json system ([295d2e8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/295d2e877e43f48e9319ba0b01be959904637000))
## [3.17.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.17.0...v3.17.1) (2026-02-23)
### Bug Fixes
* add diagnostic logging for device_limit sync to RemnaWave ([97b3f89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97b3f899d12c4bf32b6229a3b595f1b9ad611096))
* add int32 overflow guards and strengthen auth validation ([50a931e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/50a931ec363d1842126b90098f93c6cae47a9fac))
* add missing broadcast_history columns and harden subscription logic ([d4c4a8a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d4c4a8a211eaf836024f8d9dcb725f25f514f05e))
* allow tariff switch when less than 1 day remains ([67f3547](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67f3547ae2f40153229d71c1abe7e1213466e5c3))
* cap expected_monthly_referrals to prevent int32 overflow ([2ef6185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2ef618571570edb6011a365af8aa9cd7e3348c2e))
* cross-validate Telegram identity on every authenticated request ([973b3d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/973b3d3d3ff80376c0fd19c531d7aac3ae751df8))
* handle RemnaWave API errors in traffic aggregation ([ed4624c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed4624c6649bdbc04bc850ef63e5c86e26a37ce4))
* migrate all remaining naive timestamp columns to timestamptz ([708bb9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/708bb9eec7ea4360b26709fb2a3f82dd139ed600))
* prevent partner self-referral via own campaign link ([115c0c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/115c0c84c0698591da75d7d3b8fbd8e0fc8541ea))
* protect active paid subscriptions from being disabled in RemnaWave ([1b6bbc7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b6bbc7131341b4afd739e4195f02aa956ead616))
* repair missing DB columns and make backup resilient to schema mismatches ([c20355b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c20355b06df13328f85cc5a6045b3e490419a30a))
* show negative amounts for withdrawals in admin transaction list ([5ee45f9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ee45f97d179ce2d32b3f19eeb6fd01989a30ca7))
* suppress web page preview when logo mode is disabled ([1f4430f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f4430f3af8f3efcc58ef7b562904adcb1640a44))
* uploaded backup restore button not triggering handler ([ebe5083](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebe508302b906f8b56cb230b934fb8566990c684))
* use aiogram 3.x bot.download() instead of document.download() ([205c8d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/205c8d987d93151a17aa0793cb51bd99917aea97))
## [3.17.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.3...v3.17.0) (2026-02-18)
### New Features
* add referral code tracking to all cabinet auth methods + email_templates migration ([18c2477](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18c24771737994f3ae1f832435ed2247ca625aab))
### Bug Fixes
* prevent 'caption is too long' error in logo mode ([6e28a1a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6e28a1a22b02055b357051dfecbee7fefbebc774))
* skip blocked users in trial notifications and broadcasts without DB status change ([493f315](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/493f315a65610826a04e04c3d2065e0b395426ed))
## [3.16.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.2...v3.16.3) (2026-02-18)
### Bug Fixes
* 3 user deletion bugs — type cast, inner savepoint, lazy load ([af31c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af31c551d2f23ef01425bdb2db8f255dbc3047e2))
* auth middleware catches all commit errors, not just connection errors ([6409b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6409b0c023cd7957c43d5c1c3d83e671ccaf959c))
* connected_squads stores UUIDs, not int IDs — use get_server_ids_by_uuids ([d7039d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7039d75a47fbf67436a9d39f2cd9f65f2646544))
* deadlock on user deletion + robust migration 0002 ([b7b83ab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7b83abb723913b3167e7462ff592a374c3f421b))
* eliminate deadlock by matching lock order with webhook ([d651a6c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d651a6c02f501b7a0ded570f2db6addcc16173a9))
* make migration 0002 robust with table existence checks ([f076269](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f076269c323726c683a38db092d907591a26e647))
* wrap user deletion steps in savepoints to prevent transaction cascade abort ([a38dfcb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a38dfcb75a47a185d979a8202f637d8b79812e67))
## [3.16.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.1...v3.16.2) (2026-02-18)
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.16.2" # x-release-please-version
ARG VERSION="v3.21.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
-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": "-"
}
}
}
]
}
}
+15 -10
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,10 +59,12 @@ from app.handlers.admin import (
users as admin_users,
welcome_text as admin_welcome_text,
)
from app.handlers.channel_member import register_handlers as register_channel_member_handlers
from app.handlers.stars_payments import register_stars_handlers
from app.middlewares.auth import AuthMiddleware
from app.middlewares.blacklist import BlacklistMiddleware
from app.middlewares.button_stats import ButtonStatsMiddleware
from app.middlewares.chat_type_filter import ChatTypeFilterMiddleware
from app.middlewares.context_binding import ContextVarsMiddleware
from app.middlewares.global_error import GlobalErrorMiddleware
from app.middlewares.logging import LoggingMiddleware
@@ -115,6 +118,9 @@ 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(GlobalErrorMiddleware())
dp.callback_query.middleware(GlobalErrorMiddleware())
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
@@ -126,8 +132,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
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,15 +142,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(button_stats_middleware)
logger.info('📊 ButtonStatsMiddleware активирован')
if settings.CHANNEL_IS_REQUIRED_SUB:
from app.middlewares.channel_checker import ChannelCheckerMiddleware
from app.middlewares.channel_checker import ChannelCheckerMiddleware
channel_checker_middleware = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker_middleware)
dp.callback_query.middleware(channel_checker_middleware)
logger.info('🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован')
else:
logger.info('🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован')
channel_checker = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker)
dp.callback_query.middleware(channel_checker)
dp.message.middleware(AuthMiddleware())
dp.callback_query.middleware(AuthMiddleware())
dp.pre_checkout_query.middleware(AuthMiddleware())
@@ -194,6 +197,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_bulk_ban.register_bulk_ban_handlers(dp)
admin_blacklist.register_blacklist_handlers(dp)
admin_blocked_users.register_handlers(dp)
admin_required_channels.register_handlers(dp)
register_channel_member_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_contests.register_handlers(dp)
+19 -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)
+130 -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,38 @@ 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) -> 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.
"""
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) or data.get('provider') != provider:
return None
return data
# --- Provider implementations ---
@@ -130,13 +151,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 +187,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 +200,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 +245,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 +257,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 +311,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 +323,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 +365,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 +439,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'),
)
+189 -53
View File
@@ -1,10 +1,7 @@
"""FastAPI dependencies for cabinet module."""
import asyncio
import structlog
from aiogram import Bot
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,23 +13,13 @@ from app.services.blacklist_service import blacklist_service
from app.services.maintenance_service import maintenance_service
from .auth.jwt_handler import get_token_payload
from .auth.telegram_auth import validate_telegram_init_data
logger = structlog.get_logger(__name__)
security = HTTPBearer(auto_error=False)
# Кешированный Bot для проверки подписки на канал
_channel_check_bot: Bot | None = None
def _get_channel_check_bot() -> Bot:
"""Получить или создать Bot для проверки подписки на канал."""
global _channel_check_bot
if _channel_check_bot is None:
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
return _channel_check_bot
async def get_cabinet_db() -> AsyncSession:
"""Get database session for cabinet operations."""
@@ -44,6 +31,7 @@ async def get_cabinet_db() -> AsyncSession:
async def get_current_cabinet_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
@@ -51,6 +39,7 @@ async def get_current_cabinet_user(
Get current authenticated cabinet user from JWT token.
Args:
request: FastAPI request object (for reading X-Telegram-Init-Data header)
credentials: HTTP Bearer credentials
db: Database session
@@ -105,6 +94,34 @@ async def get_current_cabinet_user(
detail='User account is not active',
)
# Defense in depth: cross-validate Telegram identity.
# The frontend sends X-Telegram-Init-Data on every request.
# If the header is present and cryptographically valid, verify that
# the Telegram user ID matches the JWT user's telegram_id.
# This prevents cross-account token reuse when Telegram WebView
# shares localStorage across accounts on the same device.
init_data_raw = request.headers.get('X-Telegram-Init-Data')
if init_data_raw and user.telegram_id is not None:
# Use generous max_age: Telegram Desktop caches initData
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
if tg_user is None:
logger.warning(
'Telegram initData validation failed but header was present',
jwt_user_id=user.id,
)
elif tg_user.get('id') != user.telegram_id:
logger.warning(
'Telegram identity mismatch: JWT belongs to different user than current Telegram account',
jwt_user_id=user.id,
jwt_telegram_id=user.telegram_id,
init_data_telegram_id=tg_user.get('id'),
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Session belongs to a different Telegram account. Please restart the app.',
headers={'WWW-Authenticate': 'Bearer'},
)
# Check blacklist
if user.telegram_id is not None:
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
@@ -132,47 +149,37 @@ async def get_current_cabinet_user(
},
)
# Check required channel subscription - ТОЛЬКО для Telegram юзеров
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
# Пропускаем проверку для email-only юзеров (нет telegram_id)
# Check required channel subscription - Telegram users only
if settings.CHANNEL_IS_REQUIRED_SUB:
# Skip for email-only users (no telegram_id)
if user.telegram_id is not None:
# Проверяем админа по telegram_id ИЛИ email
# Skip admin check
is_admin = settings.is_admin(
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
)
if not is_admin:
try:
bot = _get_channel_check_bot()
chat_member = await asyncio.wait_for(
bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=user.telegram_id),
timeout=10.0,
)
# Не закрываем сессию - бот переиспользуется
from app.services.channel_subscription_service import channel_subscription_service
if chat_member.status not in ['member', 'administrator', 'creator']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to our channel to continue',
'channel_link': settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except TimeoutError:
logger.warning('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
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 +207,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
ip_address = (
request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
or request.headers.get('X-Real-IP', '').strip()
or (request.client.host if request.client else None)
)
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=ip_address,
)
if not allowed:
await PermissionService.log_action(
db,
user_id=user.id,
action=perm,
resource_type=resource_type,
status='denied',
ip_address=ip_address,
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=ip_address,
user_agent=user_agent,
request_method=request.method,
request_path=str(request.url.path),
details=details,
)
await db.commit()
return user
return dependency
+11 -1
View File
@@ -3,18 +3,23 @@
from fastapi import APIRouter
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_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_channels import router as admin_channels_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_partners import router as admin_partners_router
from .admin_payment_methods import router as admin_payment_methods_router
from .admin_payments import router as admin_payments_router
from .admin_pinned_messages import router as admin_pinned_messages_router
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_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
@@ -79,11 +84,11 @@ router.include_router(wheel_router)
router.include_router(admin_ticket_notifications_router)
router.include_router(admin_tickets_router)
router.include_router(admin_settings_router)
router.include_router(admin_apps_router)
router.include_router(admin_wheel_router)
router.include_router(admin_tariffs_router)
router.include_router(admin_servers_router)
router.include_router(admin_stats_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)
@@ -101,6 +106,11 @@ router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
router.include_router(admin_pinned_messages_router)
router.include_router(admin_button_styles_router)
router.include_router(admin_channels_router)
router.include_router(admin_apps_router)
router.include_router(admin_roles_router)
router.include_router(admin_policies_router)
router.include_router(admin_audit_log_router)
# WebSocket route
router.include_router(websocket_router)
+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()
+12 -12
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,
@@ -247,7 +247,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."""
@@ -310,7 +310,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."""
@@ -333,7 +333,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)
@@ -352,7 +352,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."""
@@ -381,7 +381,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."""
@@ -461,7 +461,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),
@@ -487,7 +487,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."""
@@ -523,7 +523,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."""
@@ -548,7 +548,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)."""
@@ -679,7 +679,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."""
@@ -695,7 +695,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)."""
+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."""
+140 -102
View File
@@ -7,7 +7,7 @@ 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,
@@ -29,9 +29,11 @@ from app.database.models import (
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,
@@ -54,20 +56,9 @@ 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 _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 _safe_div(value: float | None, divisor: int = 100) -> float:
"""Safely divide kopeks to rubles, handling None values."""
return (value or 0) / divisor
def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
@@ -80,35 +71,44 @@ def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
@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."""
@@ -126,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."""
@@ -155,7 +155,7 @@ async def get_available_tariffs(
@router.get('/available-partners', response_model=list[AvailablePartnerItem])
async def get_available_partners(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of approved partners for campaign partner selector."""
@@ -178,12 +178,12 @@ 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:
@@ -211,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."""
@@ -236,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,
@@ -249,53 +249,89 @@ async def get_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)
@@ -303,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."""
@@ -381,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."""
@@ -411,7 +447,7 @@ async def create_new_campaign(
# Validate partner exists and is approved
if request.partner_user_id is not None:
partner_user = await db.get(User, request.partner_user_id)
if not partner_user or partner_user.partner_status != 'approved':
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',
@@ -434,9 +470,6 @@ async def create_new_campaign(
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)
@@ -446,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."""
@@ -478,29 +511,29 @@ 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)
@@ -509,7 +542,7 @@ async def update_existing_campaign(
new_partner_id = request.partner_user_id
if new_partner_id is not None:
partner_user = await db.get(User, new_partner_id)
if not partner_user or partner_user.partner_status != 'approved':
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',
@@ -532,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."""
@@ -543,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,
@@ -560,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()
+7 -7
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,
@@ -370,7 +370,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 +405,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 +479,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 +515,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 +543,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]
@@ -588,7 +588,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."""
+49 -24
View File
@@ -20,7 +20,7 @@ from app.database.models import (
from app.services.partner_application_service import partner_application_service
from app.services.partner_stats_service import PartnerStatsService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.partners import (
AdminApproveRequest,
AdminPartnerApplicationItem,
@@ -73,7 +73,7 @@ def _build_partner_settings_response() -> PartnerSettingsResponse:
@router.get('/settings', response_model=PartnerSettingsResponse)
async def get_partner_settings(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:settings')),
):
"""Get partner system settings."""
return _build_partner_settings_response()
@@ -82,7 +82,7 @@ async def get_partner_settings(
@router.patch('/settings', response_model=PartnerSettingsResponse)
async def update_partner_settings(
request: PartnerSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:settings')),
):
"""Update partner system settings."""
from pathlib import Path
@@ -159,7 +159,7 @@ async def list_applications(
application_status: Literal['pending', 'approved', 'rejected', 'none'] | None = Query(None, alias='status'),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List partner applications."""
@@ -190,6 +190,7 @@ async def list_applications(
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,
@@ -205,7 +206,7 @@ async def list_applications(
async def approve_application(
application_id: int,
request: AdminApproveRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a partner application."""
@@ -259,7 +260,7 @@ async def approve_application(
async def reject_application(
application_id: int,
request: AdminRejectRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a partner application."""
@@ -310,7 +311,7 @@ async def reject_application(
@router.get('/stats')
async def get_partner_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get overall partner statistics."""
@@ -340,7 +341,7 @@ async def get_partner_stats(
async def list_partners(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List approved partners."""
@@ -404,7 +405,7 @@ async def list_partners(
@router.get('/{user_id}', response_model=AdminPartnerDetailResponse)
async def get_partner_detail(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed partner info."""
@@ -417,17 +418,24 @@ async def get_partner_detail(
stats = await PartnerStatsService.get_referrer_detailed_stats(db, user_id)
# Get assigned campaigns
# 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
]
@@ -460,7 +468,7 @@ async def get_partner_detail(
async def update_commission(
user_id: int,
request: AdminUpdateCommissionRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update partner commission percent."""
@@ -495,7 +503,7 @@ async def update_commission(
@router.post('/{user_id}/revoke')
async def revoke_partner(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:revoke')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Revoke partner status."""
@@ -514,7 +522,7 @@ async def revoke_partner(
async def assign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Assign a campaign to a partner."""
@@ -551,6 +559,12 @@ async def assign_campaign(
)
await db.commit()
logger.info(
'Кампания привязана к партнёру',
campaign_id=campaign_id,
partner_user_id=user_id,
admin_id=admin.id,
)
return {'success': True}
@@ -558,25 +572,36 @@ async def assign_campaign(
async def unassign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('partners:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unassign a campaign from a partner."""
campaign = await db.get(AdvertisingCampaign, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Кампания не найдена',
# 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,
)
if campaign.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='Кампания не привязана к этому партнёру',
)
campaign.partner_user_id = None
campaign.updated_at = datetime.now(UTC)
await db.commit()
logger.info(
'Кампания откреплена от партнёра',
campaign_id=campaign_id,
partner_user_id=user_id,
admin_id=admin.id,
)
return {'success': True}
+6 -6
View File
@@ -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__)
@@ -124,7 +124,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 +135,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 +146,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 +163,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 +176,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."""
+5 -5
View File
@@ -19,7 +19,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__)
@@ -272,7 +272,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 +306,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."""
@@ -329,7 +329,7 @@ async def get_payments_stats(
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."""
@@ -356,7 +356,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."""
+12 -12
View File
@@ -22,7 +22,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,
@@ -89,7 +89,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 +117,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 +130,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 +147,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 +201,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 +240,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 +267,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 +282,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 +311,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 +360,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 +391,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}
+7 -7
View File
@@ -34,7 +34,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__)
@@ -272,7 +272,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 +288,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 +302,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 +338,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),
@@ -491,7 +491,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 +605,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),
+12 -12
View File
@@ -30,7 +30,7 @@ from app.database.crud.promocode import (
)
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'])
@@ -305,7 +305,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),
@@ -326,7 +326,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."""
@@ -349,7 +349,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."""
@@ -399,7 +399,7 @@ async def create_promocode_endpoint(
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."""
@@ -460,7 +460,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,7 +486,7 @@ 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."""
@@ -537,7 +537,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 +561,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 +576,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 +608,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 +645,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."""
+30 -30
View File
@@ -16,7 +16,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
@@ -153,7 +153,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 +176,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()
@@ -238,7 +238,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 +252,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 +278,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 +290,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 +309,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 +335,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 +358,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()
@@ -399,7 +399,7 @@ async def perform_node_action(
@router.post('/nodes/restart-all', response_model=NodeActionResponse)
async def restart_all_nodes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> NodeActionResponse:
"""Restart all nodes."""
service = _get_service()
@@ -421,7 +421,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 +463,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 +506,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 +533,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 +564,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 +609,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 +632,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 +657,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 +731,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 +746,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 +775,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 +811,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 +839,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 +863,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 +882,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 +925,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 +944,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 +963,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 +982,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."""
+503
View File
@@ -0,0 +1,503 @@
"""Admin RBAC roles management routes."""
from __future__ import annotations
from datetime import datetime
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 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 maximum role level of the current admin.
Legacy config-based admins (ADMIN_IDS) get superadmin level (999+1=1000)
so they can manage all roles including level 999.
"""
from app.config import settings
_perms, _names, max_level = await UserRoleCRUD.get_user_permissions(db, admin.id)
# 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, 1000)
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('/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)
# 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',
)
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. Cannot remove the last superadmin."""
from sqlalchemy import select as sa_select
from app.database.models import UserRole
# Load the assignment to check hierarchy
result = await db.execute(sa_select(UserRole).where(UserRole.id == assignment_id))
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',
)
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',
)
# Protect last superadmin (level 999)
superadmin_level = 999
if role.level == superadmin_level:
superadmin_count = await UserRoleCRUD.get_superadmin_count(db)
if superadmin_count <= 1:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot remove the last superadmin',
)
revoked = await UserRoleCRUD.revoke_role(db, assignment_id)
if not revoked:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to revoke role',
)
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."""
+9 -56
View File
@@ -26,7 +26,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__)
@@ -246,7 +246,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."""
@@ -326,7 +326,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 +358,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 +374,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 +401,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:
@@ -596,7 +596,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 +686,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 +765,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 +822,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."""
+11 -11
View File
@@ -19,7 +19,7 @@ from app.database.crud.tariff import (
)
from app.database.models import PromoGroup, Subscription, 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 (
PeriodPrice,
PromoGroupInfo,
@@ -107,7 +107,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."""
@@ -141,7 +141,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."""
@@ -161,7 +161,7 @@ async def get_available_servers(
@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 +176,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."""
@@ -246,7 +246,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."""
@@ -307,7 +307,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."""
@@ -400,7 +400,7 @@ async def update_existing_tariff(
@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 +430,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 +460,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 +500,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."""
+9 -9
View File
@@ -16,7 +16,7 @@ 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 ..dependencies import get_cabinet_db, require_permission
from ..schemas.tickets import TicketMessageResponse
@@ -197,7 +197,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 +222,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,7 +242,7 @@ 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."""
@@ -337,7 +337,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 +386,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 +428,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."""
@@ -497,7 +497,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 +556,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."""
+11 -5
View File
@@ -20,7 +20,7 @@ from app.config import settings
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,
@@ -99,7 +99,13 @@ async def _aggregate_traffic(
user_uuids_set = set(user_uuids)
async with service.get_api_client() as api:
nodes = await api.get_all_nodes()
try:
nodes = await api.get_all_nodes()
except Exception:
logger.warning('Failed to fetch nodes for traffic aggregation', exc_info=True)
# Cache empty result to avoid hammering the failing API
_traffic_cache[cache_key] = (now, {}, [])
return {}, []
# Fetch per-node user stats — O(nodes) calls instead of O(users)
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
@@ -256,7 +262,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),
@@ -491,7 +497,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 +530,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."""
+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
+147 -64
View File
@@ -26,6 +26,7 @@ from app.database.crud.user import (
)
from app.database.models import (
PromoGroup,
ReferralEarning,
Subscription,
SubscriptionServer,
SubscriptionStatus,
@@ -37,7 +38,7 @@ from app.database.models import (
)
from app.utils.timezone import panel_datetime_to_utc
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.users import (
DeleteDeviceResponse,
DeleteUserRequest,
@@ -205,10 +206,17 @@ async def _build_subscription_info_async(db: AsyncSession, subscription: Subscri
return info
async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription: Subscription) -> dict:
async def _sync_subscription_to_panel(
db: AsyncSession,
user: User,
subscription: Subscription,
reset_traffic: bool = False,
reset_traffic_reason: str | None = None,
) -> dict:
"""
Sync user subscription to Remnawave panel.
Creates user if not exists, updates if exists.
Optionally resets traffic after sync.
Returns dict with changes/errors.
"""
try:
@@ -297,7 +305,10 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
update_kwargs['hwid_device_limit'] = hwid_limit
try:
await api.update_user(**update_kwargs)
updated_panel_user = await api.update_user(**update_kwargs)
subscription.subscription_url = updated_panel_user.subscription_url
subscription.subscription_crypto_link = updated_panel_user.happ_crypto_link
subscription.remnawave_short_uuid = updated_panel_user.short_uuid
changes['action'] = 'updated'
logger.info('Updated user in Remnawave panel', user_id=user.id)
except Exception as update_error:
@@ -326,10 +337,21 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
user.remnawave_uuid = new_panel_user.uuid
subscription.remnawave_short_uuid = new_panel_user.short_uuid
subscription.subscription_url = new_panel_user.subscription_url
subscription.subscription_crypto_link = new_panel_user.happ_crypto_link
changes['action'] = 'created'
changes['panel_uuid'] = new_panel_user.uuid
logger.info('Created user in Remnawave panel', user_id=user.id, uuid=new_panel_user.uuid)
# Reset traffic on panel if requested
if reset_traffic and user.remnawave_uuid:
try:
await api.reset_user_traffic(user.remnawave_uuid)
changes['traffic_reset'] = True
reason_text = f' ({reset_traffic_reason})' if reset_traffic_reason else ''
logger.info('Reset RemnaWave traffic for user', user_id=user.id, reason=reason_text)
except Exception as reset_exc:
logger.warning('Failed to reset RemnaWave traffic', user_id=user.id, error=reset_exc)
user.last_remnawave_sync = datetime.now(UTC)
await db.commit()
@@ -351,7 +373,7 @@ async def list_users(
email: str | None = Query(None, max_length=255),
status: UserStatusEnum | None = Query(None),
sort_by: SortByEnum = Query(SortByEnum.CREATED_AT),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -408,7 +430,7 @@ async def list_users(
@router.get('/stats', response_model=UsersStatsResponse)
async def get_users_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get overall users statistics."""
@@ -509,7 +531,7 @@ async def get_users_stats(
@router.get('/{user_id}', response_model=UserDetailResponse)
async def get_user_detail(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed user information by ID."""
@@ -542,11 +564,9 @@ async def get_user_detail(
referrals = await get_referrals(db, user.id)
referrals_count = len(referrals)
# Calculate total referral earnings
referral_earnings_q = select(func.sum(Transaction.amount_kopeks)).where(
Transaction.user_id == user.id,
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.is_completed == True,
# Calculate total referral earnings (canonical source: ReferralEarning)
referral_earnings_q = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == user.id
)
referral_earnings = (await db.execute(referral_earnings_q)).scalar() or 0
@@ -575,12 +595,14 @@ async def get_user_detail(
transactions_result = await db.execute(transactions_q)
transactions = transactions_result.scalars().all()
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
recent_transactions = [
UserTransactionItem(
id=t.id,
type=t.type,
amount_kopeks=t.amount_kopeks,
amount_rubles=t.amount_kopeks / 100,
amount_kopeks=-t.amount_kopeks if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=-t.amount_kopeks / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
@@ -619,7 +641,7 @@ async def get_user_detail(
referral=referral_info,
total_spent_kopeks=user_stats.get('total_spent', 0),
purchase_count=user_stats.get('purchase_count', 0),
used_promocodes=user.used_promocodes,
used_promocodes=user.used_promocodes or 0,
has_had_paid_subscription=user.has_had_paid_subscription,
lifetime_used_traffic_bytes=user.lifetime_used_traffic_bytes or 0,
campaign_name=campaign_name,
@@ -638,7 +660,7 @@ async def get_user_detail(
@router.get('/by-telegram/{telegram_id}', response_model=UserDetailResponse)
async def get_user_by_telegram(
telegram_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user by Telegram ID."""
@@ -657,7 +679,7 @@ async def get_user_by_telegram(
@router.get('/{user_id}/panel-info', response_model=UserPanelInfoResponse)
async def get_user_panel_info(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user panel info from Remnawave (config links, traffic, connection data)."""
@@ -735,7 +757,7 @@ async def get_user_panel_info(
@router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse)
async def get_user_node_usage(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user per-node traffic usage (always 30 days with daily breakdown)."""
@@ -823,7 +845,7 @@ async def get_user_node_usage(
async def update_user_balance(
user_id: int,
request: UpdateBalanceRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:balance')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -900,7 +922,7 @@ async def update_user_balance(
async def update_user_subscription(
user_id: int,
request: UpdateSubscriptionRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:subscription')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1050,11 +1072,35 @@ async def update_user_subscription(
# Set squads from tariff
if tariff.allowed_squads:
subscription.connected_squads = tariff.allowed_squads
# Сбрасываем докупленный трафик при смене тарифа
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
from app.config import settings
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
subscription.traffic_used_gb = 0.0
await db.commit()
await db.refresh(subscription)
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
# Синхронизируем с RemnaWave (discovery/create + сброс трафика по админ-настройке)
try:
await _sync_subscription_to_panel(
db,
user,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_traffic_reason='смена тарифа (cabinet admin)',
)
except Exception as e:
logger.error('Failed to sync tariff switch with RemnaWave', error=e)
logger.info('Admin changed tariff for user to', admin_id=admin.id, user_id=user_id, tariff_name=tariff.name)
@@ -1267,7 +1313,7 @@ async def update_user_subscription(
async def get_user_available_tariffs(
user_id: int,
include_inactive: bool = Query(False, description='Include inactive tariffs'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1365,7 +1411,7 @@ async def get_user_available_tariffs(
async def update_user_status(
user_id: int,
request: UpdateUserStatusRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user status (active, blocked, deleted)."""
@@ -1410,7 +1456,7 @@ async def update_user_status(
async def block_user(
user_id: int,
reason: str | None = None,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Block a user (shortcut for status update)."""
@@ -1421,7 +1467,7 @@ async def block_user(
@router.post('/{user_id}/unblock', response_model=UpdateUserStatusResponse)
async def unblock_user(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unblock a user (shortcut for status update)."""
@@ -1436,7 +1482,7 @@ async def unblock_user(
async def update_user_restrictions(
user_id: int,
request: UpdateRestrictionsRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user restrictions (topup, subscription)."""
@@ -1484,7 +1530,7 @@ async def update_user_restrictions(
async def update_user_promo_group(
user_id: int,
request: UpdatePromoGroupRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:promo_group')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user promo group."""
@@ -1539,7 +1585,7 @@ async def update_user_promo_group(
async def update_user_referral_commission(
user_id: int,
request: UpdateReferralCommissionRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:referral')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user's individual referral commission percentage."""
@@ -1577,7 +1623,7 @@ async def update_user_referral_commission(
@router.get('/{user_id}/devices', response_model=UserDevicesResponse)
async def get_user_devices(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user devices from Remnawave panel."""
@@ -1631,7 +1677,7 @@ async def get_user_devices(
async def delete_user_device(
user_id: int,
hwid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a single device for user."""
@@ -1662,7 +1708,7 @@ async def delete_user_device(
@router.delete('/{user_id}/devices', response_model=ResetDevicesResponse)
async def reset_user_devices(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset all devices for user."""
@@ -1710,7 +1756,7 @@ async def reset_user_devices(
async def delete_user(
user_id: int,
request: DeleteUserRequest = DeleteUserRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1748,7 +1794,7 @@ async def delete_user(
async def full_delete_user(
user_id: int,
request: FullDeleteUserRequest = FullDeleteUserRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1771,15 +1817,18 @@ async def full_delete_user(
panel_error: str | None = None
deleted_from_panel = False
# Pre-fetch admin.id to avoid MissingGreenlet after transaction rollback
admin_id_val = admin.id
# UserService.delete_user_account handles both bot DB and Remnawave panel
user_service = UserService()
success = await user_service.delete_user_account(db, user_id, admin.id)
success = await user_service.delete_user_account(db, user_id, admin_id_val)
if success:
deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info('Admin fully deleted user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
logger.info('Admin fully deleted user', admin_id=admin_id_val, user_id=user_id, reason_text=reason_text)
return FullDeleteUserResponse(
success=success,
@@ -1794,7 +1843,7 @@ async def full_delete_user(
async def reset_user_trial(
user_id: int,
request: ResetTrialRequest = ResetTrialRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:subscription')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1816,24 +1865,33 @@ async def reset_user_trial(
# Delete subscription if exists
if user.subscription:
# Deactivate in Remnawave panel first
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
from app.database.crud.subscription import is_active_paid_subscription
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск удаления подписки и RemnaWave: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
else:
# Deactivate in Remnawave panel first
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
# Delete subscription from database
from sqlalchemy import delete
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
subscription_id = user.subscription.id
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
# Delete subscription from database
from sqlalchemy import delete
subscription_id = user.subscription.id
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
# Reset trial flag
user.has_used_trial = False
@@ -1856,7 +1914,7 @@ async def reset_user_trial(
async def reset_user_subscription(
user_id: int,
request: ResetSubscriptionRequest = ResetSubscriptionRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:subscription')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1886,6 +1944,21 @@ async def reset_user_subscription(
panel_deactivated=False,
)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск сброса подписки: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
return ResetSubscriptionResponse(
success=False,
message='Cannot reset active paid subscription. Subscription is still active and paid.',
subscription_deleted=False,
panel_deactivated=False,
)
# Deactivate in Remnawave panel if requested
if request.deactivate_in_panel and user.remnawave_uuid:
try:
@@ -1926,7 +1999,7 @@ async def reset_user_subscription(
async def disable_user(
user_id: int,
request: DisableUserRequest = DisableUserRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1948,8 +2021,16 @@ async def disable_user(
panel_deactivated = False
panel_error: str | None = None
# Deactivate subscription in panel
if user.remnawave_uuid:
# Deactivate subscription in panel (skip if active paid subscription)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск отключения RemnaWave: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
elif user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
@@ -1961,8 +2042,8 @@ async def disable_user(
panel_error = str(e)
logger.warning('Failed to disable Remnawave user', error=e)
# Deactivate subscription in bot database
if user.subscription:
# Deactivate subscription in bot database (skip if active paid subscription)
if user.subscription and not is_active_paid_subscription(user.subscription):
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, user.subscription)
@@ -1995,7 +2076,7 @@ async def get_user_referrals(
user_id: int,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of users referred by this user."""
@@ -2035,7 +2116,7 @@ async def get_user_transactions(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
transaction_type: str | None = Query(None),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user transactions."""
@@ -2062,12 +2143,14 @@ async def get_user_transactions(
result = await db.execute(query)
transactions = result.scalars().all()
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
items = [
UserTransactionItem(
id=t.id,
type=t.type,
amount_kopeks=t.amount_kopeks,
amount_rubles=t.amount_kopeks / 100,
amount_kopeks=-t.amount_kopeks if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=-t.amount_kopeks / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
@@ -2090,7 +2173,7 @@ async def get_user_transactions(
@router.get('/{user_id}/sync/status', response_model=PanelSyncStatusResponse)
async def get_user_sync_status(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:sync')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -2247,7 +2330,7 @@ async def get_user_sync_status(
async def sync_user_from_panel(
user_id: int,
request: SyncFromPanelRequest = SyncFromPanelRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:sync')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -2449,7 +2532,7 @@ async def sync_user_from_panel(
async def sync_user_to_panel(
user_id: int,
request: SyncToPanelRequest = SyncToPanelRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:sync')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
+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),
):
"""Получить все спины с фильтрами."""
+6 -6
View File
@@ -16,7 +16,7 @@ from app.database.models import (
)
from app.services.referral_withdrawal_service import referral_withdrawal_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.withdrawals import (
AdminApproveWithdrawalRequest,
AdminRejectWithdrawalRequest,
@@ -49,7 +49,7 @@ async def list_withdrawals(
),
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('withdrawals:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all withdrawal requests."""
@@ -123,7 +123,7 @@ async def list_withdrawals(
@router.get('/{withdrawal_id}', response_model=AdminWithdrawalDetailResponse)
async def get_withdrawal_detail(
withdrawal_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('withdrawals:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed withdrawal request with risk analysis."""
@@ -180,7 +180,7 @@ async def get_withdrawal_detail(
async def approve_withdrawal(
withdrawal_id: int,
request: AdminApproveWithdrawalRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('withdrawals:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a withdrawal request."""
@@ -232,7 +232,7 @@ async def approve_withdrawal(
async def reject_withdrawal(
withdrawal_id: int,
request: AdminRejectWithdrawalRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('withdrawals:reject')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a withdrawal request."""
@@ -283,7 +283,7 @@ async def reject_withdrawal(
@router.post('/{withdrawal_id}/complete')
async def complete_withdrawal(
withdrawal_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('withdrawals:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark a withdrawal as completed (money transferred)."""
+161 -55
View File
@@ -15,6 +15,7 @@ from app.database.crud.campaign import (
get_campaign_by_start_parameter,
get_campaign_registration_by_user,
)
from app.database.crud.rbac import UserRoleCRUD
from app.database.crud.user import (
clear_email_change_pending,
create_user,
@@ -99,9 +100,17 @@ def _user_to_response(user: User) -> UserResponse:
)
def _create_auth_response(user: User) -> AuthResponse:
"""Create full auth response with tokens."""
access_token = create_access_token(user.id, user.telegram_id)
async def _create_auth_response(user: User, db: AsyncSession) -> AuthResponse:
"""Create full auth response with tokens and RBAC permissions."""
user_permissions, user_role_names, user_role_level = await UserRoleCRUD.get_user_permissions(db, user.id)
access_token = create_access_token(
user.id,
user.telegram_id,
permissions=user_permissions,
roles=user_role_names,
role_level=user_role_level,
)
refresh_token = create_refresh_token(user.id)
expires_in = settings.get_cabinet_access_token_expire_minutes() * 60
@@ -151,6 +160,15 @@ async def _process_campaign_bonus(
if not campaign:
return None
# Skip if user IS the campaign partner — prevent self-referral
if campaign.partner_user_id and campaign.partner_user_id == user.id:
logger.debug(
'Skipping campaign attribution: user is the campaign partner',
user_id=user.id,
campaign_id=campaign.id,
)
return None
# Lock user row to prevent concurrent bonus application (race condition)
await db.execute(select(User).where(User.id == user.id).with_for_update())
@@ -200,6 +218,30 @@ async def _process_campaign_bonus(
return None
async def _process_referral_code(
db: AsyncSession,
user: User,
referral_code: str | None,
) -> None:
"""Set referred_by_id for user if referral_code is valid. Never raises."""
if not referral_code or user.referred_by_id:
return
try:
referrer = await get_user_by_referral_code(db, referral_code)
if not referrer:
return
if referrer.id == user.id:
return
if referrer.email and user.email and referrer.email.lower() == user.email.lower():
return
user.referred_by_id = referrer.id
await db.flush()
await process_referral_registration(db, user.id, referrer.id, bot=None)
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
except Exception as e:
logger.error('Failed to process referral code', error=e, referral_code=referral_code)
async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -> None:
"""
Check if user has subscription in RemnaWave panel by email and sync it.
@@ -341,6 +383,16 @@ async def auth_telegram(
tg_last_name = user_data.get('last_name')
tg_language = user_data.get('language_code', 'ru')
# Resolve referral code to referrer ID for new users
referrer_id = None
if request.referral_code and not user:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
if not user:
# Create new user from Telegram initData
logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
@@ -351,6 +403,7 @@ async def auth_telegram(
first_name=tg_first_name,
last_name=tg_last_name,
language=tg_language,
referred_by_id=referrer_id,
)
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
else:
@@ -378,11 +431,14 @@ async def auth_telegram(
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = _create_auth_response(user)
response = await _create_auth_response(user, db)
# Store refresh token
await _store_refresh_token(db, user.id, response.refresh_token)
# Process referral code (before campaign bonus, which may also set referrer)
await _process_referral_code(db, user, request.referral_code)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
@@ -402,7 +458,7 @@ async def auth_telegram_widget(
This endpoint validates data from Telegram Login Widget and returns
JWT tokens for authenticated access.
"""
widget_data = request.model_dump(exclude={'campaign_slug'})
widget_data = request.model_dump(exclude={'campaign_slug', 'referral_code'})
if not validate_telegram_login_widget(widget_data):
raise HTTPException(
@@ -412,6 +468,16 @@ async def auth_telegram_widget(
user = await get_user_by_telegram_id(db, request.id)
# Resolve referral code to referrer ID for new users
referrer_id = None
if request.referral_code and not user:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
if not user:
# Create new user from Telegram data
logger.info(
@@ -424,6 +490,7 @@ async def auth_telegram_widget(
first_name=request.first_name,
last_name=request.last_name,
language='ru',
referred_by_id=referrer_id,
)
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
@@ -444,9 +511,12 @@ async def auth_telegram_widget(
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = _create_auth_response(user)
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process referral code (before campaign bonus, which may also set referrer)
await _process_referral_code(db, user, request.referral_code)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
@@ -489,53 +559,61 @@ async def register_email(
detail='You already have a verified email',
)
# Generate verification token
verification_token = generate_verification_token()
verification_expires = get_verification_expires_at()
# Update user
user.email = request.email
user.email_verified = False
user.password_hash = hash_password(request.password)
user.email_verification_token = verification_token
user.email_verification_expires = verification_expires
await db.commit()
if not settings.is_cabinet_email_verification_enabled():
# Верификация отключена — сразу помечаем email как verified
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
await db.commit()
else:
# Generate verification token
verification_token = generate_verification_token()
verification_expires = get_verification_expires_at()
# Send verification email asynchronously (smtplib is blocking)
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
cabinet_url = settings.CABINET_URL
verification_url = f'{cabinet_url}/verify-email'
lang = user.language or 'ru'
full_url = f'{verification_url}?token={verification_token}'
expire_hours = settings.get_cabinet_email_verification_expire_hours()
user.email_verified = False
user.email_verification_token = verification_token
user.email_verification_expires = verification_expires
await db.commit()
# Check for admin template override
override = await get_rendered_override(
'email_verification',
lang,
context={
'username': user.first_name or '',
'verification_url': full_url,
'expire_hours': str(expire_hours),
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
# Send verification email asynchronously (smtplib is blocking)
if email_service.is_configured():
cabinet_url = settings.CABINET_URL
verification_url = f'{cabinet_url}/verify-email'
lang = user.language or 'ru'
full_url = f'{verification_url}?token={verification_token}'
expire_hours = settings.get_cabinet_email_verification_expire_hours()
await asyncio.to_thread(
email_service.send_verification_email,
to_email=request.email,
verification_token=verification_token,
verification_url=verification_url,
username=user.first_name,
language=lang,
custom_subject=custom_subject,
custom_body_html=custom_body,
)
# Check for admin template override
override = await get_rendered_override(
'email_verification',
lang,
context={
'username': user.first_name or '',
'verification_url': full_url,
'expire_hours': str(expire_hours),
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
await asyncio.to_thread(
email_service.send_verification_email,
to_email=request.email,
verification_token=verification_token,
verification_url=verification_url,
username=user.first_name,
language=lang,
custom_subject=custom_subject,
custom_body_html=custom_body,
)
return {
'message': 'Verification email sent',
'message': 'Email linked successfully'
if not settings.is_cabinet_email_verification_enabled()
else 'Verification email sent',
'email': request.email,
}
@@ -615,12 +693,12 @@ async def register_email_standalone(
referred_by_id=referrer.id if referrer else None,
)
# Для тестового email - автоматически верифицировать
if is_test_email:
# Для тестового email или отключённой верификации - автоматически верифицировать
if is_test_email or not settings.is_cabinet_email_verification_enabled():
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
await db.commit()
logger.info('Test email auto-verified: user_id', email=request.email, user_id=user.id)
logger.info('Email auto-verified (test or verification disabled)', email=request.email, user_id=user.id)
else:
# Сгенерировать токен верификации
verification_token = generate_verification_token()
@@ -673,11 +751,12 @@ async def register_email_standalone(
# Не прерываем регистрацию из-за ошибки реферальной системы
# Для тестового email - сразу можно логиниться (уже verified)
# Для обычного email - требуется верификация
# Для обычного email - требуется верификация (если включена)
verification_required = not is_test_email and settings.is_cabinet_email_verification_enabled()
return RegisterResponse(
message='Verification email sent. Please check your inbox.',
email=request.email,
requires_verification=not is_test_email,
requires_verification=verification_required,
)
@@ -716,7 +795,7 @@ async def verify_email(
await _sync_subscription_from_panel_by_email(db, user)
# Return auth tokens so user is logged in after verification
response = _create_auth_response(user)
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
@@ -847,8 +926,8 @@ async def login_email(
detail='Invalid email or password',
)
# Test email bypasses verification check
if not user.email_verified and not is_test_email:
# Test email and disabled verification bypass the check
if not user.email_verified and not is_test_email and settings.is_cabinet_email_verification_enabled():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Please verify your email first',
@@ -863,7 +942,7 @@ async def login_email(
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = _create_auth_response(user)
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
@@ -926,7 +1005,14 @@ async def refresh_token(
detail='User not found or inactive',
)
access_token = create_access_token(user.id, user.telegram_id)
user_permissions, user_role_names, user_role_level = await UserRoleCRUD.get_user_permissions(db, user.id)
access_token = create_access_token(
user.id,
user.telegram_id,
permissions=user_permissions,
roles=user_role_names,
role_level=user_role_level,
)
expires_in = settings.get_cabinet_access_token_expire_minutes() * 60
return TokenResponse(
@@ -1050,12 +1136,32 @@ async def get_current_user(
return _user_to_response(user)
@router.get('/me/permissions')
async def get_my_permissions(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get current user's RBAC permissions, roles, and level."""
from app.services.permission_service import PermissionService
return await PermissionService.get_user_permissions(db, user.id, user=user)
@router.get('/me/is-admin')
async def check_is_admin(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Check if current user is an admin."""
"""Check if current user is an admin (legacy config or RBAC)."""
# Legacy check: config-based admin list
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
if not is_admin:
# RBAC check: user has any active role with level > 0
_permissions, _role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
if max_level > 0:
is_admin = True
return {'is_admin': is_admin}
+6
View File
@@ -314,6 +314,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)
+174 -13
View File
@@ -3,18 +3,19 @@
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.models import SystemSetting, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -37,6 +38,17 @@ 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"
ANIMATION_CONFIG_KEY = 'CABINET_ANIMATION_CONFIG' # Stores JSON with animation config
# 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 +133,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."""
@@ -296,7 +394,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 +422,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."""
@@ -387,7 +485,7 @@ 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."""
@@ -459,7 +557,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 +591,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 +631,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 +685,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 +696,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 +783,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."""
@@ -658,7 +819,7 @@ async def get_email_auth_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."""
@@ -694,7 +855,7 @@ async def get_analytics_counters(
@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."""
@@ -758,7 +919,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."""
+66 -22
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
@@ -37,16 +38,21 @@ async def _finalize_oauth_login(
user: User,
provider: str,
campaign_slug: str | None = None,
referral_code: str | None = None,
) -> AuthResponse:
"""Update last login, create tokens, store refresh token."""
user.cabinet_last_login = datetime.now(UTC)
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 (before campaign bonus, which may also set referrer)
from .auth import _process_referral_code, _user_to_response
await _process_referral_code(db, user, referral_code)
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
if auth_response.campaign_bonus:
from .auth import _user_to_response
auth_response.user = _user_to_response(user)
return auth_response
@@ -69,11 +75,13 @@ 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, description='Referral code of inviter')
# --- Endpoints ---
@@ -98,11 +106,13 @@ async def get_oauth_authorize_url(provider: str):
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)
authorize_url = oauth_provider.get_authorization_url(state, **auth_extra)
return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state)
@@ -114,8 +124,9 @@ async def oauth_callback(
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',
@@ -126,14 +137,21 @@ async def oauth_callback(
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=exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to exchange authorization code',
@@ -143,7 +161,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=exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to fetch user information from provider',
@@ -152,18 +170,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 as e:
logger.warning(
'Failed to resolve referral code during OAuth', referral_code=request.referral_code, exc_info=e
)
# 8. Create new user
user = await create_user_by_oauth(
db=db,
provider=provider,
@@ -173,6 +216,7 @@ async def oauth_callback(
first_name=user_info.first_name,
last_name=user_info.last_name,
username=user_info.username,
referred_by_id=referrer_id,
)
logger.info('OAuth new user created via with id', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
logger.info('New OAuth user created', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
+75 -19
View File
@@ -5,16 +5,24 @@ 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,
)
@@ -23,22 +31,6 @@ logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/referral/partner', tags=['Cabinet Partner'])
def _get_campaign_deep_link(start_parameter: str) -> str | None:
"""Generate Telegram deep link for campaign."""
bot_username = settings.get_bot_username()
if bot_username:
return f'https://t.me/{bot_username}?start={start_parameter}'
return None
def _get_campaign_web_link(start_parameter: str) -> str | None:
"""Generate web link for campaign."""
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
if base_url:
return f'{base_url}/?campaign={start_parameter}'
return None
@router.get('/status', response_model=PartnerStatusResponse)
async def get_partner_status(
user: User = Depends(get_current_cabinet_user),
@@ -57,6 +49,7 @@ async def get_partner_status(
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,
@@ -76,7 +69,14 @@ async def get_partner_status(
AdvertisingCampaign.is_active.is_(True),
)
)
for c in result.scalars().all():
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,
@@ -86,8 +86,11 @@ async def get_partner_status(
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),
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),
)
)
@@ -99,6 +102,56 @@ async def get_partner_status(
)
@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,
@@ -114,6 +167,7 @@ async def apply_for_partner(
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:
@@ -140,6 +194,7 @@ async def apply_for_partner(
'website_url': request.website_url,
'description': request.description,
'expected_monthly_referrals': request.expected_monthly_referrals,
'desired_commission_percent': request.desired_commission_percent,
},
)
finally:
@@ -155,6 +210,7 @@ async def apply_for_partner(
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,
+3
View File
@@ -71,7 +71,10 @@ async def activate_promocode(
'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',
'trial_subscription_not_eligible': 'This promo code is not available for trial subscriptions',
'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',
}
+40 -6
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 AdvertisingCampaign, 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,6 +71,26 @@ async def get_referral_info(
if commission_percent is None:
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
# 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 link
bot_username = settings.get_bot_username() or 'bot'
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
@@ -72,6 +103,9 @@ async def get_referral_info(
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,
)
+135 -321
View File
@@ -1,7 +1,6 @@
"""Subscription management routes for cabinet."""
import base64
import json
import re
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -205,7 +204,10 @@ def _subscription_to_response(
if is_daily and not is_daily_paused:
last_charge = getattr(subscription, 'last_daily_charge_at', None)
if last_charge:
next_daily_charge_at = last_charge + timedelta(days=1)
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()
@@ -394,6 +396,12 @@ async def renew_subscription(
db: AsyncSession = Depends(get_cabinet_db),
):
"""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',
)
await db.refresh(user, ['subscription'])
if not user.subscription:
@@ -654,6 +662,12 @@ async def purchase_traffic(
db: AsyncSession = Depends(get_cabinet_db),
):
"""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
@@ -920,6 +934,12 @@ async def purchase_devices_legacy(
DEPRECATED: Use /devices/purchase instead for full tariff and discount support.
"""
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Subscription purchases are restricted for this account',
)
await db.refresh(user, ['subscription'])
if not user.subscription:
@@ -1630,6 +1650,12 @@ async def submit_purchase(
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Submit subscription purchase (deduct from balance, classic mode only)."""
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Subscription purchases are restricted for this account',
)
# This endpoint is for classic mode only, tariffs mode uses /purchase-tariff
if settings.is_tariffs_mode():
raise HTTPException(
@@ -1767,6 +1793,12 @@ async def purchase_tariff(
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Purchase a tariff (for tariffs mode)."""
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Subscription purchases are restricted for this account',
)
try:
# Check tariffs mode
if not settings.is_tariffs_mode():
@@ -2168,6 +2200,12 @@ async def purchase_devices(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Purchase additional device slots for subscription."""
if getattr(user, 'restriction_subscription', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Subscription purchases are restricted for this account',
)
try:
await db.refresh(user, ['subscription'])
subscription = user.subscription
@@ -2681,19 +2719,6 @@ async def get_device_price(
# ============ App Config for Connection ============
def _load_app_config_from_file() -> dict[str, Any]:
"""Load app-config.json file."""
try:
config_path = settings.get_app_config_path()
with open(config_path, encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception as e:
logger.error('Failed to load app-config.json', error=e)
return {}
def _get_remnawave_config_uuid() -> str | None:
"""Get RemnaWave config UUID from system settings or env."""
try:
@@ -2702,58 +2727,6 @@ def _get_remnawave_config_uuid() -> str | None:
return settings.CABINET_REMNA_SUB_CONFIG
def _is_subscription_link_template(url: str) -> bool:
"""Check if URL is a RemnaWave subscription link template."""
if not url:
return False
# RemnaWave uses templates like {{HAPP_CRYPT4_LINK}}, {{V2RAY_LINK}}, etc.
if url.startswith('{{') and url.endswith('}}'):
return True
# Also check for button type "subscriptionLink" indicator
return False
def _convert_remnawave_block_to_step(block: dict[str, Any], url_scheme: str = '') -> dict[str, Any]:
"""Convert RemnaWave block format to cabinet step format."""
step = {
'description': block.get('description', {}),
}
if block.get('title'):
step['title'] = block['title']
if block.get('buttons'):
buttons = []
for btn in block['buttons']:
btn_url = btn.get('url', '') or btn.get('link', '')
btn_type = btn.get('type', '')
# Replace subscription link templates with {{deepLink}} placeholder
# RemnaWave uses templates like {{HAPP_CRYPT4_LINK}} or type="subscriptionLink"
if (
_is_subscription_link_template(btn_url)
or btn_type == 'subscriptionLink'
or (
url_scheme
and btn_url
and (
btn_url.startswith(url_scheme)
or btn_url.endswith('://')
or btn_url.endswith('://add/')
or ('://' in btn_url and not btn_url.startswith('http'))
)
)
):
btn_url = '{{deepLink}}'
buttons.append(
{
'buttonLink': btn_url,
'buttonText': btn.get('text', {}),
}
)
step['buttons'] = buttons
return step
def _extract_scheme_from_buttons(buttons: list[dict[str, Any]]) -> tuple[str, bool]:
"""Extract URL scheme from buttons list.
@@ -2822,15 +2795,6 @@ def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
if scheme:
return scheme, uses_crypto
# 4. Check in step structures (cabinet format)
for step_key in ['installationStep', 'addSubscriptionStep', 'connectAndUseStep']:
step = app.get(step_key, {})
if isinstance(step, dict):
step_buttons = step.get('buttons', [])
scheme, uses_crypto = _extract_scheme_from_buttons(step_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',
@@ -2842,147 +2806,10 @@ def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
return '', False
def _find_subscription_block(blocks: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Find block that contains subscriptionLink button."""
for block in blocks:
if not isinstance(block, dict):
continue
buttons = block.get('buttons', [])
for btn in buttons:
if not isinstance(btn, dict):
continue
# Check for subscriptionLink type, {{SUBSCRIPTION_LINK}}, or {{HAPP_CRYPT4_LINK}} in link
btn_type = btn.get('type', '')
link = btn.get('link', '') or btn.get('url', '')
link_upper = link.upper() if link else ''
if btn_type == 'subscriptionLink' or 'SUBSCRIPTION_LINK' in link_upper or 'HAPP_CRYPT4_LINK' in link_upper:
return block
return None
async def _load_app_config_async() -> dict[str, Any] | None:
"""Load app config from RemnaWave API (if configured).
def _find_connect_block(blocks: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Find block that is about connection/usage (usually last or has specific keywords)."""
# Look for block with "connect" or "use" in title
for block in blocks:
if not isinstance(block, dict):
continue
title = block.get('title', {})
title_en = title.get('en', '') if isinstance(title, dict) else ''
title_lower = title_en.lower()
if 'connect' in title_lower or 'use' in title_lower:
return block
# Fallback to last block if no match
return blocks[-1] if blocks else None
def _convert_remnawave_app_to_cabinet(app: dict[str, Any]) -> dict[str, Any]:
"""Convert RemnaWave app format to cabinet app format."""
blocks = app.get('blocks', [])
url_scheme, uses_crypto = _get_url_scheme_for_app(app)
# Debug log for conversion (не логируем отсутствие urlScheme - для Happ это нормально)
app_name = app.get('name', 'unknown')
if url_scheme:
logger.debug('_convert_remnawave_app_to_cabinet: app urlScheme', app_name=app_name, url_scheme=url_scheme)
# Smart block mapping: find blocks by their content, not just position
# 1. First block is usually installation
installation_block = blocks[0] if len(blocks) > 0 else None
# 2. Find subscription block (with subscriptionLink button)
subscription_block = _find_subscription_block(blocks)
# 3. Find connect/use block (usually last or has "connect" in title)
connect_block = _find_connect_block(blocks)
# Convert blocks to steps
installation_step = (
_convert_remnawave_block_to_step(installation_block, url_scheme) if installation_block else {'description': {}}
)
subscription_step = (
_convert_remnawave_block_to_step(subscription_block, url_scheme) if subscription_block else {'description': {}}
)
connect_step = _convert_remnawave_block_to_step(connect_block, url_scheme) if connect_block else {'description': {}}
# Ensure subscription step has a deepLink button if urlScheme exists
if url_scheme:
has_deeplink_button = False
if 'buttons' in subscription_step:
for btn in subscription_step['buttons']:
if btn.get('buttonLink') == '{{deepLink}}':
has_deeplink_button = True
break
if not has_deeplink_button:
# Add deepLink button at the beginning
deeplink_button = {
'buttonLink': '{{deepLink}}',
'buttonText': {
'en': 'Open app',
'ru': 'Открыть приложение',
'zh': '打开应用',
'fa': 'باز کردن برنامه',
},
}
if 'buttons' not in subscription_step:
subscription_step['buttons'] = []
subscription_step['buttons'].insert(0, deeplink_button)
return {
'id': app.get('name', '').lower().replace(' ', '-'),
'name': app.get('name', ''),
'isFeatured': app.get('featured', False),
'urlScheme': url_scheme,
'usesCryptoLink': uses_crypto,
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
'installationStep': installation_step,
'addSubscriptionStep': subscription_step,
'connectAndUseStep': connect_step,
}
def _convert_remnawave_config_to_cabinet(config: dict[str, Any]) -> dict[str, Any]:
"""Convert RemnaWave config format to cabinet format."""
platforms = {}
remnawave_platforms = config.get('platforms', {})
for platform_key, platform_data in remnawave_platforms.items():
if not isinstance(platform_data, dict):
continue
apps = platform_data.get('apps', [])
if not isinstance(apps, list):
continue
cabinet_apps = []
for app in apps:
if isinstance(app, dict):
cabinet_apps.append(_convert_remnawave_app_to_cabinet(app))
if cabinet_apps:
platforms[platform_key] = cabinet_apps
# Convert branding
branding = {}
if config.get('brandingSettings'):
branding = {
'name': config['brandingSettings'].get('name', ''),
'logoUrl': config['brandingSettings'].get('logoUrl', ''),
'supportUrl': config['brandingSettings'].get('supportUrl', ''),
}
return {
'config': {
'additionalLocales': ['zh', 'fa'],
'branding': branding,
},
'platforms': platforms,
}
async def _load_app_config_async() -> dict[str, Any]:
"""Load app config from RemnaWave (if configured) or local file.
When config comes from RemnaWave, returns the original format with
``_isRemnawave`` flag so the caller can serve it as-is (enriched with
deep links) instead of converting to the legacy step-based format.
Returns None when no Remnawave config is set or API fails.
"""
remnawave_uuid = _get_remnawave_config_uuid()
@@ -2997,15 +2824,9 @@ async def _load_app_config_async() -> dict[str, Any]:
raw['_isRemnawave'] = True
return raw
except Exception as e:
logger.warning('Failed to load RemnaWave config, falling back to file', error=e)
logger.warning('Failed to load RemnaWave config', error=e)
# Fallback to local file
return _load_app_config_from_file()
def _load_app_config() -> dict[str, Any]:
"""Load app-config.json file (sync version for compatibility)."""
return _load_app_config_from_file()
return None
def _create_deep_link(
@@ -3420,18 +3241,41 @@ async def get_app_config(
subscription_url = user.subscription.subscription_url
subscription_crypto_link = user.subscription.subscription_crypto_link
# Load config from RemnaWave (if configured) or local file
# 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 user.subscription:
user.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()
is_remnawave = config.pop('_isRemnawave', False)
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()
# Строим platformNames из displayName каждой платформы RemnaWave
# 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']
# Фоллбэк для платформ без displayName (en достаточно)
fallback_names = {
'ios': {'en': 'iPhone/iPad'},
'android': {'en': 'Android'},
@@ -3445,111 +3289,67 @@ async def get_app_config(
if k not in platform_names:
platform_names[k] = v
if is_remnawave:
# ── RemnaWave original format ──
# 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:
btn['resolvedUrl'] = _resolve_button_url(
url,
subscription_url,
subscription_crypto_link,
)
enriched_apps.append(app)
if enriched_apps:
# Сохраняем platform-level поля (svgIconKey, displayName и т.д.)
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', {}),
}
# ── Legacy file-based format ──
platforms_raw = config.get('platforms', {})
if not isinstance(platforms_raw, dict):
platforms_raw = {}
platforms_legacy: dict[str, Any] = {}
for platform_key, apps in platforms_raw.items():
# 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
platform_apps = []
enriched_apps = []
for app in apps:
if not isinstance(app, dict):
continue
app_data = {
'id': app.get('id'),
'name': app.get('name'),
'isFeatured': app.get('isFeatured', False),
'installationStep': app.get('installationStep'),
'addSubscriptionStep': app.get('addSubscriptionStep'),
'connectAndUseStep': app.get('connectAndUseStep'),
'additionalBeforeAddSubscriptionStep': app.get('additionalBeforeAddSubscriptionStep'),
'additionalAfterAddSubscriptionStep': app.get('additionalAfterAddSubscriptionStep'),
}
# Add deep link if subscription exists
# Generate deep link
deep_link = None
if subscription_url or subscription_crypto_link:
app_data['deepLink'] = _create_deep_link(app, subscription_url, subscription_crypto_link)
deep_link = _create_deep_link(app, subscription_url, subscription_crypto_link)
app['deepLink'] = deep_link
platform_apps.append(app_data)
# 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
if platform_apps:
platforms_legacy[platform_key] = platform_apps
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 {
'platforms': platforms_legacy,
'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 if not hide_link else None,
'subscriptionCryptoLink': subscription_crypto_link if not hide_link else None,
'subscriptionUrl': subscription_url,
'subscriptionCryptoLink': subscription_crypto_link,
'hideLink': hide_link,
'branding': config.get('config', {}).get('branding', {}),
'branding': config.get('brandingSettings', {}),
}
@@ -4329,6 +4129,9 @@ async def switch_tariff(
user.subscription.purchased_traffic_gb = 0
user.subscription.traffic_reset_at = None
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
user.subscription.traffic_used_gb = 0.0
if switching_to_daily:
# Switching TO daily - reset end_date to 1 day, set last_daily_charge_at
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
@@ -4341,13 +4144,24 @@ async def switch_tariff(
user.subscription.updated_at = datetime.now(UTC)
await db.commit()
# Sync with RemnaWave
# Sync with RemnaWave (optionally reset traffic based on admin setting)
should_reset_traffic = settings.RESET_TRAFFIC_ON_TARIFF_SWITCH
try:
subscription_service = SubscriptionService()
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(db, user.subscription)
await subscription_service.update_remnawave_user(
db,
user.subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа',
)
else:
await subscription_service.create_remnawave_user(db, user.subscription)
await subscription_service.create_remnawave_user(
db,
user.subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа',
)
except Exception as e:
logger.error('Failed to sync tariff switch with RemnaWave', error=e)
+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."""
+7 -1
View File
@@ -282,7 +282,13 @@ async def add_ticket_message(
# Уведомить админов об ответе пользователя (Telegram)
try:
await notify_admins_about_ticket_reply(ticket, request.message, db)
await notify_admins_about_ticket_reply(
ticket,
request.message,
db,
media_file_id=request.media_file_id,
media_type=request.media_type,
)
except Exception as e:
logger.error('Error notifying admins about ticket reply from cabinet', error=e)
+17
View File
@@ -50,6 +50,12 @@ async def get_wheel_config(
# Проверяем доступность
availability = await wheel_service.check_availability(db, user)
# Проверяем наличие подписки
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user.id)
has_subscription = subscription is not None and subscription.is_active
prizes_display = [
WheelPrizeDisplay(
id=p.id,
@@ -77,6 +83,7 @@ async def get_wheel_config(
can_pay_days=availability.can_pay_days,
user_balance_kopeks=availability.user_balance_kopeks,
required_balance_kopeks=availability.required_balance_kopeks,
has_subscription=has_subscription,
)
@@ -213,6 +220,16 @@ async def create_stars_invoice(
detail='Оплата Stars не включена',
)
# Проверяем наличие активной подписки
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription or not subscription.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Для использования колеса необходима активная подписка',
)
# Проверяем лимит спинов
spins_today = await get_user_spins_today(db, user.id)
if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit:
+2
View File
@@ -12,6 +12,7 @@ class TelegramAuthRequest(BaseModel):
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
class TelegramWidgetAuthRequest(BaseModel):
@@ -27,6 +28,7 @@ class TelegramWidgetAuthRequest(BaseModel):
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
class EmailRegisterRequest(BaseModel):
+2 -2
View File
@@ -63,7 +63,7 @@ class PaymentMethodResponse(BaseModel):
class TopUpRequest(BaseModel):
"""Request to create payment for balance top-up."""
amount_kopeks: int = Field(..., ge=1000, description='Amount in kopeks (min 10 rubles)')
amount_kopeks: int = Field(..., ge=1000, le=2_000_000_000, description='Amount in kopeks (min 10 rubles)')
payment_method: str = Field(..., description='Payment method ID')
payment_option: str | None = Field(None, description='Payment option (e.g. Platega method code)')
@@ -82,7 +82,7 @@ class TopUpResponse(BaseModel):
class StarsInvoiceRequest(BaseModel):
"""Request to create Telegram Stars invoice for balance top-up."""
amount_kopeks: int = Field(..., ge=100, description='Amount in kopeks (min 1 ruble)')
amount_kopeks: int = Field(..., ge=100, le=2_000_000_000, description='Amount in kopeks (min 1 ruble)')
class StarsInvoiceResponse(BaseModel):
+62 -7
View File
@@ -3,7 +3,7 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
CampaignBonusType = Literal['balance', 'subscription', 'none', 'tariff']
@@ -31,8 +31,7 @@ class CampaignListItem(BaseModel):
partner_name: str | None = None
created_at: datetime
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class CampaignListResponse(BaseModel):
@@ -73,8 +72,7 @@ class CampaignDetailResponse(BaseModel):
deep_link: str | None = None
web_link: str | None = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class CampaignCreateRequest(BaseModel):
@@ -179,8 +177,7 @@ class CampaignRegistrationItem(BaseModel):
has_subscription: bool = False
has_paid: bool = False
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class CampaignRegistrationsResponse(BaseModel):
@@ -220,3 +217,61 @@ class ServerSquadInfo(BaseModel):
squad_uuid: str
display_name: str
country_code: str | None = None
# --- Admin campaign chart data schemas ---
class AdminDailyStatItem(BaseModel):
"""Daily stat item for admin campaign charts."""
date: str
referrals_count: int = 0 # actually registrations, named for frontend compat
earnings_kopeks: int = 0 # actually revenue, named for frontend compat
class AdminPeriodStats(BaseModel):
"""Period stats for admin campaign comparison."""
days: int
referrals_count: int = 0
earnings_kopeks: int = 0
class AdminPeriodChange(BaseModel):
"""Change metrics between periods."""
absolute: int = 0
percent: float = 0.0
trend: str = 'stable'
class AdminPeriodComparison(BaseModel):
"""Comparison of current vs previous period."""
current: AdminPeriodStats
previous: AdminPeriodStats
referrals_change: AdminPeriodChange
earnings_change: AdminPeriodChange
class AdminTopRegistrationItem(BaseModel):
"""Top user by spending in a campaign."""
id: int
full_name: str
created_at: datetime
has_paid: bool = False
is_active: bool = False
total_earnings_kopeks: int = 0 # actually total spending, named for frontend compat
class AdminCampaignChartDataResponse(BaseModel):
"""Chart data for admin campaign stats page."""
campaign_id: int
total_deposits_kopeks: int = 0
total_spending_kopeks: int = 0
daily_stats: list[AdminDailyStatItem] = []
period_comparison: AdminPeriodComparison
top_registrations: list[AdminTopRegistrationItem] = []
+84
View File
@@ -0,0 +1,84 @@
"""Pydantic v2 schemas for channel subscription management."""
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.database.crud.required_channel import validate_channel_id as _validate_channel_id_format
def _validate_channel_link_value(v: str | None) -> str | None:
"""Shared channel_link validation: t.me URL, @username auto-convert, http->https upgrade."""
if v is None:
return v
v = v.strip()
if v.startswith('http://t.me/'):
v = v.replace('http://', 'https://', 1)
if v.startswith('https://t.me/'):
return v
if v.startswith('@'):
return f'https://t.me/{v[1:]}'
raise ValueError('channel_link must be a t.me URL or @username')
class ChannelResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
channel_id: str
channel_link: str | None
title: str | None
is_active: bool
sort_order: int
disable_trial_on_leave: bool
disable_paid_on_leave: bool
class ChannelListResponse(BaseModel):
items: list[ChannelResponse]
total: int
class ChannelCreateRequest(BaseModel):
channel_id: str
channel_link: str | None = None
title: str | None = Field(None, max_length=255)
disable_trial_on_leave: bool = True
disable_paid_on_leave: bool = False
@field_validator('channel_id')
@classmethod
def validate_channel_id(cls, v: str) -> str:
return _validate_channel_id_format(v)
@field_validator('channel_link')
@classmethod
def validate_channel_link(cls, v: str | None) -> str | None:
return _validate_channel_link_value(v)
class ChannelUpdateRequest(BaseModel):
channel_id: str | None = None
channel_link: str | None = None
title: str | None = Field(None, max_length=255)
is_active: bool | None = None
sort_order: int | None = None
disable_trial_on_leave: bool | None = None
disable_paid_on_leave: bool | None = None
@field_validator('channel_id')
@classmethod
def validate_channel_id(cls, v: str | None) -> str | None:
if v is None:
return v
return _validate_channel_id_format(v)
@field_validator('channel_link')
@classmethod
def validate_channel_link(cls, v: str | None) -> str | None:
return _validate_channel_link_value(v)
class ChannelSubscriptionStatus(BaseModel):
channel_id: str
channel_link: str | None
title: str | None
is_subscribed: bool
+82 -4
View File
@@ -2,7 +2,7 @@
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
# ==================== User-facing ====================
@@ -15,7 +15,8 @@ class PartnerApplicationRequest(BaseModel):
website_url: str | None = Field(None, max_length=500)
telegram_channel: str | None = Field(None, max_length=255)
description: str | None = Field(None, max_length=2000)
expected_monthly_referrals: int | None = Field(None, ge=0)
expected_monthly_referrals: int | None = Field(None, ge=0, le=2_000_000_000)
desired_commission_percent: int | None = Field(None, ge=1, le=100)
class PartnerApplicationInfo(BaseModel):
@@ -28,13 +29,13 @@ class PartnerApplicationInfo(BaseModel):
telegram_channel: str | None = None
description: str | None = None
expected_monthly_referrals: int | None = None
desired_commission_percent: int | None = None
admin_comment: str | None = None
approved_commission_percent: int | None = None
created_at: datetime
processed_at: datetime | None = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class PartnerCampaignInfo(BaseModel):
@@ -49,6 +50,10 @@ class PartnerCampaignInfo(BaseModel):
subscription_traffic_gb: int | None = None
deep_link: str | None = None
web_link: str | None = None
# Per-campaign statistics
registrations_count: int = 0
referrals_count: int = 0
earnings_kopeks: int = 0
class PartnerStatusResponse(BaseModel):
@@ -60,6 +65,75 @@ class PartnerStatusResponse(BaseModel):
campaigns: list[PartnerCampaignInfo] = []
# ==================== Campaign detailed stats ====================
class DailyStatItem(BaseModel):
"""Single day of campaign stats."""
date: str
referrals_count: int = 0
earnings_kopeks: int = 0
class PeriodStats(BaseModel):
"""Stats for a single period."""
days: int
referrals_count: int = 0
earnings_kopeks: int = 0
class PeriodChange(BaseModel):
"""Change metrics between periods."""
absolute: int = 0
percent: float = 0.0
trend: str = 'stable'
class PeriodComparison(BaseModel):
"""Comparison between current and previous period."""
current: PeriodStats
previous: PeriodStats
referrals_change: PeriodChange
earnings_change: PeriodChange
class CampaignReferralItem(BaseModel):
"""Referral user in campaign stats."""
id: int
full_name: str
created_at: datetime
has_paid: bool = False
is_active: bool = False
total_earnings_kopeks: int = 0
class PartnerCampaignDetailedStats(BaseModel):
"""Detailed stats for a single campaign."""
campaign_id: int
campaign_name: str
# Summary
registrations_count: int = 0
referrals_count: int = 0
earnings_kopeks: int = 0
conversion_rate: float = 0.0
# Period earnings
earnings_today: int = 0
earnings_week: int = 0
earnings_month: int = 0
# Daily chart (30 days)
daily_stats: list[DailyStatItem] = []
# Period comparison (this week vs last week)
period_comparison: PeriodComparison
# Top referrals
top_referrals: list[CampaignReferralItem] = []
# ==================== Admin-facing ====================
@@ -76,6 +150,7 @@ class AdminPartnerApplicationItem(BaseModel):
telegram_channel: str | None = None
description: str | None = None
expected_monthly_referrals: int | None = None
desired_commission_percent: int | None = None
status: str
admin_comment: str | None = None
approved_commission_percent: int | None = None
@@ -132,6 +207,9 @@ class CampaignSummary(BaseModel):
name: str
start_parameter: str
is_active: bool
registrations_count: int = 0
referrals_count: int = 0
earnings_kopeks: int = 0
class AdminPartnerDetailResponse(BaseModel):
+3
View File
@@ -15,6 +15,9 @@ class ReferralInfoResponse(BaseModel):
total_earnings_kopeks: int
total_earnings_rubles: float
commission_percent: int
available_balance_kopeks: int = 0
available_balance_rubles: float = 0
withdrawn_kopeks: int = 0
class ReferralItemResponse(BaseModel):
+10 -8
View File
@@ -86,7 +86,7 @@ class RenewalOptionResponse(BaseModel):
class RenewalRequest(BaseModel):
"""Request to renew subscription."""
period_days: int = Field(..., description='Renewal period in days')
period_days: int = Field(..., ge=1, le=3650, description='Renewal period in days')
class TrafficPackageResponse(BaseModel):
@@ -101,13 +101,13 @@ class TrafficPackageResponse(BaseModel):
class TrafficPurchaseRequest(BaseModel):
"""Request to purchase additional traffic."""
gb: int = Field(..., ge=0, description='GB to purchase (0 = unlimited)')
gb: int = Field(..., ge=0, le=100_000, description='GB to purchase (0 = unlimited)')
class DevicePurchaseRequest(BaseModel):
"""Request to purchase additional device slots."""
devices: int = Field(..., ge=1, description='Number of additional devices')
devices: int = Field(..., ge=1, le=100, description='Number of additional devices')
class AutopayUpdateRequest(BaseModel):
@@ -137,10 +137,10 @@ class PurchaseSelectionRequest(BaseModel):
"""User's selection for subscription purchase."""
period_id: str | None = Field(None, description="Period ID like 'days:30'")
period_days: int | None = Field(None, description='Period in days')
traffic_value: int | None = Field(None, description='Traffic in GB (0 = unlimited)')
period_days: int | None = Field(None, ge=1, le=3650, description='Period in days')
traffic_value: int | None = Field(None, ge=0, le=100_000, description='Traffic in GB (0 = unlimited)')
servers: list[str] | None = Field(default_factory=list, description='Server UUIDs')
devices: int | None = Field(None, description='Device limit')
devices: int | None = Field(None, ge=1, le=100, description='Device limit')
class PurchasePreviewRequest(BaseModel):
@@ -156,5 +156,7 @@ class TariffPurchaseRequest(BaseModel):
"""Request to purchase a tariff."""
tariff_id: int = Field(..., description='Tariff ID to purchase')
period_days: int = Field(..., description='Period in days')
traffic_gb: int | None = Field(None, ge=0, description='Custom traffic in GB (for custom_traffic_enabled tariffs)')
period_days: int = Field(..., ge=1, le=3650, description='Period in days')
traffic_gb: int | None = Field(
None, ge=0, le=100_000, description='Custom traffic in GB (for custom_traffic_enabled tariffs)'
)
+3 -1
View File
@@ -261,7 +261,9 @@ class UserNodeUsageResponse(BaseModel):
class UpdateBalanceRequest(BaseModel):
"""Request to update user balance."""
amount_kopeks: int = Field(..., description='Amount in kopeks (positive to add, negative to subtract)')
amount_kopeks: int = Field(
..., ge=-2_000_000_000, le=2_000_000_000, description='Amount in kopeks (positive to add, negative to subtract)'
)
description: str = Field(default='Admin balance adjustment', max_length=500)
create_transaction: bool = Field(default=True, description='Create transaction record')
+1
View File
@@ -60,6 +60,7 @@ class WheelConfigResponse(BaseModel):
can_pay_days: bool = False
user_balance_kopeks: int = 0
required_balance_kopeks: int = 0
has_subscription: bool = False
class SpinAvailabilityResponse(BaseModel):
View File
+19
View File
@@ -0,0 +1,19 @@
"""Shared utility for generating campaign deep links and web links."""
from app.config import settings
def get_campaign_deep_link(start_parameter: str) -> str:
"""Generate a Telegram deep link for a 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 get_campaign_web_link(start_parameter: str) -> str | None:
"""Generate a web app link for a campaign."""
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
if base_url:
return f'{base_url}/?campaign={start_parameter}'
return None
+28 -12
View File
@@ -66,8 +66,6 @@ class Settings(BaseSettings):
ADMIN_REPORTS_TOPIC_ID: int | None = None
ADMIN_REPORTS_SEND_TIME: str | None = None
CHANNEL_SUB_ID: str | None = None
CHANNEL_LINK: str | None = None
CHANNEL_IS_REQUIRED_SUB: bool = False
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
CHANNEL_REQUIRED_FOR_ALL: bool = False
@@ -136,6 +134,7 @@ class Settings(BaseSettings):
DEFAULT_DEVICE_LIMIT: int = 1
DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH'
RESET_TRAFFIC_ON_PAYMENT: bool = False
RESET_TRAFFIC_ON_TARIFF_SWITCH: bool = True
MAX_DEVICES_LIMIT: int = 20
TRIAL_WARNING_HOURS: int = 2
@@ -502,6 +501,11 @@ class Settings(BaseSettings):
FREEKASSA_USE_API: bool = False
# Публичный IP сервера для Freekassa API (если не задан - определяется автоматически)
SERVER_PUBLIC_IP: str | None = None
# Раздельные методы оплаты Freekassa (отображаются как отдельные кнопки)
FREEKASSA_SBP_ENABLED: bool = False # СБП (QR код) — i=44
FREEKASSA_SBP_DISPLAY_NAME: str = 'СБП (QR код)'
FREEKASSA_CARD_ENABLED: bool = False # Карты РФ — i=36
FREEKASSA_CARD_DISPLAY_NAME: str = 'Карта РФ'
# KassaAI (api.fk.life) - отдельная платёжка
KASSA_AI_ENABLED: bool = False
@@ -676,7 +680,6 @@ class Settings(BaseSettings):
WEB_API_TOKEN_HMAC_SECRET: str | None = None
WEB_API_REQUEST_LOGGING: bool = True
APP_CONFIG_PATH: str = 'app-config.json'
ENABLE_DEEP_LINKS: bool = True
APP_CONFIG_CACHE_TTL: int = 3600
@@ -1383,13 +1386,6 @@ class Settings(BaseSettings):
return value
return None
def get_app_config_path(self) -> str:
if os.path.isabs(self.APP_CONFIG_PATH):
return self.APP_CONFIG_PATH
project_root = Path(__file__).parent.parent
return str(project_root / self.APP_CONFIG_PATH)
def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS
@@ -1535,8 +1531,8 @@ class Settings(BaseSettings):
logger.warning('Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT', raw_value=raw_value)
return None
if value < 0:
return 0
if value <= 0:
return None
return value
@@ -1760,6 +1756,26 @@ class Settings(BaseSettings):
def get_freekassa_display_name_html(self) -> str:
return html.escape(self.get_freekassa_display_name())
def is_freekassa_sbp_enabled(self) -> bool:
return self.FREEKASSA_SBP_ENABLED and self.is_freekassa_enabled()
def get_freekassa_sbp_display_name(self) -> str:
name = (self.FREEKASSA_SBP_DISPLAY_NAME or '').strip()
return name if name else 'СБП (QR код)'
def get_freekassa_sbp_display_name_html(self) -> str:
return html.escape(self.get_freekassa_sbp_display_name())
def is_freekassa_card_enabled(self) -> bool:
return self.FREEKASSA_CARD_ENABLED and self.is_freekassa_enabled()
def get_freekassa_card_display_name(self) -> str:
name = (self.FREEKASSA_CARD_DISPLAY_NAME or '').strip()
return name if name else 'Карта РФ'
def get_freekassa_card_display_name_html(self) -> str:
return html.escape(self.get_freekassa_card_display_name())
def is_kassa_ai_enabled(self) -> bool:
return (
self.KASSA_AI_ENABLED
+14 -3
View File
@@ -104,7 +104,6 @@ async def get_campaigns_list(
stmt = (
select(AdvertisingCampaign)
.options(
selectinload(AdvertisingCampaign.registrations),
selectinload(AdvertisingCampaign.tariff),
selectinload(AdvertisingCampaign.partner),
)
@@ -148,10 +147,22 @@ async def update_campaign(
'partner_user_id',
}
nullable_fields = {
'partner_user_id',
'tariff_id',
'subscription_duration_days',
'subscription_traffic_gb',
'subscription_device_limit',
'tariff_duration_days',
}
update_data = {}
for key, value in kwargs.items():
if key in allowed_fields and value is not None:
update_data[key] = value
if key not in allowed_fields:
continue
if value is None and key not in nullable_fields:
continue
update_data[key] = value
if not update_data:
return campaign
+501
View File
@@ -0,0 +1,501 @@
from datetime import UTC, datetime
import structlog
from sqlalchemy import and_, delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import AccessPolicy, AdminAuditLog, AdminRole, User, UserRole
logger = structlog.get_logger(__name__)
# Fields allowed for AdminRole.update()
_ROLE_UPDATABLE_FIELDS = frozenset(
{
'name',
'description',
'level',
'permissions',
'color',
'icon',
'is_active',
}
)
# Fields allowed for AccessPolicy.update()
_POLICY_UPDATABLE_FIELDS = frozenset(
{
'name',
'description',
'role_id',
'priority',
'effect',
'conditions',
'resource',
'actions',
'is_active',
}
)
# Superadmin level constant
_SUPERADMIN_LEVEL = 999
class AdminRoleCRUD:
"""CRUD operations for admin_roles table."""
@staticmethod
async def get_all(db: AsyncSession, *, include_inactive: bool = False) -> list[AdminRole]:
"""Get all admin roles ordered by level descending."""
stmt = select(AdminRole).order_by(AdminRole.level.desc())
if not include_inactive:
stmt = stmt.where(AdminRole.is_active.is_(True))
result = await db.execute(stmt)
return list(result.scalars().all())
@staticmethod
async def get_by_id(db: AsyncSession, role_id: int) -> AdminRole | None:
result = await db.execute(select(AdminRole).where(AdminRole.id == role_id))
return result.scalar_one_or_none()
@staticmethod
async def get_by_name(db: AsyncSession, name: str) -> AdminRole | None:
result = await db.execute(select(AdminRole).where(AdminRole.name == name))
return result.scalar_one_or_none()
@staticmethod
async def create(
db: AsyncSession,
*,
name: str,
description: str | None,
level: int,
permissions: list[str],
color: str | None = None,
icon: str | None = None,
is_system: bool = False,
created_by: int | None = None,
) -> AdminRole:
role = AdminRole(
name=name,
description=description,
level=level,
permissions=permissions,
color=color,
icon=icon,
is_system=is_system,
created_by=created_by,
)
db.add(role)
await db.flush()
await db.refresh(role)
logger.info('Created admin role', role_id=role.id, name=name, level=level)
return role
@staticmethod
async def update(db: AsyncSession, role_id: int, **kwargs: object) -> AdminRole | None:
"""Update only provided fields. Rejects unknown/non-updatable keys."""
role = await AdminRoleCRUD.get_by_id(db, role_id)
if not role:
return None
for key, value in kwargs.items():
if key not in _ROLE_UPDATABLE_FIELDS:
logger.warning('Rejected update of non-updatable AdminRole field', field=key)
continue
setattr(role, key, value)
await db.flush()
await db.refresh(role)
logger.info('Updated admin role', role_id=role_id, fields=list(kwargs.keys()))
return role
@staticmethod
async def delete(db: AsyncSession, role_id: int) -> bool:
"""Delete a role. Returns False if the role is a system role or does not exist.
Cascades are handled by DB-level ON DELETE CASCADE on user_roles and access_policies.
"""
role = await AdminRoleCRUD.get_by_id(db, role_id)
if not role:
return False
if role.is_system:
logger.warning('Attempted to delete system role', role_id=role_id, name=role.name)
return False
# Explicitly delete dependent user_roles and access_policies in application layer
# to keep audit trail clear (DB cascade would also work, but explicit is better)
await db.execute(delete(UserRole).where(UserRole.role_id == role_id))
await db.execute(delete(AccessPolicy).where(AccessPolicy.role_id == role_id))
await db.delete(role)
await db.flush()
logger.info('Deleted admin role', role_id=role_id, name=role.name)
return True
@staticmethod
async def count_users(db: AsyncSession, role_id: int) -> int:
"""Count active user_roles assigned to this role."""
result = await db.execute(
select(func.count(UserRole.id)).where(
UserRole.role_id == role_id,
UserRole.is_active.is_(True),
)
)
return result.scalar() or 0
class UserRoleCRUD:
"""CRUD operations for user_roles table + permission aggregation."""
@staticmethod
async def get_user_roles(db: AsyncSession, user_id: int) -> list[UserRole]:
"""Get active user roles with eager-loaded AdminRole."""
result = await db.execute(
select(UserRole)
.options(selectinload(UserRole.role))
.where(
UserRole.user_id == user_id,
UserRole.is_active.is_(True),
)
)
return list(result.scalars().all())
@staticmethod
async def get_user_permissions(
db: AsyncSession,
user_id: int,
) -> tuple[list[str], list[str], int]:
"""Aggregate permissions from all active, non-expired roles.
Returns:
(sorted_permissions, role_names, max_level)
"""
now = datetime.now(UTC)
result = await db.execute(
select(UserRole)
.options(selectinload(UserRole.role))
.where(
UserRole.user_id == user_id,
UserRole.is_active.is_(True),
)
)
user_roles = result.scalars().all()
permissions: set[str] = set()
role_names: list[str] = []
max_level: int = 0
for ur in user_roles:
# Skip expired assignments
if ur.expires_at is not None and ur.expires_at <= now:
continue
role = ur.role
if role is None or not role.is_active:
continue
permissions.update(role.permissions or [])
role_names.append(role.name)
max_level = max(max_level, role.level)
return sorted(permissions), role_names, max_level
@staticmethod
async def assign_role(
db: AsyncSession,
*,
user_id: int,
role_id: int,
assigned_by: int | None = None,
expires_at: datetime | None = None,
) -> UserRole:
"""Assign a role to a user. Reactivates existing inactive assignment if present."""
# Check for existing assignment (active or inactive) due to unique constraint
result = await db.execute(
select(UserRole).where(
UserRole.user_id == user_id,
UserRole.role_id == role_id,
)
)
existing = result.scalar_one_or_none()
if existing is not None:
existing.is_active = True
existing.assigned_by = assigned_by
existing.assigned_at = datetime.now(UTC)
existing.expires_at = expires_at
await db.flush()
await db.refresh(existing)
logger.info('Reactivated user role', user_role_id=existing.id, user_id=user_id, role_id=role_id)
return existing
user_role = UserRole(
user_id=user_id,
role_id=role_id,
assigned_by=assigned_by,
expires_at=expires_at,
)
db.add(user_role)
await db.flush()
await db.refresh(user_role)
logger.info('Assigned role to user', user_role_id=user_role.id, user_id=user_id, role_id=role_id)
return user_role
@staticmethod
async def revoke_role(db: AsyncSession, user_role_id: int) -> bool:
"""Soft-revoke: set is_active=False. Returns False if not found."""
result = await db.execute(select(UserRole).where(UserRole.id == user_role_id))
user_role = result.scalar_one_or_none()
if not user_role:
return False
user_role.is_active = False
await db.flush()
logger.info(
'Revoked user role', user_role_id=user_role_id, user_id=user_role.user_id, role_id=user_role.role_id
)
return True
@staticmethod
async def get_all_admins(
db: AsyncSession,
*,
limit: int = 100,
offset: int = 0,
) -> list[dict]:
"""Get users that have at least one active role.
Returns list of dicts: [{'user': User, 'role_names': [str, ...]}]
"""
# Subquery: aggregate role names per user
role_agg = (
select(
UserRole.user_id,
func.array_agg(AdminRole.name).label('role_names'),
)
.join(AdminRole, UserRole.role_id == AdminRole.id)
.where(
UserRole.is_active.is_(True),
AdminRole.is_active.is_(True),
)
.group_by(UserRole.user_id)
.subquery()
)
stmt = (
select(User, role_agg.c.role_names)
.join(role_agg, User.id == role_agg.c.user_id)
.order_by(User.id)
.offset(offset)
.limit(limit)
)
result = await db.execute(stmt)
rows = result.all()
return [{'user': row[0], 'role_names': list(row[1] or [])} for row in rows]
@staticmethod
async def get_superadmin_count(db: AsyncSession) -> int:
"""Count users with an active role at superadmin level (999)."""
result = await db.execute(
select(func.count(func.distinct(UserRole.user_id)))
.join(AdminRole, UserRole.role_id == AdminRole.id)
.where(
UserRole.is_active.is_(True),
AdminRole.is_active.is_(True),
AdminRole.level == _SUPERADMIN_LEVEL,
)
)
return result.scalar() or 0
class AccessPolicyCRUD:
"""CRUD operations for access_policies table (ABAC)."""
@staticmethod
async def get_all(
db: AsyncSession,
*,
role_id: int | None = None,
) -> list[AccessPolicy]:
"""Get active policies ordered by priority descending. Optionally filter by role_id."""
stmt = select(AccessPolicy).where(AccessPolicy.is_active.is_(True)).order_by(AccessPolicy.priority.desc())
if role_id is not None:
stmt = stmt.where(AccessPolicy.role_id == role_id)
result = await db.execute(stmt)
return list(result.scalars().all())
@staticmethod
async def get_by_id(db: AsyncSession, policy_id: int) -> AccessPolicy | None:
result = await db.execute(select(AccessPolicy).where(AccessPolicy.id == policy_id))
return result.scalar_one_or_none()
@staticmethod
async def create(db: AsyncSession, **kwargs: object) -> AccessPolicy:
policy = AccessPolicy(**kwargs)
db.add(policy)
await db.flush()
await db.refresh(policy)
logger.info('Created access policy', policy_id=policy.id, name=policy.name, effect=policy.effect)
return policy
@staticmethod
async def update(db: AsyncSession, policy_id: int, **kwargs: object) -> AccessPolicy | None:
"""Update only provided fields. Rejects unknown/non-updatable keys."""
policy = await AccessPolicyCRUD.get_by_id(db, policy_id)
if not policy:
return None
for key, value in kwargs.items():
if key not in _POLICY_UPDATABLE_FIELDS:
logger.warning('Rejected update of non-updatable AccessPolicy field', field=key)
continue
setattr(policy, key, value)
await db.flush()
await db.refresh(policy)
logger.info('Updated access policy', policy_id=policy_id, fields=list(kwargs.keys()))
return policy
@staticmethod
async def delete(db: AsyncSession, policy_id: int) -> bool:
policy = await AccessPolicyCRUD.get_by_id(db, policy_id)
if not policy:
return False
await db.delete(policy)
await db.flush()
logger.info('Deleted access policy', policy_id=policy_id, name=policy.name)
return True
@staticmethod
async def get_policies_for_user(
db: AsyncSession,
role_ids: list[int],
) -> list[AccessPolicy]:
"""Get active policies matching any of the given role_ids OR global (role_id IS NULL).
Ordered by priority descending for correct evaluation order.
"""
if not role_ids:
# Only global policies
stmt = (
select(AccessPolicy)
.where(
AccessPolicy.is_active.is_(True),
AccessPolicy.role_id.is_(None),
)
.order_by(AccessPolicy.priority.desc())
)
else:
stmt = (
select(AccessPolicy)
.where(
AccessPolicy.is_active.is_(True),
or_(
AccessPolicy.role_id.in_(role_ids),
AccessPolicy.role_id.is_(None),
),
)
.order_by(AccessPolicy.priority.desc())
)
result = await db.execute(stmt)
return list(result.scalars().all())
class AuditLogCRUD:
"""Create + filtered query for admin_audit_log table."""
@staticmethod
async def create(
db: AsyncSession,
*,
user_id: int,
action: str,
resource_type: str | None = None,
resource_id: str | None = None,
details: dict | None = None,
ip_address: str | None = None,
user_agent: str | None = None,
status: str = 'success',
request_method: str | None = None,
request_path: str | None = None,
) -> AdminAuditLog:
entry = AdminAuditLog(
user_id=user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
details=details,
ip_address=ip_address,
user_agent=user_agent,
status=status,
request_method=request_method,
request_path=request_path,
)
db.add(entry)
await db.flush()
await db.refresh(entry)
logger.debug(
'Audit log created',
audit_id=entry.id,
user_id=user_id,
action=action,
status=status,
)
return entry
@staticmethod
async def get_logs(
db: AsyncSession,
*,
user_id: int | None = None,
action: str | None = None,
resource_type: str | None = None,
status: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
limit: int = 50,
offset: int = 0,
load_user: bool = False,
) -> tuple[list[AdminAuditLog], int]:
"""Get filtered audit logs with total count.
Returns:
(logs, total_count)
"""
filters = []
if user_id is not None:
filters.append(AdminAuditLog.user_id == user_id)
if action is not None:
filters.append(AdminAuditLog.action.ilike(f'%{action}%'))
if resource_type is not None:
filters.append(AdminAuditLog.resource_type == resource_type)
if status is not None:
filters.append(AdminAuditLog.status == status)
if date_from is not None:
filters.append(AdminAuditLog.created_at >= date_from)
if date_to is not None:
filters.append(AdminAuditLog.created_at <= date_to)
where_clause = and_(*filters) if filters else True
# Total count
count_result = await db.execute(select(func.count(AdminAuditLog.id)).where(where_clause))
total_count = count_result.scalar() or 0
# Paginated results
stmt = (
select(AdminAuditLog)
.where(where_clause)
.order_by(AdminAuditLog.created_at.desc())
.offset(offset)
.limit(limit)
)
if load_user:
from sqlalchemy.orm import selectinload
stmt = stmt.options(selectinload(AdminAuditLog.user))
result = await db.execute(stmt)
logs = list(result.scalars().all())
return logs, total_count
+13 -90
View File
@@ -5,7 +5,7 @@ from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import AdvertisingCampaignRegistration, ReferralEarning, User
from app.database.models import AdvertisingCampaignRegistration, ReferralEarning, Subscription, SubscriptionStatus, User
logger = structlog.get_logger(__name__)
@@ -89,7 +89,7 @@ async def get_referral_earnings_sum(
query = query.where(ReferralEarning.created_at <= end_date)
result = await db.execute(query)
return result.scalar()
return result.scalar() or 0
async def get_referral_statistics(db: AsyncSession) -> dict:
@@ -104,18 +104,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
active_referrers = active_referrers_result.scalar()
referral_paid_result = await db.execute(select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
referral_paid = referral_paid_result.scalar()
from app.database.models import Transaction, TransactionType
transaction_paid_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
Transaction.type == TransactionType.REFERRAL_REWARD.value
)
)
transaction_paid = transaction_paid_result.scalar()
total_paid = referral_paid + transaction_paid
total_paid = referral_paid_result.scalar()
referrals_stats_result = await db.execute(
select(User.referred_by_id.label('referrer_id'), func.count(User.id).label('referrals_count'))
@@ -132,15 +121,6 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
)
referral_earnings = {row.referrer_id: row.referral_earnings for row in referral_earnings_result.all()}
transaction_earnings_result = await db.execute(
select(
Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('transaction_earnings')
)
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
.group_by(Transaction.user_id)
)
transaction_earnings = {row.referrer_id: row.transaction_earnings for row in transaction_earnings_result.all()}
top_referrers_data = {}
for referrer_id, count in referrals_stats.items():
@@ -153,11 +133,6 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
top_referrers_data[referrer_id] = {'referrals_count': 0, 'total_earned': 0}
top_referrers_data[referrer_id]['total_earned'] += earnings or 0
for referrer_id, earnings in transaction_earnings.items():
if referrer_id not in top_referrers_data:
top_referrers_data[referrer_id] = {'referrals_count': 0, 'total_earned': 0}
top_referrers_data[referrer_id]['total_earned'] += earnings or 0
sorted_referrers = sorted(
top_referrers_data.items(), key=lambda x: (x[1]['total_earned'], x[1]['referrals_count']), reverse=True
)
@@ -197,37 +172,22 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
today = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
today_referral_earnings_result = await db.execute(
today_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= today)
)
today_transaction_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= today)
)
)
today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar()
today_earnings = today_earnings_result.scalar()
week_ago = datetime.now(UTC) - timedelta(days=7)
week_referral_earnings_result = await db.execute(
week_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= week_ago)
)
week_transaction_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= week_ago)
)
)
week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar()
week_earnings = week_earnings_result.scalar()
month_ago = datetime.now(UTC) - timedelta(days=30)
month_referral_earnings_result = await db.execute(
month_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= month_ago)
)
month_transaction_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= month_ago)
)
)
month_earnings = month_referral_earnings_result.scalar() + month_transaction_earnings_result.scalar()
month_earnings = month_earnings_result.scalar()
logger.info(
'Реферальная статистика: рефералов, рефереров, выплачено копеек',
@@ -264,8 +224,6 @@ async def get_top_referrers_by_period(
Returns:
Список словарей с данными рефереров
"""
from app.database.models import Transaction, TransactionType
now = datetime.now(UTC)
if period == 'week':
start_date = now - timedelta(days=7)
@@ -292,18 +250,6 @@ async def get_top_referrers_by_period(
)
earnings = earnings_result.scalar() or 0
# Добавляем транзакции REFERRAL_REWARD
trans_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
and_(
Transaction.user_id == row.referrer_id,
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= start_date,
)
)
)
earnings += trans_earnings_result.scalar() or 0
top_data.append(
{'referrer_id': row.referrer_id, 'invited_count': row.invited_count, 'earnings_kopeks': earnings}
)
@@ -320,27 +266,8 @@ async def get_top_referrers_by_period(
)
referral_earnings = {row.referrer_id: row.ref_earnings for row in referral_earnings_result}
# Добавляем транзакции REFERRAL_REWARD
transaction_earnings_result = await db.execute(
select(
Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('trans_earnings')
)
.where(
and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= start_date)
)
.group_by(Transaction.user_id)
)
# Объединяем заработки
combined_earnings = dict(referral_earnings)
for row in transaction_earnings_result:
if row.referrer_id in combined_earnings:
combined_earnings[row.referrer_id] += row.trans_earnings or 0
else:
combined_earnings[row.referrer_id] = row.trans_earnings or 0
# Сортируем и берём топ
sorted_referrers = sorted(combined_earnings.items(), key=lambda x: x[1], reverse=True)[:limit]
sorted_referrers = sorted(referral_earnings.items(), key=lambda x: x[1], reverse=True)[:limit]
top_data = []
for referrer_id, earnings in sorted_referrers:
@@ -400,22 +327,18 @@ async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
month_ago = datetime.now(UTC) - timedelta(days=30)
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
from app.database.models import Subscription, SubscriptionStatus
current_time = datetime.now(UTC)
active_referrals_result = await db.execute(
select(func.count(User.id))
select(func.count(func.distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
and_(
User.referred_by_id == user_id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > current_time,
Subscription.end_date > func.now(),
)
)
)
active_referrals = active_referrals_result.scalar()
active_referrals = active_referrals_result.scalar() or 0
return {
'invited_count': invited_count,
+226
View File
@@ -0,0 +1,226 @@
import re
from datetime import UTC, datetime
import structlog
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import RequiredChannel, UserChannelSubscription
logger = structlog.get_logger(__name__)
# Explicit allowlist of fields that can be updated via update_channel()
_UPDATABLE_FIELDS = frozenset(
{
'channel_id',
'channel_link',
'title',
'is_active',
'sort_order',
'disable_trial_on_leave',
'disable_paid_on_leave',
}
)
# Validation patterns for channel_id
_CHANNEL_ID_NUMERIC = re.compile(r'^-100\d{10,13}$')
_BARE_DIGITS = re.compile(r'^\d{10,13}$')
def validate_channel_id(channel_id: str) -> str:
"""Validate and normalize channel_id. Auto-prefixes -100 for bare digits.
Raises ValueError on invalid input.
"""
channel_id = channel_id.strip()
if _CHANNEL_ID_NUMERIC.match(channel_id):
return channel_id
if _BARE_DIGITS.match(channel_id):
return f'-100{channel_id}'
raise ValueError(
f'Invalid channel_id format: {channel_id!r}. '
'Enter numeric channel ID (e.g. 1234567890) — prefix -100 is added automatically'
)
# -- RequiredChannel CRUD --------------------------------------------------------
async def get_active_channels(db: AsyncSession) -> list[RequiredChannel]:
"""Get all active required channels (sorted by sort_order)."""
result = await db.execute(
select(RequiredChannel)
.where(RequiredChannel.is_active.is_(True))
.order_by(RequiredChannel.sort_order, RequiredChannel.id)
)
return list(result.scalars().all())
async def get_all_channels(db: AsyncSession) -> list[RequiredChannel]:
"""Get all required channels (including inactive)."""
result = await db.execute(select(RequiredChannel).order_by(RequiredChannel.sort_order, RequiredChannel.id))
return list(result.scalars().all())
async def get_channel_by_id(db: AsyncSession, channel_db_id: int) -> RequiredChannel | None:
result = await db.execute(select(RequiredChannel).where(RequiredChannel.id == channel_db_id))
return result.scalar_one_or_none()
async def get_channel_by_channel_id(db: AsyncSession, channel_id: str) -> RequiredChannel | None:
result = await db.execute(select(RequiredChannel).where(RequiredChannel.channel_id == channel_id))
return result.scalar_one_or_none()
async def add_channel(
db: AsyncSession,
channel_id: str,
channel_link: str | None = None,
title: str | None = None,
disable_trial_on_leave: bool = True,
disable_paid_on_leave: bool = False,
) -> RequiredChannel:
channel_id = validate_channel_id(channel_id)
channel = RequiredChannel(
channel_id=channel_id,
channel_link=channel_link,
title=title,
disable_trial_on_leave=disable_trial_on_leave,
disable_paid_on_leave=disable_paid_on_leave,
)
db.add(channel)
await db.commit()
await db.refresh(channel)
return channel
async def update_channel(
db: AsyncSession,
channel_db_id: int,
**kwargs,
) -> RequiredChannel | None:
"""Update channel fields. Only fields in _UPDATABLE_FIELDS are accepted."""
channel = await get_channel_by_id(db, channel_db_id)
if not channel:
return None
for key, value in kwargs.items():
if key not in _UPDATABLE_FIELDS:
logger.warning('Rejected update of non-updatable field', field=key)
continue
if key == 'channel_id' and value is not None:
value = validate_channel_id(value)
setattr(channel, key, value)
channel.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(channel)
return channel
async def delete_channel(db: AsyncSession, channel_db_id: int) -> bool:
channel = await get_channel_by_id(db, channel_db_id)
if not channel:
return False
# Also clean up user subscriptions for this channel
await db.execute(delete(UserChannelSubscription).where(UserChannelSubscription.channel_id == channel.channel_id))
await db.delete(channel)
await db.commit()
return True
async def toggle_channel(db: AsyncSession, channel_db_id: int) -> RequiredChannel | None:
channel = await get_channel_by_id(db, channel_db_id)
if not channel:
return None
channel.is_active = not channel.is_active
channel.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(channel)
return channel
# -- UserChannelSubscription CRUD ------------------------------------------------
async def upsert_user_channel_sub(
db: AsyncSession,
telegram_id: int,
channel_id: str,
is_member: bool,
) -> None:
"""Upsert user subscription status (PostgreSQL ON CONFLICT)."""
now = datetime.now(UTC) # Single timestamp for both INSERT and UPDATE
stmt = (
pg_insert(UserChannelSubscription)
.values(
telegram_id=telegram_id,
channel_id=channel_id,
is_member=is_member,
checked_at=now,
)
.on_conflict_do_update(
constraint='uq_user_channel_sub',
set_={
'is_member': is_member,
'checked_at': now,
},
)
)
await db.execute(stmt)
# NOTE: caller is responsible for commit (allows batching)
async def get_user_channel_subs(
db: AsyncSession,
telegram_id: int,
) -> list[UserChannelSubscription]:
"""Get all channel subscriptions for a user."""
result = await db.execute(select(UserChannelSubscription).where(UserChannelSubscription.telegram_id == telegram_id))
return list(result.scalars().all())
async def get_user_channel_sub(
db: AsyncSession,
telegram_id: int,
channel_id: str,
) -> UserChannelSubscription | None:
result = await db.execute(
select(UserChannelSubscription).where(
UserChannelSubscription.telegram_id == telegram_id,
UserChannelSubscription.channel_id == channel_id,
)
)
return result.scalar_one_or_none()
async def bulk_upsert_user_subs(
db: AsyncSession,
telegram_id: int,
subs: dict[str, bool], # {channel_id: is_member}
) -> None:
"""Batch upsert user subscriptions with single multi-row INSERT."""
if not subs:
return
now = datetime.now(UTC)
values = [
{
'telegram_id': telegram_id,
'channel_id': channel_id,
'is_member': is_member,
'checked_at': now,
}
for channel_id, is_member in subs.items()
]
stmt = pg_insert(UserChannelSubscription).values(values)
stmt = stmt.on_conflict_do_update(
constraint='uq_user_channel_sub',
set_={
'is_member': stmt.excluded.is_member,
'checked_at': stmt.excluded.checked_at,
},
)
await db.execute(stmt)
await db.commit()
+73 -17
View File
@@ -15,6 +15,8 @@ from app.database.models import (
Subscription,
SubscriptionServer,
SubscriptionStatus,
Transaction,
TransactionType,
User,
UserPromoGroup,
UserStatus,
@@ -36,6 +38,18 @@ def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
return elapsed < _WEBHOOK_GUARD_SECONDS
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
"""Return True if subscription is active, paid (non-trial), and not expired."""
if not subscription:
return False
return (
not subscription.is_trial
and subscription.status == SubscriptionStatus.ACTIVE.value
and subscription.end_date is not None
and subscription.end_date > datetime.now(UTC)
)
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None:
result = await db.execute(
select(Subscription)
@@ -432,7 +446,12 @@ async def extend_subscription(
if traffic_limit_gb is not None:
old_traffic = subscription.traffic_limit_gb
subscription.traffic_used_gb = 0.0
# Сброс использованного трафика: при смене тарифа — по настройке, при продлении — всегда
if is_tariff_change:
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
subscription.traffic_used_gb = 0.0
else:
subscription.traffic_used_gb = 0.0
if is_tariff_change:
# При СМЕНЕ тарифа сбрасываем все докупки трафика
@@ -746,15 +765,23 @@ async def get_expiring_subscriptions(db: AsyncSession, days_before: int = 3) ->
async def get_expired_subscriptions(db: AsyncSession) -> list[Subscription]:
from app.database.models import Tariff
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(selectinload(Subscription.user))
.outerjoin(Tariff, Subscription.tariff_id == Tariff.id)
.options(selectinload(Subscription.user), selectinload(Subscription.tariff))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
User.status == UserStatus.ACTIVE.value,
Subscription.end_date <= datetime.now(UTC),
# Не трогаем активные суточные подписки — ими управляет DailySubscriptionService
~and_(
Tariff.is_daily.is_(True),
Subscription.is_daily_paused.is_(False),
),
)
)
)
@@ -815,29 +842,43 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
paid_subscriptions = active_subscriptions - trial_subscriptions
today = datetime.now(UTC).date()
now = datetime.now(UTC)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = today_start - timedelta(days=7)
month_ago = today_start - timedelta(days=30)
today_result = await db.execute(
select(func.count(Subscription.id)).where(
and_(Subscription.created_at >= today, Subscription.is_trial == False)
select(func.count(Transaction.id)).where(
and_(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed.is_(True),
Transaction.created_at >= today_start,
)
)
)
purchased_today = today_result.scalar()
purchased_today = today_result.scalar() or 0
week_ago = datetime.now(UTC) - timedelta(days=7)
week_result = await db.execute(
select(func.count(Subscription.id)).where(
and_(Subscription.created_at >= week_ago, Subscription.is_trial == False)
select(func.count(Transaction.id)).where(
and_(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed.is_(True),
Transaction.created_at >= week_ago,
)
)
)
purchased_week = week_result.scalar()
purchased_week = week_result.scalar() or 0
month_ago = datetime.now(UTC) - timedelta(days=30)
month_result = await db.execute(
select(func.count(Subscription.id)).where(
and_(Subscription.created_at >= month_ago, Subscription.is_trial == False)
select(func.count(Transaction.id)).where(
and_(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed.is_(True),
Transaction.created_at >= month_ago,
)
)
)
purchased_month = month_result.scalar()
purchased_month = month_result.scalar() or 0
try:
from app.database.crud.subscription_conversion import get_conversion_statistics
@@ -1371,11 +1412,26 @@ async def get_subscription_renewal_cost(
total_servers_discount = servers_discount_per_month * months_in_period
# В режиме fixed_with_topup при продлении используем фиксированный лимит
purchased_traffic = subscription.purchased_traffic_gb or 0
if settings.is_traffic_fixed():
renewal_traffic_gb = settings.get_fixed_traffic_limit()
traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit())
# Separate base traffic from purchased to avoid wrong tier lookup
elif purchased_traffic > 0:
base_traffic_gb = (subscription.traffic_limit_gb or 0) - purchased_traffic
if base_traffic_gb <= 0:
logger.warning(
'Purchased traffic >= total limit, pricing purchased portion only',
subscription_id=subscription.id,
traffic_limit_gb=subscription.traffic_limit_gb,
purchased_traffic_gb=purchased_traffic,
)
traffic_price_per_month = settings.get_traffic_price(purchased_traffic)
else:
traffic_price_per_month = settings.get_traffic_price(base_traffic_gb) + settings.get_traffic_price(
purchased_traffic
)
else:
renewal_traffic_gb = subscription.traffic_limit_gb
traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb)
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
traffic_discount_percent = _get_discount_percent(
user,
promo_group,
+13
View File
@@ -459,6 +459,19 @@ class TicketMessageCRUD:
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def get_first_message(db: AsyncSession, ticket_id: int) -> TicketMessage | None:
"""Получить первое сообщение в тикете"""
query = (
select(TicketMessage)
.where(TicketMessage.ticket_id == ticket_id)
.order_by(TicketMessage.created_at)
.limit(1)
)
result = await db.execute(query)
return result.scalar_one_or_none()
@staticmethod
async def get_last_message(db: AsyncSession, ticket_id: int) -> TicketMessage | None:
"""Получить последнее сообщение в тикете"""
+2
View File
@@ -1300,6 +1300,7 @@ async def create_user_by_oauth(
last_name: str | None = None,
username: str | None = None,
language: str = 'ru',
referred_by_id: int | None = None,
) -> User:
"""Create a new user via OAuth provider."""
referral_code = await create_unique_referral_code(db)
@@ -1319,6 +1320,7 @@ async def create_user_by_oauth(
first_name=sanitize_telegram_name(first_name) if first_name else None,
last_name=sanitize_telegram_name(last_name) if last_name else None,
language=normalized_language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
+171
View File
@@ -28,6 +28,7 @@ from sqlalchemy import (
TypeDecorator,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Mapped, backref, mapped_column, relationship
from sqlalchemy.sql import func
@@ -1060,6 +1061,7 @@ class User(Base):
promo_group = relationship('PromoGroup', back_populates='users')
user_promo_groups = relationship('UserPromoGroup', back_populates='user', cascade='all, delete-orphan')
poll_responses = relationship('PollResponse', back_populates='user')
admin_roles_rel = relationship('UserRole', foreign_keys='[UserRole.user_id]', back_populates='user')
notification_settings = Column(JSON, nullable=True, default=dict)
last_pinned_message_id = Column(Integer, nullable=True)
@@ -1151,6 +1153,10 @@ class User(Base):
class Subscription(Base):
__tablename__ = 'subscriptions'
__table_args__ = (
Index('ix_subscriptions_status_trial', 'status', 'is_trial'),
Index('ix_subscriptions_trial_created', 'is_trial', 'created_at'),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, unique=True)
@@ -1361,6 +1367,7 @@ class TrafficPurchase(Base):
"""Докупка трафика с индивидуальной датой истечения."""
__tablename__ = 'traffic_purchases'
__table_args__ = (Index('ix_traffic_purchases_created_at', 'created_at'),)
id = Column(Integer, primary_key=True, index=True)
subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='CASCADE'), nullable=False, index=True)
@@ -1380,6 +1387,11 @@ class TrafficPurchase(Base):
class Transaction(Base):
__tablename__ = 'transactions'
__table_args__ = (
Index('ix_transactions_type_created_completed', 'type', 'created_at', 'is_completed'),
Index('ix_transactions_user_created', 'user_id', 'created_at'),
Index('ix_transactions_type_method_created', 'type', 'payment_method', 'created_at'),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
@@ -1409,6 +1421,10 @@ class Transaction(Base):
class SubscriptionConversion(Base):
__tablename__ = 'subscription_conversions'
__table_args__ = (
Index('ix_sub_conversions_converted_at', 'converted_at'),
Index('ix_sub_conversions_user_id', 'user_id'),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
@@ -1576,6 +1592,7 @@ class PartnerApplication(Base):
telegram_channel = Column(String(255), nullable=True)
description = Column(Text, nullable=True)
expected_monthly_referrals = Column(Integer, nullable=True)
desired_commission_percent = Column(Integer, nullable=True)
status = Column(String(20), default=PartnerStatus.PENDING.value, nullable=False)
@@ -2810,3 +2827,157 @@ class PaymentMethodConfig(Base):
def __repr__(self) -> str:
return f"<PaymentMethodConfig method_id='{self.method_id}' order={self.sort_order} enabled={self.is_enabled}>"
class RequiredChannel(Base):
"""Channels that users must subscribe to in order to use the bot."""
__tablename__ = 'required_channels'
id = Column(Integer, primary_key=True, autoincrement=True)
channel_id = Column(String(100), unique=True, nullable=False) # -100xxx numeric format (always string)
channel_link = Column(String(500), nullable=True) # https://t.me/xxx
title = Column(String(255), nullable=True) # Display name
is_active = Column(Boolean, nullable=False, server_default='true')
sort_order = Column(Integer, nullable=False, server_default='0')
disable_trial_on_leave = Column(Boolean, nullable=False, server_default='true')
disable_paid_on_leave = Column(Boolean, nullable=False, server_default='false')
created_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
updated_at = Column(AwareDateTime(), nullable=True, onupdate=func.now())
def __repr__(self) -> str:
return f'<RequiredChannel id={self.id} channel_id={self.channel_id!r} active={self.is_active}>'
class UserChannelSubscription(Base):
"""Cache of user subscription status per required channel."""
__tablename__ = 'user_channel_subscriptions'
id = Column(Integer, primary_key=True, autoincrement=True)
telegram_id = Column(BigInteger, nullable=False)
channel_id = Column(String(100), nullable=False) # matches RequiredChannel.channel_id
is_member = Column(Boolean, nullable=False, server_default='false')
checked_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
__table_args__ = (
UniqueConstraint('telegram_id', 'channel_id', name='uq_user_channel_sub'),
# UniqueConstraint creates its own index; only add telegram_id index for
# "get all subs for user" queries
Index('ix_user_channel_sub_telegram_id', 'telegram_id'),
# Standalone channel_id index for delete_channel() bulk DELETE
Index('ix_user_channel_sub_channel_id', 'channel_id'),
)
def __repr__(self) -> str:
return (
f'<UserChannelSubscription telegram_id={self.telegram_id}'
f' channel={self.channel_id!r} member={self.is_member}>'
)
# ── RBAC / ABAC models ──────────────────────────────────────────────────
class AdminRole(Base):
"""Role definition with permission groups for admin cabinet RBAC."""
__tablename__ = 'admin_roles'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(Text, nullable=True)
level = Column(Integer, default=0, nullable=False)
permissions = Column(JSONB, default=list, nullable=False)
color = Column(String(7), nullable=True)
icon = Column(String(50), nullable=True)
is_system = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
creator = relationship('User', foreign_keys=[created_by])
user_roles = relationship('UserRole', back_populates='role')
def __repr__(self) -> str:
return f'<AdminRole id={self.id} name={self.name!r} level={self.level}>'
class UserRole(Base):
"""M2M assignment of users to admin roles."""
__tablename__ = 'user_roles'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
role_id = Column(Integer, ForeignKey('admin_roles.id', ondelete='CASCADE'), nullable=False)
assigned_by = Column(Integer, ForeignKey('users.id'), nullable=True)
assigned_at = Column(AwareDateTime(), server_default=func.now())
expires_at = Column(AwareDateTime(), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
__table_args__ = (UniqueConstraint('user_id', 'role_id', name='uq_user_role'),)
user = relationship('User', foreign_keys=[user_id], back_populates='admin_roles_rel')
role = relationship('AdminRole', back_populates='user_roles')
assigner = relationship('User', foreign_keys=[assigned_by])
def __repr__(self) -> str:
return f'<UserRole id={self.id} user_id={self.user_id} role_id={self.role_id}>'
class AccessPolicy(Base):
"""ABAC attribute-based access policy."""
__tablename__ = 'access_policies'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
role_id = Column(Integer, ForeignKey('admin_roles.id', ondelete='CASCADE'), nullable=True)
priority = Column(Integer, default=0, nullable=False)
effect = Column(String(10), nullable=False) # "allow" / "deny"
conditions = Column(JSONB, default=dict, nullable=False)
resource = Column(String(100), nullable=False)
actions = Column(JSONB, default=list, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
created_at = Column(AwareDateTime(), server_default=func.now())
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
role = relationship('AdminRole')
creator = relationship('User', foreign_keys=[created_by])
def __repr__(self) -> str:
return f'<AccessPolicy id={self.id} name={self.name!r} effect={self.effect!r}>'
class AdminAuditLog(Base):
"""Immutable audit log for admin actions."""
__tablename__ = 'admin_audit_log'
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
action = Column(String(100), nullable=False)
resource_type = Column(String(50), nullable=True)
resource_id = Column(String(100), nullable=True)
details = Column(JSONB, nullable=True)
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
status = Column(String(20), nullable=False)
request_method = Column(String(10), nullable=True)
request_path = Column(Text, nullable=True)
created_at = Column(AwareDateTime(), server_default=func.now())
__table_args__ = (
Index('ix_admin_audit_user_created', 'user_id', 'created_at'),
Index('ix_admin_audit_resource', 'resource_type', 'resource_id'),
Index('ix_admin_audit_created', 'created_at'),
)
user = relationship('User', foreign_keys=[user_id])
def __repr__(self) -> str:
return f'<AdminAuditLog id={self.id} action={self.action!r} status={self.status!r}>'
+22 -1
View File
@@ -461,9 +461,19 @@ class RemnaWaveAPI:
if active_internal_squads:
data['activeInternalSquads'] = active_internal_squads
logger.debug('Создание пользователя в панели', data=data)
logger.info(
'POST /api/users payload',
username=data.get('username'),
hwidDeviceLimit=data.get('hwidDeviceLimit'),
status=data.get('status'),
)
response = await self._make_request('POST', '/api/users', data)
user = self._parse_user(response['response'])
logger.info(
'POST /api/users response',
uuid=user.uuid,
response_hwidDeviceLimit=user.hwid_device_limit,
)
return await self.enrich_user_with_happ_link(user)
async def get_user_by_uuid(self, uuid: str) -> RemnaWaveUser | None:
@@ -553,8 +563,19 @@ class RemnaWaveAPI:
if active_internal_squads is not None:
data['activeInternalSquads'] = active_internal_squads
logger.info(
'PATCH /api/users payload',
uuid=uuid,
hwidDeviceLimit=data.get('hwidDeviceLimit'),
status=data.get('status'),
)
response = await self._make_request('PATCH', '/api/users', data)
user = self._parse_user(response['response'])
logger.info(
'PATCH /api/users response',
uuid=uuid,
response_hwidDeviceLimit=user.hwid_device_limit,
)
return await self.enrich_user_with_happ_link(user)
async def delete_user(self, uuid: str) -> bool:
+1
View File
@@ -24,6 +24,7 @@ from . import (
referrals,
remnawave,
reports,
required_channels,
rules,
servers,
statistics,
+4 -3
View File
@@ -1,3 +1,4 @@
import html
from datetime import datetime
import structlog
@@ -154,7 +155,7 @@ async def create_backup_handler(callback: types.CallbackQuery, db_user: User, db
)
else:
await progress_msg.edit_text(
f'❌ <b>Ошибка создания бекапа</b>\n\n{message}',
f'❌ <b>Ошибка создания бекапа</b>\n\n{html.escape(message)}',
parse_mode='HTML',
reply_markup=get_backup_main_keyboard(db_user.language),
)
@@ -431,11 +432,11 @@ async def handle_backup_file_upload(message: types.Message, db_user: User, db: A
inline_keyboard=[
[
InlineKeyboardButton(
text='✅ Восстановить', callback_data=f'backup_restore_uploaded_{temp_path.name}'
text='✅ Восстановить', callback_data=f'backup_restore_execute_{temp_path.name}'
),
InlineKeyboardButton(
text='🗑️ Очистить и восстановить',
callback_data=f'backup_restore_uploaded_clear_{temp_path.name}',
callback_data=f'backup_restore_clear_{temp_path.name}',
),
],
[InlineKeyboardButton(text='❌ Отмена', callback_data='backup_panel')],
+143 -4
View File
@@ -5,6 +5,7 @@ import time
from collections.abc import Iterable
from datetime import UTC, datetime
import structlog
from aiogram import Dispatcher, F, types
from aiogram.filters import BaseFilter, StateFilter
from aiogram.fsm.context import FSMContext
@@ -32,6 +33,8 @@ from app.utils.currency_converter import currency_converter
from app.utils.decorators import admin_required, error_handler
logger = structlog.get_logger(__name__)
CATEGORY_PAGE_SIZE = 10
SETTINGS_PAGE_SIZE = 8
SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE = 6
@@ -318,10 +321,11 @@ def _get_group_status(group_key: str) -> tuple[str, str]:
if key == 'core':
token_ok = bool(getattr(settings, 'BOT_TOKEN', ''))
channel_ok = bool(settings.CHANNEL_LINK or not settings.CHANNEL_IS_REQUIRED_SUB)
if token_ok and channel_ok:
# Channel subscription channels are now managed via DB (admin panel),
# not a single CHANNEL_LINK setting. Dashboard cannot async-query DB here.
if token_ok:
return '🟢', 'Бот готов к работе'
return '🟡', 'Проверьте токен и обязательную подписку'
return '🟡', 'Проверьте токен бота'
if key == 'subscriptions':
price_ready = settings.PRICE_30_DAYS > 0 and settings.AVAILABLE_SUBSCRIPTION_PERIODS
@@ -807,7 +811,7 @@ async def handle_import_message(
content = ''
if message.document:
buffer = io.BytesIO()
await message.document.download(destination=buffer)
await message.bot.download(message.document, destination=buffer)
buffer.seek(0)
content = buffer.read().decode('utf-8', errors='ignore')
else:
@@ -2689,6 +2693,128 @@ async def apply_setting_choice(
await callback.answer('Значение обновлено')
# ── Remnawave App Config Selector ──
@admin_required
@error_handler
async def show_remna_config_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession, **kwargs):
"""Show available Remnawave subscription page configs for selection."""
current_uuid = bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
configs = await api.get_subscription_page_configs()
except Exception as e:
logger.error('Failed to load Remnawave configs', error=e)
await callback.answer('Ошибка загрузки конфигов', show_alert=True)
return
keyboard: list[list[types.InlineKeyboardButton]] = []
if not configs:
text = (
'📱 <b>Конфиг приложений (Remnawave)</b>\n\n'
'В Remnawave не найдено конфигураций страниц подписки.\n\n'
'Создайте конфигурацию в панели Remnawave, затем вернитесь сюда для выбора.'
)
else:
text = '📱 <b>Конфиг приложений (Remnawave)</b>\n\n'
if current_uuid:
current_name = next((c.name for c in configs if c.uuid == current_uuid), None)
if current_name:
text += f'✅ Текущий: <b>{html.escape(current_name)}</b>\n\n'
else:
text += f'⚠️ Текущий UUID не найден: <code>{html.escape(str(current_uuid))}</code>\n\n'
else:
text += 'ℹ️ Конфиг не выбран (гайд-режим отключён)\n\n'
text += 'Выберите конфигурацию для гайд-режима:'
for config in configs:
prefix = '' if config.uuid == current_uuid else ''
keyboard.append(
[
types.InlineKeyboardButton(
text=f'{prefix}{config.name}',
callback_data=f'admin_remna_select_{config.uuid}',
)
]
)
if current_uuid:
keyboard.append(
[
types.InlineKeyboardButton(
text='🗑 Сбросить (отключить гайд-режим)',
callback_data='admin_remna_clear',
)
]
)
keyboard.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_submenu_settings')])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode='HTML',
)
await callback.answer()
@admin_required
@error_handler
async def select_remna_config(callback: types.CallbackQuery, db_user: User, db: AsyncSession, **kwargs):
"""Select a Remnawave subscription page config."""
uuid = callback.data.replace('admin_remna_select_', '')
# Validate UUID format
import re as _re
if not _re.match(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', uuid):
await callback.answer('Некорректный UUID конфигурации', show_alert=True)
return
try:
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid)
await db.commit()
except Exception as e:
logger.error('Failed to save Remnawave config UUID', error=e)
await callback.answer('Ошибка сохранения', show_alert=True)
return
# Invalidate app config cache
from app.handlers.subscription.common import invalidate_app_config_cache
invalidate_app_config_cache()
await callback.answer('✅ Конфиг выбран', show_alert=True)
# Re-render the menu
await show_remna_config_menu(callback, db_user=db_user, db=db)
@admin_required
@error_handler
async def clear_remna_config(callback: types.CallbackQuery, db_user: User, db: AsyncSession, **kwargs):
"""Clear the Remnawave config, disabling guide mode until new config is selected."""
try:
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', '')
await db.commit()
except Exception as e:
logger.error('Failed to clear Remnawave config', error=e)
await callback.answer('Ошибка сброса', show_alert=True)
return
from app.handlers.subscription.common import invalidate_app_config_cache
invalidate_app_config_cache()
await callback.answer('✅ Конфиг сброшен', show_alert=True)
await show_remna_config_menu(callback, db_user=db_user, db=db)
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_bot_config_menu,
@@ -2788,3 +2914,16 @@ def register_handlers(dp: Dispatcher) -> None:
handle_import_message,
BotConfigStates.waiting_for_import_file,
)
# Remnawave app config selector
dp.callback_query.register(
show_remna_config_menu,
F.data == 'admin_remna_config',
)
dp.callback_query.register(
select_remna_config,
F.data.startswith('admin_remna_select_'),
)
dp.callback_query.register(
clear_remna_config,
F.data == 'admin_remna_clear',
)
+3 -12
View File
@@ -613,7 +613,9 @@ async def show_messages_history(callback: types.CallbackQuery, db_user: User, db
)
message_preview = (
broadcast.message_text[:100] + '...' if len(broadcast.message_text) > 100 else broadcast.message_text
broadcast.message_text[:100] + '...'
if broadcast.message_text and len(broadcast.message_text) > 100
else (broadcast.message_text or '📊 Опрос')
)
import html
@@ -1402,17 +1404,6 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
# Задержка между батчами для соблюдения rate limits
await asyncio.sleep(_BATCH_DELAY)
# Фоновая очистка заблокировавших бота пользователей
if blocked_telegram_ids:
from app.services.broadcast_service import _background_tasks, cleanup_blocked_broadcast_users
task = asyncio.create_task(
cleanup_blocked_broadcast_users(blocked_telegram_ids),
name=f'broadcast-{broadcast_id}-blocked-cleanup',
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
# Учитываем пропущенных email-only пользователей
skipped_email_users = total_users_count - total_recipients
if skipped_email_users > 0:
+8 -21
View File
@@ -137,13 +137,16 @@ def _build_notification_settings_view(language: str):
return summary_text, keyboard
def _build_notification_preview_message(language: str, notification_type: str):
async def _build_notification_preview_message(language: str, notification_type: str):
texts = get_texts(language)
now = datetime.now(UTC)
price_30_days = settings.format_price(settings.PRICE_30_DAYS)
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from app.keyboards.inline import get_channel_sub_keyboard
from app.services.channel_subscription_service import channel_subscription_service
header = '🧪 <b>Тестовое уведомление мониторинга</b>\n\n'
if notification_type == 'trial_channel_unsubscribed':
@@ -157,25 +160,9 @@ def _build_notification_preview_message(language: str, notification_type: str):
)
check_button = texts.t('CHANNEL_CHECK_BUTTON', '✅ Я подписался')
message = template.format(check_button=check_button)
buttons: list[list[InlineKeyboardButton]] = []
if settings.CHANNEL_LINK:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
url=settings.CHANNEL_LINK,
)
]
)
buttons.append(
[
InlineKeyboardButton(
text=check_button,
callback_data='sub_channel_check',
)
]
)
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
# Use all required channels for the preview keyboard
required_channels = await channel_subscription_service.get_required_channels()
keyboard = get_channel_sub_keyboard(required_channels, language=language)
elif notification_type == 'expired_1d':
template = texts.get(
'SUBSCRIPTION_EXPIRED_1D',
@@ -307,7 +294,7 @@ def _build_notification_preview_message(language: str, notification_type: str):
async def _send_notification_preview(bot, chat_id: int, language: str, notification_type: str) -> None:
message, keyboard = _build_notification_preview_message(language, notification_type)
message, keyboard = await _build_notification_preview_message(language, notification_type)
await bot.send_message(
chat_id,
message,
+7
View File
@@ -180,6 +180,13 @@ CORE_PRICING_ENTRIES: tuple[SettingEntry, ...] = (
label_en='🔄 Reset traffic on payment',
action='toggle',
),
SettingEntry(
key='RESET_TRAFFIC_ON_TARIFF_SWITCH',
section='core',
label_ru='🔄 Сбрасывать трафик при смене тарифа',
label_en='🔄 Reset traffic on tariff switch',
action='toggle',
),
SettingEntry(
key='DEFAULT_TRAFFIC_RESET_STRATEGY',
section='core',
+273
View File
@@ -0,0 +1,273 @@
"""Admin handler for managing required channel subscriptions."""
import structlog
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from app.database.crud.required_channel import (
add_channel,
delete_channel,
get_all_channels,
get_channel_by_id,
toggle_channel,
validate_channel_id,
)
from app.database.database import AsyncSessionLocal
from app.services.channel_subscription_service import channel_subscription_service
from app.utils.decorators import admin_required
logger = structlog.get_logger(__name__)
router = Router(name='admin_required_channels')
class AddChannelStates(StatesGroup):
waiting_channel_id = State()
waiting_channel_link = State()
waiting_channel_title = State()
# -- List channels ----------------------------------------------------------------
def _channels_keyboard(channels: list) -> InlineKeyboardMarkup:
buttons = []
for ch in channels:
status = '' if ch.is_active else ''
title = ch.title or ch.channel_id
buttons.append(
[
InlineKeyboardButton(
text=f'{status} {title}',
callback_data=f'reqch:view:{ch.id}',
)
]
)
buttons.append([InlineKeyboardButton(text=' Добавить канал', callback_data='reqch:add')])
buttons.append([InlineKeyboardButton(text='◀️ Назад', callback_data='admin_submenu_settings')])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def _channel_detail_keyboard(channel_id: int, is_active: bool) -> InlineKeyboardMarkup:
toggle_text = '❌ Отключить' if is_active else '✅ Включить'
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=toggle_text, callback_data=f'reqch:toggle:{channel_id}')],
[InlineKeyboardButton(text='🗑 Удалить', callback_data=f'reqch:delete:{channel_id}')],
[InlineKeyboardButton(text='◀️ К списку', callback_data='reqch:list')],
]
)
@router.callback_query(F.data == 'reqch:list')
@admin_required
async def show_channels_list(callback: CallbackQuery, **kwargs) -> None:
async with AsyncSessionLocal() as db:
channels = await get_all_channels(db)
if not channels:
text = '<b>📢 Обязательные каналы</b>\n\nКаналы не настроены. Нажмите «Добавить» чтобы создать.'
else:
lines = ['<b>📢 Обязательные каналы</b>\n']
for ch in channels:
status = '' if ch.is_active else ''
title = ch.title or ch.channel_id
lines.append(f'{status} <code>{ch.channel_id}</code> — {title}')
text = '\n'.join(lines)
await callback.message.edit_text(text, reply_markup=_channels_keyboard(channels))
await callback.answer()
@router.callback_query(F.data.startswith('reqch:view:'))
@admin_required
async def view_channel(callback: CallbackQuery, **kwargs) -> None:
try:
channel_db_id = int(callback.data.split(':')[2])
except (ValueError, IndexError):
await callback.answer('Неверный ID канала', show_alert=True)
return
async with AsyncSessionLocal() as db:
ch = await get_channel_by_id(db, channel_db_id)
if not ch:
await callback.answer('Канал не найден', show_alert=True)
return
status = '✅ Активен' if ch.is_active else '❌ Отключён'
text = (
f'<b>{ch.title or "Без названия"}</b>\n\n'
f'<b>ID:</b> <code>{ch.channel_id}</code>\n'
f'<b>Ссылка:</b> {ch.channel_link or ""}\n'
f'<b>Статус:</b> {status}\n'
f'<b>Порядок:</b> {ch.sort_order}'
)
await callback.message.edit_text(text, reply_markup=_channel_detail_keyboard(ch.id, ch.is_active))
await callback.answer()
# -- Toggle / Delete ---------------------------------------------------------------
@router.callback_query(F.data.startswith('reqch:toggle:'))
@admin_required
async def toggle_channel_handler(callback: CallbackQuery, **kwargs) -> None:
try:
channel_db_id = int(callback.data.split(':')[2])
except (ValueError, IndexError):
await callback.answer('Неверный ID канала', show_alert=True)
return
async with AsyncSessionLocal() as db:
ch = await toggle_channel(db, channel_db_id)
if ch:
await channel_subscription_service.invalidate_channels_cache()
status = 'включён' if ch.is_active else 'отключён'
await callback.answer(f'Канал {status}', show_alert=True)
# Refresh list
async with AsyncSessionLocal() as db:
channels = await get_all_channels(db)
await callback.message.edit_text(
'<b>📢 Обязательные каналы</b>',
reply_markup=_channels_keyboard(channels),
)
@router.callback_query(F.data.startswith('reqch:delete:'))
@admin_required
async def delete_channel_handler(callback: CallbackQuery, **kwargs) -> None:
try:
channel_db_id = int(callback.data.split(':')[2])
except (ValueError, IndexError):
await callback.answer('Неверный ID канала', show_alert=True)
return
async with AsyncSessionLocal() as db:
ok = await delete_channel(db, channel_db_id)
if ok:
await channel_subscription_service.invalidate_channels_cache()
await callback.answer('Канал удалён', show_alert=True)
else:
await callback.answer('Ошибка удаления', show_alert=True)
async with AsyncSessionLocal() as db:
channels = await get_all_channels(db)
await callback.message.edit_text(
'<b>📢 Обязательные каналы</b>',
reply_markup=_channels_keyboard(channels),
)
# -- Add channel flow --------------------------------------------------------------
@router.callback_query(F.data == 'reqch:add')
@admin_required
async def start_add_channel(callback: CallbackQuery, state: FSMContext, **kwargs) -> None:
await state.set_state(AddChannelStates.waiting_channel_id)
await callback.message.edit_text(
'<b>➕ Добавить канал</b>\n\n'
'Отправьте числовой ID канала (например <code>1234567890</code>).\n'
'Префикс <code>-100</code> добавляется автоматически.'
)
await callback.answer()
@router.message(AddChannelStates.waiting_channel_id)
@admin_required
async def process_channel_id(message: Message, state: FSMContext, **kwargs) -> None:
if not message.text:
await message.answer('Отправьте текстовое сообщение.')
return
channel_id = message.text.strip()
# Validate and normalize channel_id (auto-prefixes -100 for bare digits)
try:
channel_id = validate_channel_id(channel_id)
except ValueError as e:
await message.answer(f'Неверный формат. {e}\n\nПопробуйте ещё раз:')
return
await state.update_data(channel_id=channel_id)
await state.set_state(AddChannelStates.waiting_channel_link)
await message.answer(
f'Канал: <code>{channel_id}</code>\n\n'
'Теперь отправьте ссылку на канал (например <code>https://t.me/mychannel</code>)\n'
'Или отправьте <code>-</code> чтобы пропустить:'
)
@router.message(AddChannelStates.waiting_channel_link)
@admin_required
async def process_channel_link(message: Message, state: FSMContext, **kwargs) -> None:
if not message.text:
await message.answer('Отправьте текстовое сообщение.')
return
link = message.text.strip()
if link == '-':
link = None
if link is not None:
# Validate and normalize channel link
if not link.startswith(('https://t.me/', 'http://t.me/', '@')):
await message.answer('Ссылка должна быть URL вида t.me или @username. Попробуйте ещё раз:')
return
if link.startswith('@'):
link = f'https://t.me/{link[1:]}'
if link.startswith('http://'):
link = link.replace('http://', 'https://', 1)
await state.update_data(channel_link=link)
await state.set_state(AddChannelStates.waiting_channel_title)
await message.answer(
'Отправьте название канала (например <code>Новости проекта</code>)\n'
'Или отправьте <code>-</code> чтобы пропустить:'
)
@router.message(AddChannelStates.waiting_channel_title)
@admin_required
async def process_channel_title(message: Message, state: FSMContext, **kwargs) -> None:
if not message.text:
await message.answer('Отправьте текстовое сообщение.')
return
title = message.text.strip()
if title == '-':
title = None
data = await state.get_data()
await state.clear()
async with AsyncSessionLocal() as db:
try:
ch = await add_channel(
db,
channel_id=data['channel_id'],
channel_link=data.get('channel_link'),
title=title,
)
await channel_subscription_service.invalidate_channels_cache()
text = (
'✅ Канал добавлен!\n\n'
f'<b>ID:</b> <code>{ch.channel_id}</code>\n'
f'<b>Ссылка:</b> {ch.channel_link or ""}\n'
f'<b>Название:</b> {ch.title or ""}'
)
except Exception as e:
text = '❌ Ошибка добавления канала. Попробуйте ещё раз.'
logger.error('Error adding channel', error=e)
async with AsyncSessionLocal() as db:
channels = await get_all_channels(db)
await message.answer(text, reply_markup=_channels_keyboard(channels))
def register_handlers(dp_router: Router) -> None:
dp_router.include_router(router)
+25 -4
View File
@@ -4013,7 +4013,11 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm
async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool:
try:
from app.database.crud.subscription import deactivate_subscription, get_subscription_by_user_id
from app.database.crud.subscription import (
deactivate_subscription,
get_subscription_by_user_id,
is_active_paid_subscription,
)
from app.services.subscription_service import SubscriptionService
subscription = await get_subscription_by_user_id(db, user_id)
@@ -4021,6 +4025,13 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id
logger.error('Подписка не найдена для пользователя', user_id=user_id)
return False
if is_active_paid_subscription(subscription):
logger.info(
'⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка',
user_id=user_id,
)
return False
await deactivate_subscription(db, subscription)
user = await get_user_by_id(db, user_id)
@@ -5099,10 +5110,11 @@ async def _change_subscription_type(db: AsyncSession, user_id: int, new_type: st
old_type = 'триальной' if subscription.is_trial else 'платной'
new_type_text = 'триальной' if new_is_trial else 'платной'
was_trial = subscription.is_trial
subscription.is_trial = new_is_trial
subscription.updated_at = datetime.now(UTC)
if not new_is_trial and subscription.is_trial:
if not new_is_trial and was_trial:
user = await get_user_by_id(db, user_id)
if user:
user.has_had_paid_subscription = True
@@ -5315,11 +5327,20 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
# Сброс использованного трафика по админ-настройке
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
subscription.traffic_used_gb = 0.0
await db.commit()
# Синхронизируем с RemnaWave
# Синхронизируем с RemnaWave (сброс трафика по админ-настройке)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='смена тарифа (админ)',
)
logger.info(
'Админ изменил тариф пользователя',
+139 -11
View File
@@ -1,5 +1,7 @@
"""Handler for Freekassa balance top-up."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
@@ -18,12 +20,29 @@ from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
FREEKASSA_SUB_METHODS = {
'freekassa_sbp': {'payment_system_id': 44, 'get_name': lambda: settings.get_freekassa_sbp_display_name()},
'freekassa_card': {'payment_system_id': 36, 'get_name': lambda: settings.get_freekassa_card_display_name()},
}
def _resolve_freekassa_params(
payment_method: str | None,
) -> tuple[int | None, str]:
"""Return (payment_system_id, display_name) for a freekassa sub-method key."""
if payment_method and payment_method in FREEKASSA_SUB_METHODS:
meta = FREEKASSA_SUB_METHODS[payment_method]
return meta['payment_system_id'], meta['get_name']()
return None, settings.get_freekassa_display_name()
async def _create_freekassa_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method: str | None = None,
):
"""
Common logic for creating Freekassa payment and sending response.
@@ -34,10 +53,13 @@ async def _create_freekassa_payment_and_respond(
db: Database session
amount_kopeks: Amount in kopeks
edit_message: Whether to edit existing message or send new one
payment_method: Sub-method key (freekassa_sbp, freekassa_card, or None for default)
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
ps_id, display_name = _resolve_freekassa_params(payment_method)
# Create payment
payment_service = PaymentService()
@@ -53,6 +75,8 @@ async def _create_freekassa_payment_and_respond(
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
payment_system_id=ps_id,
payment_method=payment_method,
)
if not result:
@@ -74,7 +98,6 @@ async def _create_freekassa_payment_and_respond(
return
payment_url = result.get('payment_url')
display_name = settings.get_freekassa_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
@@ -103,7 +126,7 @@ async def _create_freekassa_payment_and_respond(
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
).format(name=html.escape(display_name), amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
@@ -128,9 +151,11 @@ async def process_freekassa_payment_amount(
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
payment_method: str | None = None,
):
"""
Process payment amount directly (called from quick_amount handlers).
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
"""
texts = get_texts(db_user.language)
@@ -183,21 +208,44 @@ async def process_freekassa_payment_amount(
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
payment_method=payment_method,
)
@error_handler
async def start_freekassa_topup(
async def _start_freekassa_topup_impl(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
payment_method: str,
):
"""
Start Freekassa top-up process - ask for amount.
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
"""
texts = get_texts(db_user.language)
# Проверка доступности метода
if not settings.is_freekassa_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
if payment_method == 'freekassa_sbp' and not settings.is_freekassa_sbp_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
if payment_method == 'freekassa_card' and not settings.is_freekassa_card_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
@@ -215,11 +263,11 @@ async def start_freekassa_topup(
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='freekassa')
await state.update_data(payment_method=payment_method)
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_freekassa_display_name()
_, display_name = _resolve_freekassa_params(payment_method)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
@@ -249,6 +297,39 @@ async def start_freekassa_topup(
)
@error_handler
async def start_freekassa_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_freekassa_topup_impl(callback, db_user, state, 'freekassa')
@error_handler
async def start_freekassa_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_freekassa_topup_impl(callback, db_user, state, 'freekassa_sbp')
@error_handler
async def start_freekassa_card_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_freekassa_topup_impl(callback, db_user, state, 'freekassa_card')
FREEKASSA_PAYMENT_METHODS = {'freekassa', 'freekassa_sbp', 'freekassa_card'}
@error_handler
async def process_freekassa_custom_amount(
message: types.Message,
@@ -260,7 +341,7 @@ async def process_freekassa_custom_amount(
Process custom amount input for Freekassa payment.
"""
data = await state.get_data()
if data.get('payment_method') != 'freekassa':
if data.get('payment_method') not in FREEKASSA_PAYMENT_METHODS:
return
texts = get_texts(db_user.language)
@@ -285,19 +366,21 @@ async def process_freekassa_custom_amount(
db=db,
amount_kopeks=amount_kopeks,
state=state,
payment_method=data.get('payment_method'),
)
@error_handler
async def process_freekassa_quick_amount(
async def _process_freekassa_quick_amount_impl(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
payment_method: str,
):
"""
Process quick amount selection for Freekassa payment.
Called when user clicks a predefined amount button.
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
"""
texts = get_texts(db_user.language)
@@ -308,7 +391,21 @@ async def process_freekassa_quick_amount(
)
return
# Extract amount from callback data: topup_amount|freekassa|{amount_kopeks}
if payment_method == 'freekassa_sbp' and not settings.is_freekassa_sbp_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
if payment_method == 'freekassa_card' and not settings.is_freekassa_card_enabled():
await callback.answer(
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|{method}|{amount_kopeks}
try:
parts = callback.data.split('|')
if len(parts) >= 3:
@@ -363,4 +460,35 @@ async def process_freekassa_quick_amount(
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
payment_method=payment_method,
)
@error_handler
async def process_freekassa_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa')
@error_handler
async def process_freekassa_sbp_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_sbp')
@error_handler
async def process_freekassa_card_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_card')
+36 -11
View File
@@ -24,6 +24,15 @@ logger = structlog.get_logger(__name__)
TRANSACTIONS_PER_PAGE = 10
CREDIT_TRANSACTION_TYPES: frozenset[str] = frozenset(
{
TransactionType.DEPOSIT.value,
TransactionType.REFERRAL_REWARD.value,
TransactionType.REFUND.value,
TransactionType.POLL_REWARD.value,
}
)
async def route_payment_by_method(
message: types.Message, db_user: User, amount_kopeks: int, state: FSMContext, payment_method: str
@@ -113,11 +122,13 @@ async def route_payment_by_method(
await process_cloudpayments_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'freekassa':
if payment_method in ('freekassa', 'freekassa_sbp', 'freekassa_card'):
from .freekassa import process_freekassa_payment_amount
async with AsyncSessionLocal() as db:
await process_freekassa_payment_amount(message, db_user, db, amount_kopeks, state)
await process_freekassa_payment_amount(
message, db_user, db, amount_kopeks, state, payment_method=payment_method
)
return True
if payment_method == 'kassa_ai':
@@ -275,10 +286,11 @@ async def show_balance_history(callback: types.CallbackQuery, db_user: User, db:
text = '📊 <b>История операций</b>\n\n'
for transaction in unique_transactions:
emoji = '💰' if transaction.type == TransactionType.DEPOSIT.value else '💸'
is_credit = transaction.type in CREDIT_TRANSACTION_TYPES
emoji = '💰' if is_credit else '💸'
amount_text = (
f'+{texts.format_price(transaction.amount_kopeks)}'
if transaction.type == TransactionType.DEPOSIT.value
if is_credit
else f'-{texts.format_price(abs(transaction.amount_kopeks))}'
)
@@ -513,15 +525,17 @@ async def handle_successful_topup_with_cart(user_id: int, amount_kopeks: int, bo
]
)
if 0 < total_price <= user.balance_kopeks:
balance_hint = 'Средств на балансе достаточно для оформления.'
else:
missing = max(total_price - user.balance_kopeks, 0)
balance_hint = f'Не хватает: {texts.format_price(missing)}'
success_text = (
f'✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n'
f'💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n'
f'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
f'Обязательно активируйте подписку отдельно!\n\n'
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
f'подписка будет приобретена автоматически после пополнения баланса.\n\n'
f'🛒 У вас есть сохраненная корзина подписки\n'
f'Стоимость: {texts.format_price(total_price)}\n\n'
f'🛒 У вас есть сохранённая корзина на {texts.format_price(total_price)}\n'
f'{balance_hint}\n\n'
f'Хотите продолжить оформление?'
)
@@ -838,10 +852,21 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_cloudpayments_payment, F.data == 'topup_cloudpayments')
dp.callback_query.register(handle_cloudpayments_quick_amount, F.data.startswith('topup_amount|cloudpayments|'))
from .freekassa import process_freekassa_quick_amount, start_freekassa_topup
from .freekassa import (
process_freekassa_card_quick_amount,
process_freekassa_quick_amount,
process_freekassa_sbp_quick_amount,
start_freekassa_card_topup,
start_freekassa_sbp_topup,
start_freekassa_topup,
)
dp.callback_query.register(start_freekassa_topup, F.data == 'topup_freekassa')
dp.callback_query.register(process_freekassa_quick_amount, F.data.startswith('topup_amount|freekassa|'))
dp.callback_query.register(start_freekassa_sbp_topup, F.data == 'topup_freekassa_sbp')
dp.callback_query.register(process_freekassa_sbp_quick_amount, F.data.startswith('topup_amount|freekassa_sbp|'))
dp.callback_query.register(start_freekassa_card_topup, F.data == 'topup_freekassa_card')
dp.callback_query.register(process_freekassa_card_quick_amount, F.data.startswith('topup_amount|freekassa_card|'))
from .kassa_ai import process_kassa_ai_quick_amount, start_kassa_ai_topup
+176
View File
@@ -0,0 +1,176 @@
"""ChatMemberUpdated event handler for real-time channel subscription tracking.
KEY COMPONENT for scalability: the bot receives push notifications from Telegram
when users join/leave channels, instead of polling via getChatMember.
Requirement: bot must be admin in each required channel.
IMPORTANT: Events are FILTERED to only process required channels.
Without filtering, the bot would process events from ALL channels it admins.
"""
from datetime import UTC, datetime
import structlog
from aiogram import Bot, Router
from aiogram.filters import IS_MEMBER, IS_NOT_MEMBER, ChatMemberUpdatedFilter
from aiogram.types import ChatMemberUpdated
from app.config import settings
from app.database.crud.subscription import deactivate_subscription, reactivate_subscription
from app.database.crud.user import get_user_by_telegram_id
from app.database.database import AsyncSessionLocal
from app.database.models import SubscriptionStatus, UserStatus
from app.keyboards.inline import get_channel_sub_keyboard
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.services.channel_subscription_service import channel_subscription_service
from app.services.subscription_service import SubscriptionService
logger = structlog.get_logger(__name__)
router = Router(name='channel_member')
async def _is_required_channel(channel_id: str) -> bool:
"""Check if the channel_id is one of our required channels."""
required_ids = await channel_subscription_service.get_required_channel_ids()
return channel_id in required_ids
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=IS_NOT_MEMBER >> IS_MEMBER))
async def on_user_joined_channel(event: ChatMemberUpdated, bot: Bot) -> None:
"""User subscribed to a channel -- update cache and reactivate VPN if applicable."""
user = event.new_chat_member.user
channel_id = str(event.chat.id) # Normalize int to str (DB stores string)
# FILTER: Only process events for required channels
if not await _is_required_channel(channel_id):
return
await channel_subscription_service.on_user_joined(user.id, channel_id)
# Check if user is now subscribed to ALL required channels
if not settings.CHANNEL_IS_REQUIRED_SUB:
return
is_all_subscribed = await channel_subscription_service.is_user_subscribed_to_all(user.id)
if not is_all_subscribed:
return # Still missing some channels
# Reactivate subscription if it was disabled due to channel unsubscribe
async with AsyncSessionLocal() as db:
try:
db_user = await get_user_by_telegram_id(db, user.id)
if not db_user or not db_user.subscription:
return
if db_user.status == UserStatus.BLOCKED.value:
return
subscription = db_user.subscription
if subscription.status != SubscriptionStatus.DISABLED.value:
return
# Don't reactivate expired subscriptions
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
return
await reactivate_subscription(db, subscription)
logger.info('Subscription reactivated via channel event', telegram_id=user.id)
# Re-enable in RemnaWave panel
if db_user.remnawave_uuid:
service = SubscriptionService()
try:
await service.enable_remnawave_user(db_user.remnawave_uuid)
except Exception as api_error:
logger.error('Failed to enable RemnaWave user', error=api_error)
# Notify the user
try:
texts = get_texts(db_user.language or DEFAULT_LANGUAGE)
notification_text = texts.t(
'SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE',
'Your subscription has been restored! Thank you for subscribing to the channels.',
)
await bot.send_message(user.id, notification_text)
except Exception as notify_error:
logger.warning('Failed to send notification', telegram_id=user.id, error=notify_error)
await db.commit()
except Exception as e:
logger.error('Error reactivating subscription on channel join', error=e)
await db.rollback()
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=IS_MEMBER >> IS_NOT_MEMBER))
async def on_user_left_channel(event: ChatMemberUpdated, bot: Bot) -> None:
"""User unsubscribed from a channel -- update cache and deactivate VPN if applicable."""
user = event.old_chat_member.user
channel_id = str(event.chat.id) # Normalize int to str (DB stores string)
# FILTER: Only process events for required channels
if not await _is_required_channel(channel_id):
return
await channel_subscription_service.on_user_left(user.id, channel_id)
if not settings.CHANNEL_IS_REQUIRED_SUB:
return
# Skip admins -- never deactivate admin subscriptions
if settings.is_admin(user.id):
return
# Fetch per-channel settings to decide whether to disable
channel_settings = await channel_subscription_service.get_channel_settings(channel_id)
if not channel_settings:
return
async with AsyncSessionLocal() as db:
try:
db_user = await get_user_by_telegram_id(db, user.id)
if not db_user or not db_user.subscription:
return
subscription = db_user.subscription
if subscription.status != SubscriptionStatus.ACTIVE.value:
return
# Per-channel settings: check if this channel requires deactivation
if not channel_subscription_service.should_disable_subscription(channel_settings, subscription.is_trial):
return
await deactivate_subscription(db, subscription)
logger.info('Subscription deactivated via channel event', telegram_id=user.id)
# Disable in RemnaWave panel
if db_user.remnawave_uuid:
service = SubscriptionService()
try:
await service.disable_remnawave_user(db_user.remnawave_uuid)
except Exception as api_error:
logger.error('Failed to disable RemnaWave user', error=api_error)
# Notify the user with channel subscription keyboard
try:
texts = get_texts(db_user.language or DEFAULT_LANGUAGE)
unsub_channels = await channel_subscription_service.get_channels_with_status(user.id)
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
'Your subscription has been paused because you left a required channel.',
)
channel_kb = get_channel_sub_keyboard(unsub_channels, language=db_user.language or DEFAULT_LANGUAGE)
await bot.send_message(user.id, notification_text, reply_markup=channel_kb)
except Exception as notify_error:
logger.warning('Failed to send notification', telegram_id=user.id, error=notify_error)
await db.commit()
except Exception as e:
logger.error('Error deactivating subscription on channel leave', error=e)
await db.rollback()
def register_handlers(dp_router: Router) -> None:
"""Register channel member event handlers on the dispatcher/router."""
dp_router.include_router(router)
+8
View File
@@ -139,6 +139,14 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
'PROMOCODE_ACTIVE_DISCOUNT_EXISTS',
'❌ У вас уже есть активная скидка. Используйте её перед активацией новой.',
),
'no_subscription_for_days': texts.t(
'PROMOCODE_NO_SUBSCRIPTION',
'❌ Для активации этого промокода необходима подписка (активная или просроченная).',
),
'trial_subscription_not_eligible': texts.t(
'PROMOCODE_TRIAL_NOT_ELIGIBLE',
'❌ Промокод на дни недоступен для пробной подписки. Оформите платную подписку.',
),
'daily_limit': texts.t(
'PROMO_DAILY_LIMIT',
'❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.',
+36 -6
View File
@@ -37,8 +37,38 @@ async def _handle_wheel_spin_payment(
)
return False
# Проверяем наличие активной подписки
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription or not subscription.is_active:
# Конвертируем Stars в баланс как компенсацию
rubles_fallback = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
kopeks_fallback = int((rubles_fallback * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
from app.database.crud.user import add_user_balance
from app.database.models import TransactionType
await add_user_balance(
db,
user,
kopeks_fallback,
f'Возврат за спин колеса без подписки ({stars_amount} Stars)',
transaction_type=TransactionType.REFUND,
)
await db.commit()
await message.answer(
'❌ Для использования колеса удачи необходима активная подписка.\n'
f'💰 {stars_amount} Stars возвращены на баланс в виде {kopeks_fallback / 100:.0f} ₽.',
)
logger.warning(
'Wheel spin without subscription, refunded to balance',
user_id=user.id,
stars_amount=stars_amount,
refund_kopeks=kopeks_fallback,
)
return False
# Выполняем спин напрямую (оплата уже прошла через Stars)
prizes = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=True)
if not prizes:
@@ -64,7 +94,11 @@ async def _handle_wheel_spin_payment(
promocode_id = None
if generated_promocode:
result = await db.execute(f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'")
from sqlalchemy import text
result = await db.execute(
text('SELECT id FROM promocodes WHERE code = :code'), {'code': generated_promocode}
)
row = result.fetchone()
if row:
promocode_id = row[0]
@@ -419,10 +453,6 @@ async def handle_successful_payment(message: types.Message, db: AsyncSession, st
'⭐ Потрачено звезд: {stars_spent}\n'
'💰 Зачислено на баланс: {amount}\n'
'🆔 ID транзакции: {transaction_id}...\n\n'
'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
'Обязательно активируйте подписку отдельно!\n\n'
'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
'подписка будет приобретена автоматически после пополнения баланса.\n\n'
'Спасибо за пополнение! 🚀',
).format(
stars_spent=payment.total_amount,
+36 -20
View File
@@ -2,7 +2,6 @@ from datetime import UTC, datetime
import structlog
from aiogram import Bot, Dispatcher, F, types
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
@@ -37,6 +36,7 @@ from app.middlewares.channel_checker import (
)
from app.services.admin_notification_service import AdminNotificationService
from app.services.campaign_service import AdvertisingCampaignService
from app.services.channel_subscription_service import channel_subscription_service
from app.services.main_menu_button_service import MainMenuButtonService
from app.services.pinned_message_service import (
deliver_pinned_message_to_user,
@@ -394,9 +394,17 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
if campaign.partner_user_id:
await state.update_data(referrer_id=campaign.partner_user_id)
logger.info(
'👤 Кампания привязана к партнёру',
'👤 Кампания привязана к партнёру, реферер будет установлен',
campaign_id=campaign.id,
campaign_name=campaign.name,
partner_user_id=campaign.partner_user_id,
)
else:
logger.debug(
'Кампания без партнёра, реферер не устанавливается',
campaign_id=campaign.id,
campaign_name=campaign.name,
)
else:
referral_code = start_parameter
logger.info('🔎 Найден реферальный код', referral_code=referral_code)
@@ -1174,11 +1182,14 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
if existing_user and existing_user.status == UserStatus.DELETED.value:
logger.info('🔄 Восстанавливаем удаленного пользователя', from_user_id=callback.from_user.id)
# Prevent self-referral when partner re-registers via own campaign link
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
existing_user.username = callback.from_user.username
existing_user.first_name = callback.from_user.first_name
existing_user.last_name = callback.from_user.last_name
existing_user.language = language
existing_user.referred_by_id = referrer_id
existing_user.referred_by_id = safe_referrer_id
existing_user.status = UserStatus.ACTIVE.value
existing_user.balance_kopeks = 0
existing_user.has_had_paid_subscription = False
@@ -1212,7 +1223,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
logger.info('🔄 Обновляем существующего пользователя', from_user_id=callback.from_user.id)
existing_user.status = UserStatus.ACTIVE.value
existing_user.language = language
if referrer_id and not existing_user.referred_by_id:
if referrer_id and referrer_id != existing_user.id and not existing_user.referred_by_id:
existing_user.referred_by_id = referrer_id
existing_user.updated_at = datetime.now(UTC)
@@ -1222,7 +1233,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
await db.refresh(existing_user, ['subscription'])
user = existing_user
if referrer_id:
if referrer_id and referrer_id != user.id:
try:
await process_referral_registration(db, user.id, referrer_id, callback.bot)
logger.info('✅ Реферальная регистрация обработана для', user_id=user.id)
@@ -1436,11 +1447,14 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
if existing_user and existing_user.status == UserStatus.DELETED.value:
logger.info('🔄 Восстанавливаем удаленного пользователя', from_user_id=message.from_user.id)
# Prevent self-referral when partner re-registers via own campaign link
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
existing_user.username = message.from_user.username
existing_user.first_name = message.from_user.first_name
existing_user.last_name = message.from_user.last_name
existing_user.language = language
existing_user.referred_by_id = referrer_id
existing_user.referred_by_id = safe_referrer_id
existing_user.status = UserStatus.ACTIVE.value
existing_user.balance_kopeks = 0
existing_user.has_had_paid_subscription = False
@@ -1474,7 +1488,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
logger.info('🔄 Обновляем существующего пользователя', from_user_id=message.from_user.id)
existing_user.status = UserStatus.ACTIVE.value
existing_user.language = language
if referrer_id and not existing_user.referred_by_id:
if referrer_id and referrer_id != existing_user.id and not existing_user.referred_by_id:
existing_user.referred_by_id = referrer_id
existing_user.updated_at = datetime.now(UTC)
@@ -1484,7 +1498,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
await db.refresh(existing_user, ['subscription'])
user = existing_user
if referrer_id:
if referrer_id and referrer_id != user.id:
try:
await process_referral_registration(db, user.id, referrer_id, message.bot)
logger.info('✅ Реферальная регистрация обработана для', user_id=user.id)
@@ -1860,20 +1874,22 @@ async def required_sub_channel_check(
texts = get_texts(language)
chat_member = await bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=query.from_user.id)
# Ensure bot is set on service
if not channel_subscription_service.bot:
channel_subscription_service.bot = bot
if chat_member.status not in [
ChatMemberStatus.MEMBER,
ChatMemberStatus.ADMINISTRATOR,
ChatMemberStatus.CREATOR,
]:
# Invalidate cache for fresh check (user just clicked "I subscribed")
await channel_subscription_service.invalidate_user_cache(query.from_user.id)
is_subscribed = await channel_subscription_service.is_user_subscribed_to_all(query.from_user.id)
if not is_subscribed:
# НЕ удаляем payload - пользователь может попробовать снова после подписки
logger.info(
"📦 CHANNEL CHECK: Подписка не подтверждена, payload '' сохранён для следующей попытки",
'CHANNEL CHECK: Подписка не подтверждена, payload сохранён для следующей попытки',
pending_start_payload=pending_start_payload,
)
return await query.answer(
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', '❌ Вы не подписались на канал!'),
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', 'Please subscribe to all required channels first!'),
show_alert=True,
)
@@ -1989,7 +2005,7 @@ async def required_sub_channel_check(
custom_buttons=custom_buttons,
)
if settings.ENABLE_LOGO_MODE:
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
@@ -2046,7 +2062,7 @@ async def required_sub_channel_check(
logger.info('✅ CHANNEL CHECK: pending_start_payload удален из state после создания пользователя')
# Обрабатываем реферальную регистрацию
if referrer_id:
if referrer_id and referrer_id != user.id:
try:
await process_referral_registration(db, user.id, referrer_id, bot)
logger.info('✅ CHANNEL CHECK: Реферальная регистрация обработана для', user_id=user.id)
@@ -2082,7 +2098,7 @@ async def required_sub_channel_check(
custom_buttons=custom_buttons,
)
if settings.ENABLE_LOGO_MODE:
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
@@ -2112,7 +2128,7 @@ async def required_sub_channel_check(
else:
rules_text = await get_rules(language)
if settings.ENABLE_LOGO_MODE:
if settings.ENABLE_LOGO_MODE and len(rules_text) <= 900:
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
+14 -8
View File
@@ -11,16 +11,19 @@ from .autopay import (
from .common import (
build_redirect_link,
create_deep_link,
format_additional_section,
format_traffic_display,
get_apps_for_device,
get_apps_for_platform_async,
get_confirm_switch_traffic_keyboard,
get_device_name,
get_localized_value,
get_platforms_list,
get_reset_devices_confirm_keyboard,
get_step_description,
get_traffic_switch_keyboard,
load_app_config,
invalidate_app_config_cache,
load_app_config_async,
normalize_app,
render_guide_blocks,
resolve_button_url,
update_traffic_prices,
validate_traffic_price,
)
@@ -132,18 +135,17 @@ __all__ = [
'devices_continue',
'execute_change_devices',
'execute_switch_traffic',
'format_additional_section',
'format_traffic_display',
'get_apps_for_device',
'get_apps_for_platform_async',
'get_confirm_switch_traffic_keyboard',
'get_countries_price_by_uuids_fallback',
'get_current_devices_count',
'get_current_devices_detailed',
'get_device_name',
'get_localized_value',
'get_platforms_list',
'get_reset_devices_confirm_keyboard',
'get_servers_display_names',
'get_step_description',
'get_subscription_cost',
'get_subscription_info_text',
'get_traffic_packages_info',
@@ -176,9 +178,13 @@ __all__ = [
'handle_subscription_config_back',
'handle_subscription_settings',
'handle_switch_traffic',
'load_app_config',
'invalidate_app_config_cache',
'load_app_config_async',
'normalize_app',
'refresh_traffic_config',
'register_handlers',
'render_guide_blocks',
'resolve_button_url',
'resume_subscription_checkout',
'return_to_saved_cart',
'save_cart_and_redirect_to_topup',
+243 -78
View File
@@ -1,5 +1,8 @@
import asyncio
import base64
import json
import html as html_mod
import re
import time
from datetime import datetime
from typing import Any
from urllib.parse import quote
@@ -23,23 +26,34 @@ logger = structlog.get_logger(__name__)
TRAFFIC_PRICES = get_traffic_prices()
# ── App config cache ──
_app_config_cache: dict[str, Any] = {}
_app_config_cache_ts: float = 0.0
_app_config_lock = asyncio.Lock()
class _SafeFormatDict(dict):
def __missing__(self, key: str) -> str: # pragma: no cover - defensive fallback
return '{' + key + '}'
_PLACEHOLDER_RE = re.compile(r'\{(\w+)\}')
def _format_text_with_placeholders(template: str, values: dict[str, Any]) -> str:
"""Safe placeholder substitution — only replaces simple {key} patterns.
Unlike str.format_map, this does NOT allow attribute access ({key.attr})
or indexing ({key[0]}), preventing format string injection attacks.
"""
if not isinstance(template, str):
return template
safe_values = _SafeFormatDict()
safe_values.update(values)
def _replace(match: re.Match) -> str:
key = match.group(1)
if key in values:
return str(values[key])
return match.group(0)
try:
return template.format_map(safe_values)
return _PLACEHOLDER_RE.sub(_replace, template)
except Exception: # pragma: no cover - defensive logging
logger.warning("Failed to format template '' with values", template=template, values=values)
logger.warning('Failed to format template with values', template=template, values=values)
return template
@@ -152,23 +166,6 @@ def validate_traffic_price(gb: int) -> bool:
return price > 0
def load_app_config() -> dict[str, Any]:
try:
from app.config import settings
config_path = settings.get_app_config_path()
with open(config_path, encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return data
logger.error('Некорректный формат app-config.json: ожидается объект')
except Exception as e:
logger.error('Ошибка загрузки конфига приложений', error=e)
return {}
def get_localized_value(values: Any, language: str, default_language: str = 'en') -> str:
if not isinstance(values, dict):
return ''
@@ -199,39 +196,29 @@ def get_localized_value(values: Any, language: str, default_language: str = 'en'
return ''
def get_step_description(app: dict[str, Any], step_key: str, language: str) -> str:
if not isinstance(app, dict):
return ''
step = app.get(step_key)
if not isinstance(step, dict):
return ''
description = step.get('description')
return get_localized_value(description, language)
def format_additional_section(additional: Any, texts, language: str) -> str:
if not isinstance(additional, dict):
return ''
title = get_localized_value(additional.get('title'), language)
description = get_localized_value(additional.get('description'), language)
def render_guide_blocks(blocks: list[dict], language: str) -> str:
"""Render block-format guide steps to HTML text."""
parts: list[str] = []
if title:
parts.append(
texts.t(
'SUBSCRIPTION_ADDITIONAL_STEP_TITLE',
'<b>{title}:</b>',
).format(title=title)
step_num = 1
for block in blocks:
if not isinstance(block, dict):
continue
title = block.get('title', {})
desc = block.get('description', {})
title_text = html_mod.escape(
get_localized_value(title, language) if isinstance(title, dict) else str(title or '')
)
if description:
parts.append(description)
return '\n'.join(parts)
desc_text = html_mod.escape(get_localized_value(desc, language) if isinstance(desc, dict) else str(desc or ''))
if title_text or desc_text:
step = f'<b>Шаг {step_num}'
if title_text:
step += f' - {title_text}'
step += ':</b>'
if desc_text:
step += f'\n{desc_text}'
parts.append(step)
step_num += 1
return '\n\n'.join(parts)
def build_redirect_link(target_link: str | None, template: str | None) -> str | None:
@@ -266,34 +253,13 @@ def build_redirect_link(target_link: str | None, template: str | None) -> str |
return result
def get_apps_for_device(device_type: str, language: str = 'ru') -> list[dict[str, Any]]:
config = load_app_config()
platforms = config.get('platforms', {}) if isinstance(config, dict) else {}
if not isinstance(platforms, dict):
return []
device_mapping = {
'ios': 'ios',
'android': 'android',
'windows': 'windows',
'mac': 'macos',
'tv': 'androidTV',
'appletv': 'appleTV',
'apple_tv': 'appleTV',
}
config_key = device_mapping.get(device_type, device_type)
apps = platforms.get(config_key, [])
return apps if isinstance(apps, list) else []
def get_device_name(device_type: str, language: str = 'ru') -> str:
names = {
'ios': 'iPhone/iPad',
'android': 'Android',
'windows': 'Windows',
'mac': 'macOS',
'linux': 'Linux',
'tv': 'Android TV',
'appletv': 'Apple TV',
'apple_tv': 'Apple TV',
@@ -302,6 +268,205 @@ def get_device_name(device_type: str, language: str = 'ru') -> str:
return names.get(device_type, device_type)
# ── Remnawave async config loader ──
_PLATFORM_DISPLAY = {
'ios': {'name': 'iPhone/iPad', 'emoji': '📱'},
'android': {'name': 'Android', 'emoji': '🤖'},
'windows': {'name': 'Windows', 'emoji': '💻'},
'macos': {'name': 'macOS', 'emoji': '🎯'},
'linux': {'name': 'Linux', 'emoji': '🐧'},
'androidTV': {'name': 'Android TV', 'emoji': '📺'},
'appleTV': {'name': 'Apple TV', 'emoji': '📺'},
}
# Map callback device_type keys to Remnawave platform keys
_DEVICE_TO_PLATFORM = {
'ios': 'ios',
'android': 'android',
'windows': 'windows',
'mac': 'macos',
'linux': 'linux',
'tv': 'androidTV',
'appletv': 'appleTV',
'apple_tv': 'appleTV',
}
# Reverse: Remnawave platform key → callback device_type
_PLATFORM_TO_DEVICE = {
'ios': 'ios',
'android': 'android',
'windows': 'windows',
'macos': 'mac',
'linux': 'linux',
'androidTV': 'tv',
'appleTV': 'appletv',
}
def _get_remnawave_config_uuid() -> str | None:
try:
from app.services.system_settings_service import bot_configuration_service
return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
except Exception as e:
logger.debug('Could not read CABINET_REMNA_SUB_CONFIG from service, using settings fallback', error=e)
return getattr(settings, 'CABINET_REMNA_SUB_CONFIG', None)
async def load_app_config_async() -> dict[str, Any] | None:
"""Load app config from Remnawave API (if configured), with TTL cache.
Returns None when no Remnawave config is set or API fails.
"""
global _app_config_cache, _app_config_cache_ts
ttl = settings.APP_CONFIG_CACHE_TTL
if _app_config_cache and (time.monotonic() - _app_config_cache_ts) < ttl:
return _app_config_cache
async with _app_config_lock:
# Double-check after acquiring lock
if _app_config_cache and (time.monotonic() - _app_config_cache_ts) < ttl:
return _app_config_cache
remnawave_uuid = _get_remnawave_config_uuid()
if remnawave_uuid:
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
async with service.get_api_client() as api:
config = await api.get_subscription_page_config(remnawave_uuid)
if config and config.config:
raw = dict(config.config)
raw['_isRemnawave'] = True
_app_config_cache = raw
_app_config_cache_ts = time.monotonic()
logger.debug('Loaded app config from Remnawave', remnawave_uuid=remnawave_uuid)
return raw
except Exception as e:
logger.warning('Failed to load Remnawave config', error=e)
return None
def invalidate_app_config_cache() -> None:
"""Clear the cached app config so next call re-fetches from Remnawave.
Note: This is intentionally sync (called from sync contexts in cabinet API).
Setting timestamp to 0 first ensures the fast-path check in load_app_config_async
fails immediately, even without acquiring _app_config_lock.
"""
global _app_config_cache, _app_config_cache_ts
_app_config_cache_ts = 0.0
_app_config_cache = {}
async def get_apps_for_platform_async(device_type: str, language: str = 'ru') -> list[dict[str, Any]]:
"""Get apps for a device type from Remnawave config."""
config = await load_app_config_async()
if not config:
return []
platforms = config.get('platforms', {})
if not isinstance(platforms, dict):
return []
platform_key = _DEVICE_TO_PLATFORM.get(device_type, device_type)
platform_data = platforms.get(platform_key)
if isinstance(platform_data, dict):
apps = platform_data.get('apps', [])
return [normalize_app(app) for app in apps if isinstance(app, dict)]
return []
def normalize_app(app: dict[str, Any]) -> dict[str, Any]:
"""Normalize Remnawave app dict to a unified format with blocks."""
return {
'id': app.get('id', app.get('name', 'unknown')),
'name': app.get('name', ''),
'isFeatured': app.get('featured', app.get('isFeatured', False)),
'urlScheme': app.get('urlScheme', ''),
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
'blocks': app.get('blocks', []),
'_raw': app,
}
def get_platforms_list(config: dict[str, Any]) -> list[dict[str, Any]]:
"""Extract available platforms from config for keyboard generation.
Returns list of {key, displayName, icon_emoji, device_type} sorted by typical order.
"""
platforms = config.get('platforms', {})
if not isinstance(platforms, dict):
return []
# Desired order
order = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
result = []
for pk in order:
if pk not in platforms:
continue
pd = platforms[pk]
if not isinstance(pd, dict) or not pd.get('apps'):
continue
display = _PLATFORM_DISPLAY.get(pk, {'name': pk, 'emoji': '📱'})
# Get displayName from Remnawave or fallback
display_name_data = pd.get('displayName', display['name'])
result.append(
{
'key': pk,
'displayName': display_name_data,
'icon_emoji': display['emoji'],
'device_type': _PLATFORM_TO_DEVICE.get(pk, pk),
}
)
# Also include any platforms in config not in our order list
for pk, pd in platforms.items():
if pk in order:
continue
if not isinstance(pd, dict) or not pd.get('apps'):
continue
display = _PLATFORM_DISPLAY.get(pk, {'name': pk, 'emoji': '📱'})
result.append(
{
'key': pk,
'displayName': display.get('name', pk),
'icon_emoji': display.get('emoji', '📱'),
'device_type': _PLATFORM_TO_DEVICE.get(pk, pk),
}
)
return result
def resolve_button_url(
url: str,
subscription_url: str | None,
crypto_link: str | None = None,
) -> str:
"""Resolve template variables in button URLs (port of cabinet's _resolve_button_url)."""
if not url:
return url
result = url
if subscription_url:
result = result.replace('{{SUBSCRIPTION_LINK}}', subscription_url)
if crypto_link:
result = result.replace('{{HAPP_CRYPT3_LINK}}', crypto_link)
result = result.replace('{{HAPP_CRYPT4_LINK}}', crypto_link)
return result
def create_deep_link(app: dict[str, Any], subscription_url: str) -> str | None:
if not subscription_url:
return None
+37 -81
View File
@@ -1,3 +1,4 @@
import html as html_mod
from datetime import UTC, datetime
from aiogram import types
@@ -36,11 +37,10 @@ from app.utils.subscription_utils import (
from .common import (
_get_addon_discount_percent_for_user,
_get_period_hint_from_subscription,
format_additional_section,
get_apps_for_device,
get_apps_for_platform_async,
get_device_name,
get_step_description,
logger,
render_guide_blocks,
)
from .countries import _get_available_countries
@@ -805,6 +805,7 @@ async def handle_devices_page(callback: types.CallbackQuery, db_user: User, db:
async def handle_single_device_reset(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
try:
callback_parts = callback.data.split('_')
if len(callback_parts) < 4:
@@ -828,8 +829,6 @@ async def handle_single_device_reset(callback: types.CallbackQuery, db_user: Use
)
return
texts = get_texts(db_user.language)
try:
from app.services.remnawave_service import RemnaWaveService
@@ -1271,7 +1270,8 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
)
return
apps = get_apps_for_device(device_type, db_user.language)
apps = await get_apps_for_platform_async(device_type, db_user.language)
hide_subscription_link = settings.should_hide_subscription_link()
if not apps:
@@ -1286,7 +1286,7 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
other_apps = [app for app in apps if isinstance(app, dict) and app.get('id') and app.get('id') != featured_app_id]
other_app_names = ', '.join(
str(app.get('name')).strip()
html_mod.escape(str(app.get('name')).strip())
for app in other_apps
if isinstance(app.get('name'), str) and app.get('name').strip()
)
@@ -1304,34 +1304,20 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
else:
link_section = (
texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 <b>Ссылка подписки:</b>')
+ f'\n<code>{subscription_link}</code>\n\n'
+ f'\n<code>{html_mod.escape(subscription_link)}</code>\n\n'
)
installation_description = get_step_description(featured_app, 'installationStep', db_user.language)
add_description = get_step_description(featured_app, 'addSubscriptionStep', db_user.language)
connect_description = get_step_description(featured_app, 'connectAndUseStep', db_user.language)
additional_before_text = format_additional_section(
featured_app.get('additionalBeforeAddSubscriptionStep'),
texts,
db_user.language,
)
additional_after_text = format_additional_section(
featured_app.get('additionalAfterAddSubscriptionStep'),
texts,
db_user.language,
)
guide_text = (
texts.t(
'SUBSCRIPTION_DEVICE_GUIDE_TITLE',
'📱 <b>Настройка для {device_name}</b>',
).format(device_name=get_device_name(device_type, db_user.language))
).format(device_name=html_mod.escape(get_device_name(device_type, db_user.language)))
+ '\n\n'
+ link_section
+ texts.t(
'SUBSCRIPTION_DEVICE_FEATURED_APP',
'📋 <b>Рекомендуемое приложение:</b> {app_name}',
).format(app_name=featured_app.get('name', ''))
).format(app_name=html_mod.escape(featured_app.get('name', '')))
)
if other_app_names:
@@ -1344,20 +1330,9 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
'Нажмите кнопку "Другие приложения" ниже, чтобы выбрать приложение.',
)
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE', '<b>Шаг 1 - Установка:</b>')
if installation_description:
guide_text += f'\n{installation_description}'
if additional_before_text:
guide_text += f'\n\n{additional_before_text}'
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_ADD_TITLE', '<b>Шаг 2 - Добавление подписки:</b>')
if add_description:
guide_text += f'\n{add_description}'
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE', '<b>Шаг 3 - Подключение:</b>')
if connect_description:
guide_text += f'\n{connect_description}'
blocks_text = render_guide_blocks(featured_app.get('blocks', []), db_user.language)
if blocks_text:
guide_text += '\n\n' + blocks_text
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_HOW_TO_TITLE', '💡 <b>Как подключить:</b>')
guide_text += '\n' + '\n'.join(
@@ -1381,9 +1356,6 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
]
)
if additional_after_text:
guide_text += f'\n\n{additional_after_text}'
await callback.message.edit_text(
guide_text,
reply_markup=get_connection_guide_keyboard(
@@ -1402,7 +1374,7 @@ async def handle_app_selection(callback: types.CallbackQuery, db_user: User, db:
device_type = callback.data.split('_')[2]
texts = get_texts(db_user.language)
apps = get_apps_for_device(device_type, db_user.language)
apps = await get_apps_for_platform_async(device_type, db_user.language)
if not apps:
await callback.answer(
@@ -1415,7 +1387,7 @@ async def handle_app_selection(callback: types.CallbackQuery, db_user: User, db:
texts.t(
'SUBSCRIPTION_APPS_TITLE',
'📱 <b>Приложения для {device_name}</b>',
).format(device_name=get_device_name(device_type, db_user.language))
).format(device_name=html_mod.escape(get_device_name(device_type, db_user.language)))
+ '\n\n'
+ texts.t('SUBSCRIPTION_APPS_PROMPT', 'Выберите приложение для подключения:')
)
@@ -1427,7 +1399,11 @@ async def handle_app_selection(callback: types.CallbackQuery, db_user: User, db:
async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
_, device_type, app_id = callback.data.split('_')
parts = callback.data.split('_', 2)
if len(parts) < 3:
await callback.answer('Invalid callback data', show_alert=True)
return
_, device_type, app_id = parts
texts = get_texts(db_user.language)
subscription = db_user.subscription
@@ -1440,8 +1416,8 @@ async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User
)
return
apps = get_apps_for_device(device_type, db_user.language)
app = next((a for a in apps if a['id'] == app_id), None)
apps = await get_apps_for_platform_async(device_type, db_user.language)
app = next((a for a in apps if a.get('id') == app_id), None) if apps else None
if not app:
await callback.answer(
@@ -1465,53 +1441,33 @@ async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User
else:
link_section = (
texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 <b>Ссылка подписки:</b>')
+ f'\n<code>{subscription_link}</code>\n\n'
+ f'\n<code>{html_mod.escape(subscription_link)}</code>\n\n'
)
installation_description = get_step_description(app, 'installationStep', db_user.language)
add_description = get_step_description(app, 'addSubscriptionStep', db_user.language)
connect_description = get_step_description(app, 'connectAndUseStep', db_user.language)
additional_before_text = format_additional_section(
app.get('additionalBeforeAddSubscriptionStep'),
texts,
db_user.language,
)
additional_after_text = format_additional_section(
app.get('additionalAfterAddSubscriptionStep'),
texts,
db_user.language,
)
guide_text = (
texts.t(
'SUBSCRIPTION_SPECIFIC_APP_TITLE',
'📱 <b>{app_name} - {device_name}</b>',
).format(app_name=app.get('name', ''), device_name=get_device_name(device_type, db_user.language))
).format(
app_name=html_mod.escape(app.get('name', '')),
device_name=html_mod.escape(get_device_name(device_type, db_user.language)),
)
+ '\n\n'
+ link_section
)
guide_text += texts.t('SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE', '<b>Шаг 1 - Установка:</b>')
if installation_description:
guide_text += f'\n{installation_description}'
if additional_before_text:
guide_text += f'\n\n{additional_before_text}'
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_ADD_TITLE', '<b>Шаг 2 - Добавление подписки:</b>')
if add_description:
guide_text += f'\n{add_description}'
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE', '<b>Шаг 3 - Подключение:</b>')
if connect_description:
guide_text += f'\n{connect_description}'
if additional_after_text:
guide_text += f'\n\n{additional_after_text}'
blocks_text = render_guide_blocks(app.get('blocks', []), db_user.language)
if blocks_text:
guide_text += blocks_text + '\n\n'
await callback.message.edit_text(
guide_text,
reply_markup=get_specific_app_keyboard(subscription_link, app, device_type, db_user.language),
reply_markup=get_specific_app_keyboard(
subscription_link,
app,
device_type,
db_user.language,
),
parse_mode='HTML',
)
await callback.answer()
@@ -1543,7 +1499,7 @@ async def show_device_connection_help(callback: types.CallbackQuery, db_user: Us
Нажмите "Подключить"
<b>🔗 Ваша ссылка подписки:</b>
<code>{subscription_link}</code>
<code>{html_mod.escape(subscription_link)}</code>
💡 <b>Совет:</b> Сохраните эту ссылку - она понадобится для подключения новых устройств
"""
+32 -1
View File
@@ -16,6 +16,8 @@ from app.utils.subscription_utils import (
get_happ_cryptolink_redirect_link,
)
from .common import get_platforms_list, load_app_config_async, logger
async def handle_connect_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
# Проверяем, доступно ли сообщение для редактирования
@@ -144,6 +146,33 @@ async def handle_connect_subscription(callback: types.CallbackQuery, db_user: Us
parse_mode='HTML',
)
else:
# Guide mode: load config and build dynamic platform keyboard
platforms = None
try:
config = await load_app_config_async()
if config:
platforms = get_platforms_list(config) or None
except Exception as e:
logger.warning('Failed to load platforms for guide mode', error=e)
if not platforms:
await callback.message.edit_text(
texts.t(
'GUIDE_CONFIG_NOT_SET',
'⚠️ <b>Конфигурация не настроена</b>\n\n'
'Администратор ещё не настроил конфигурацию приложений.\n'
'Обратитесь к администратору.',
),
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')],
]
),
parse_mode='HTML',
)
await callback.answer()
return
if hide_subscription_link:
device_text = texts.t(
'SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN',
@@ -165,7 +194,9 @@ async def handle_connect_subscription(callback: types.CallbackQuery, db_user: Us
).format(subscription_url=subscription_link)
await callback.message.edit_text(
device_text, reply_markup=get_device_selection_keyboard(db_user.language), parse_mode='HTML'
device_text,
reply_markup=get_device_selection_keyboard(db_user.language, platforms=platforms),
parse_mode='HTML',
)
await callback.answer()
+1 -1
View File
@@ -4119,7 +4119,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(handle_app_selection, F.data.startswith('app_list_'))
dp.callback_query.register(handle_specific_app_guide, F.data.startswith('app_'))
dp.callback_query.register(handle_specific_app_guide, F.data.startswith('app_') & ~F.data.startswith('app_list_'))
dp.callback_query.register(handle_open_subscription_link, F.data == 'open_subscription_link')
+31 -15
View File
@@ -65,16 +65,25 @@ def _apply_promo_discount(price: int, discount_percent: int) -> int:
def _get_user_period_discount(db_user: User, period_days: int) -> int:
"""Получает скидку пользователя на период из промогруппы."""
promo_group = getattr(db_user, 'promo_group', None)
if promo_group:
discount = promo_group.get_discount_percent('period', period_days)
if discount > 0:
return discount
"""Получает скидку пользователя на период из промогруппы + промо-оффер (стекинг).
Возвращает итоговый процент скидки после последовательного применения
скидки промогруппы и персональной скидки промо-оффера.
"""
promo_group = db_user.get_primary_promo_group()
group_discount = promo_group.get_discount_percent('period', period_days) if promo_group else 0
personal_discount = get_user_active_promo_discount_percent(db_user)
return personal_discount
if group_discount <= 0 and personal_discount <= 0:
return 0
# Стекинг: применяем последовательно (как в кабинете)
# price * (1 - group/100) * (1 - personal/100)
# Вычисляем эффективный общий процент
remaining = (100 - group_discount) * (100 - personal_discount)
effective_discount = 100 - remaining // 100
return effective_discount
def format_tariffs_list_text(
@@ -2265,7 +2274,7 @@ async def confirm_tariff_switch(
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='переключение тарифа',
)
except Exception as e:
@@ -2434,16 +2443,19 @@ async def confirm_daily_tariff_switch(
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
subscription.traffic_used_gb = 0.0
await db.commit()
await db.refresh(subscription)
# Обновляем пользователя в Remnawave (create_remnawave_user также сбрасывает устройства)
# Обновляем пользователя в Remnawave (сброс трафика по админ-настройке)
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='смена на суточный тариф',
)
except Exception as e:
@@ -2702,11 +2714,12 @@ async def show_instant_switch_list(
return
# Рассчитываем оставшиеся дни
now = datetime.now(UTC)
remaining_days = 0
if subscription.end_date:
remaining_days = max(0, (subscription.end_date - datetime.now(UTC)).days)
remaining_days = max(0, (subscription.end_date - now).days)
if remaining_days == 0:
if not subscription.end_date or subscription.end_date <= now:
await callback.message.edit_text(
'❌ <b>Переключение недоступно</b>\n\n'
'У вашей подписки не осталось активных дней.\n'
@@ -2987,6 +3000,9 @@ async def confirm_instant_switch(
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
subscription.traffic_used_gb = 0.0
if is_new_daily:
# Для суточного тарифа - сбрасываем на 1 день и настраиваем суточные параметры
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
@@ -3013,13 +3029,13 @@ async def confirm_instant_switch(
await db.commit()
await db.refresh(subscription)
# Обновляем пользователя в Remnawave
# Обновляем пользователя в Remnawave (сброс трафика по админ-настройке)
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False, # Не сбрасываем трафик при переключении
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='мгновенное переключение тарифа',
)
except Exception as e:
+35 -15
View File
@@ -890,7 +890,9 @@ async def handle_ticket_reply(message: types.Message, state: FSMContext, db_user
# Уведомить админов об ответе пользователя
logger.info('Attempting to notify admins about ticket reply #', ticket_id=ticket_id)
await notify_admins_about_ticket_reply(ticket, reply_text, db)
await notify_admins_about_ticket_reply(
ticket, reply_text, db, media_file_id=media_file_id, media_type=media_type
)
except Exception as e:
logger.error('Error adding ticket reply', error=e)
@@ -995,14 +997,11 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
)
return
# Получаем язык пользователя для локализации заголовков в уведомлении
# и формируем удобный текст уведомления для админов
get_texts(settings.DEFAULT_LANGUAGE)
title = (ticket.title or '').strip()
if len(title) > 60:
title = title[:57] + '...'
# Загрузим пользователя, чтобы отобразить реальный Telegram ID и username
try:
user = await get_user_by_id(db, ticket.user_id)
except Exception:
@@ -1011,6 +1010,18 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else ''
username_display = (user.username or 'отсутствует') if user else 'отсутствует'
# Загружаем первое сообщение для получения медиа и превью текста
first_message = await TicketMessageCRUD.get_first_message(db, ticket.id)
media_file_id = None
media_type = None
message_preview = ''
if first_message:
media_file_id = first_message.media_file_id if first_message.has_media else None
media_type = first_message.media_type if first_message.has_media else None
msg_text = (first_message.message_text or '').strip()
if msg_text:
message_preview = msg_text[:200] + '...' if len(msg_text) > 200 else msg_text
notification_text = (
f'🎫 <b>НОВЫЙ ТИКЕТ</b>\n\n'
f'🆔 <b>ID:</b> <code>{ticket.id}</code>\n'
@@ -1018,13 +1029,13 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
f'🆔 <b>ID:</b> <code>{telegram_id_display}</code>\n'
f'📱 <b>Username:</b> @{username_display}\n'
f'📝 <b>Заголовок:</b> {title or ""}\n'
f'📅 <b>Создан:</b> {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n'
)
# Клавиатура с быстрыми действиями для админов в топике
# Отправляем через общий сервис админ-уведомлений (поддерживает топики)
# bot доступен из Dispatcher в middlewares; безопаснее взять из уже используемого контекста
# Здесь используем lazy импорт из maintenance_service, где хранится бот
if message_preview:
notification_text += f'\n📩 <b>Сообщение:</b>\n{message_preview}\n'
notification_text += f'\n📅 <b>Создан:</b> {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n'
from app.services.maintenance_service import maintenance_service
bot = maintenance_service._bot or None
@@ -1033,12 +1044,21 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
return
service = AdminNotificationService(bot)
await service.send_ticket_event_notification(notification_text, None)
await service.send_ticket_event_notification(
notification_text, None, media_file_id=media_file_id, media_type=media_type
)
except Exception as e:
logger.error('Error notifying admins about new ticket', error=e)
async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db: AsyncSession):
async def notify_admins_about_ticket_reply(
ticket: Ticket,
reply_text: str,
db: AsyncSession,
*,
media_file_id: str | None = None,
media_type: str | None = None,
):
"""Уведомить админов об ответе пользователя на тикет"""
logger.info('notify_admins_about_ticket_reply called for ticket #', ticket_id=ticket.id)
try:
@@ -1052,7 +1072,6 @@ async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db:
if len(title) > 60:
title = title[:57] + '...'
# Загрузим пользователя
try:
user = await get_user_by_id(db, ticket.user_id)
except Exception:
@@ -1061,8 +1080,7 @@ async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db:
telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else ''
username_display = (user.username or 'отсутствует') if user else 'отсутствует'
# Обрезаем текст ответа для уведомления
reply_preview = reply_text[:150] + '...' if len(reply_text) > 150 else reply_text
reply_preview = reply_text[:200] + '...' if len(reply_text) > 200 else reply_text
notification_text = (
f'💬 <b>ОТВЕТ НА ТИКЕТ</b>\n\n'
@@ -1082,7 +1100,9 @@ async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db:
return
service = AdminNotificationService(bot)
result = await service.send_ticket_event_notification(notification_text, None)
result = await service.send_ticket_event_notification(
notification_text, None, media_file_id=media_file_id, media_type=media_type
)
logger.info('Ticket # reply notification sent', ticket_id=ticket.id, result=result)
except Exception as e:
logger.error('Error notifying admins about ticket reply', error=e)
+1 -4
View File
@@ -124,10 +124,7 @@ async def handle_successful_payment(message: types.Message):
await message.answer(
f'✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!\n\n'
'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
'Обязательно активируйте подписку отдельно!\n\n'
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
f'подписка будет приобретена автоматически после пополнения баланса.'
'Средства зачислены на ваш баланс!'
)
logger.info(
+12
View File
@@ -217,6 +217,18 @@ def get_admin_settings_submenu_keyboard(language: str = 'ru') -> InlineKeyboardM
callback_data='admin_faq',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_SETTINGS_REQUIRED_CHANNELS', '📢 Обязательные каналы'),
callback_data='reqch:list',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_SETTINGS_APP_CONFIG', '📱 Конфиг приложений'),
callback_data='admin_remna_config',
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data='admin_panel')],
]
)
+175 -227
View File
@@ -154,61 +154,6 @@ async def get_main_menu_keyboard_async(
)
def _get_localized_value(values, language: str, default_language: str = 'en') -> str:
if not isinstance(values, dict):
return ''
candidates = []
normalized_language = (language or '').strip().lower()
if normalized_language:
candidates.append(normalized_language)
if '-' in normalized_language:
candidates.append(normalized_language.split('-')[0])
default_language = (default_language or '').strip().lower()
if default_language and default_language not in candidates:
candidates.append(default_language)
for candidate in candidates:
if not candidate:
continue
value = values.get(candidate)
if isinstance(value, str) and value.strip():
return value
for value in values.values():
if isinstance(value, str) and value.strip():
return value
return ''
def _build_additional_buttons(additional_section, language: str) -> list[InlineKeyboardButton]:
if not isinstance(additional_section, dict):
return []
buttons = additional_section.get('buttons')
if not isinstance(buttons, list):
return []
localized_buttons: list[InlineKeyboardButton] = []
for button in buttons:
if not isinstance(button, dict):
continue
button_text = _get_localized_value(button.get('buttonText'), language)
button_link = button.get('buttonLink')
if not button_text or not button_link:
continue
localized_buttons.append(InlineKeyboardButton(text=button_text, url=button_link))
return localized_buttons
_LANGUAGE_DISPLAY_NAMES = {
'ru': '🇷🇺 Русский',
'ru-ru': '🇷🇺 Русский',
@@ -275,22 +220,47 @@ def get_privacy_policy_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeybo
def get_channel_sub_keyboard(
channel_link: str | None,
channels: list[dict] | str | None = None,
language: str = DEFAULT_LANGUAGE,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
"""Subscription keyboard for required channels.
Supports Bot API 9.4 colored buttons via ``style`` parameter:
- subscribed channels green (``style='success'``)
- unsubscribed channels blue (``style='primary'``)
Args:
channels: List of dicts with 'channel_link', 'title', and optional
'is_subscribed' keys, OR a string (legacy single channel_link).
language: Locale code for button text.
"""
texts = get_texts(language)
buttons: list[list[InlineKeyboardButton]] = []
if channel_link:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
url=channel_link,
)
]
)
if isinstance(channels, str):
# Legacy: single channel link string
if channels:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
url=channels,
style='primary',
)
]
)
elif isinstance(channels, list):
for ch in channels:
link = ch.get('channel_link')
title = ch.get('title')
is_subscribed = ch.get('is_subscribed', False)
if link:
if is_subscribed:
label = f'{title}' if title else ''
buttons.append([InlineKeyboardButton(text=label, url=link, style='success')])
else:
label = title or texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться')
buttons.append([InlineKeyboardButton(text=label, url=link, style='primary')])
buttons.append(
[
@@ -1143,12 +1113,12 @@ def get_subscription_keyboard(
keyboard.append(settings_row)
# Кнопка докупки трафика для платных подписок
# В режиме тарифов проверяем tariff_id, в классическом - глобальные настройки
# В режиме тарифов проверяем can_topup_traffic() у тарифа, в классическом - глобальные настройки
show_traffic_topup = False
if subscription and (subscription.traffic_limit_gb or 0) > 0:
if (settings.is_tariffs_mode() and getattr(subscription, 'tariff_id', None)) or (
settings.is_traffic_topup_enabled() and not settings.is_traffic_topup_blocked()
):
if settings.is_tariffs_mode() and tariff:
show_traffic_topup = tariff.can_topup_traffic()
elif settings.is_traffic_topup_enabled() and not settings.is_traffic_topup_blocked():
show_traffic_topup = True
if show_traffic_topup:
@@ -1599,7 +1569,35 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_freekassa_enabled():
if settings.is_freekassa_sbp_enabled():
sbp_name = settings.get_freekassa_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_FREEKASSA_SBP', f'📱 {sbp_name}'),
callback_data=_build_callback('freekassa_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_freekassa_card_enabled():
card_name = settings.get_freekassa_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_FREEKASSA_CARD', f'💳 {card_name}'),
callback_data=_build_callback('freekassa_card'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_freekassa_enabled()
and not settings.is_freekassa_sbp_enabled()
and not settings.is_freekassa_card_enabled()
):
freekassa_name = settings.get_freekassa_display_name()
keyboard.append(
[
@@ -2270,35 +2268,35 @@ def get_manage_countries_keyboard(
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
def get_device_selection_keyboard(
language: str = DEFAULT_LANGUAGE,
platforms: list[dict] | None = None,
) -> InlineKeyboardMarkup:
from app.config import settings
from app.handlers.subscription.common import get_localized_value
texts = get_texts(language)
keyboard = [
[
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_IOS', '📱 iOS (iPhone/iPad)'), callback_data='device_guide_ios'
),
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_ANDROID', '🤖 Android'), callback_data='device_guide_android'
),
],
[
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_WINDOWS', '💻 Windows'), callback_data='device_guide_windows'
),
InlineKeyboardButton(text=texts.t('DEVICE_GUIDE_MAC', '🎯 macOS'), callback_data='device_guide_mac'),
],
[
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_ANDROID_TV', '📺 Android TV'), callback_data='device_guide_tv'
),
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_APPLE_TV', '📺 Apple TV'), callback_data='device_guide_appletv'
),
],
]
keyboard: list[list[InlineKeyboardButton]] = []
if platforms:
row: list[InlineKeyboardButton] = []
for p in platforms:
display_name = p.get('displayName', p['key'])
if isinstance(display_name, dict):
display_name = get_localized_value(display_name, language)
emoji = p.get('icon_emoji', '📱')
device_type = p.get('device_type', p['key'])
btn = InlineKeyboardButton(
text=f'{emoji} {display_name}',
callback_data=f'device_guide_{device_type}',
)
row.append(btn)
if len(row) == 2:
keyboard.append(row)
row = []
if row:
keyboard.append(row)
if settings.CONNECT_BUTTON_MODE == 'guide':
keyboard.append(
@@ -2322,64 +2320,84 @@ def get_connection_guide_keyboard(
language: str = DEFAULT_LANGUAGE,
has_other_apps: bool = False,
) -> InlineKeyboardMarkup:
from app.handlers.subscription import create_deep_link
from app.handlers.subscription.common import create_deep_link, get_localized_value, resolve_button_url
texts = get_texts(language)
keyboard = []
keyboard: list[list[InlineKeyboardButton]] = []
if 'installationStep' in app and 'buttons' in app['installationStep']:
app_buttons = []
for button in app['installationStep']['buttons']:
button_text = _get_localized_value(button.get('buttonText'), language)
button_link = button.get('buttonLink')
if not button_text or not button_link:
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', '')
btn_text = btn.get('text', {})
if isinstance(btn_text, dict):
btn_text = get_localized_value(btn_text, language)
if not btn_text:
continue
app_buttons.append(InlineKeyboardButton(text=f'📥 {button_text}', url=button_link))
if len(app_buttons) == 2:
keyboard.append(app_buttons)
app_buttons = []
btn_url = btn.get('url', '') or btn.get('link', '')
resolved_url = btn.get('resolvedUrl', '')
if app_buttons:
keyboard.append(app_buttons)
additional_before_buttons = _build_additional_buttons(
app.get('additionalBeforeAddSubscriptionStep'),
language,
)
for button in additional_before_buttons:
keyboard.append([button])
connect_link = create_deep_link(app, subscription_url)
if connect_link:
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
url=connect_link,
)
elif settings.is_happ_cryptolink_mode():
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='open_subscription_link',
)
else:
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
url=subscription_url,
)
keyboard.append([connect_button])
additional_after_buttons = _build_additional_buttons(
app.get('additionalAfterAddSubscriptionStep'),
language,
)
for button in additional_after_buttons:
keyboard.append([button])
if btn_type == 'externalLink':
if btn_url:
keyboard.append(
[
InlineKeyboardButton(
text=f'📥 {btn_text}',
url=btn_url,
style='primary',
)
]
)
elif btn_type == 'subscriptionLink':
url = resolved_url or resolve_button_url(btn_url, subscription_url)
deep_link = create_deep_link(app.get('_raw', app), subscription_url)
final_url = deep_link or url or subscription_url
if final_url:
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
url=final_url,
style='success',
)
]
)
elif settings.is_happ_cryptolink_mode():
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='open_subscription_link',
style='success',
)
]
)
else:
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
url=subscription_url,
style='success',
)
]
)
elif btn_type == 'copyButton':
url = resolved_url or resolve_button_url(btn_url, subscription_url)
if url:
keyboard.append(
[
InlineKeyboardButton(
text=f'📋 {btn_text}',
url=url,
)
]
)
if has_other_apps:
keyboard.append(
@@ -2441,90 +2459,20 @@ def get_app_selection_keyboard(device_type: str, apps: list, language: str = DEF
def get_specific_app_keyboard(
subscription_url: str, app: dict, device_type: str, language: str = DEFAULT_LANGUAGE
subscription_url: str,
app: dict,
device_type: str,
language: str = DEFAULT_LANGUAGE,
) -> InlineKeyboardMarkup:
from app.handlers.subscription import create_deep_link
texts = get_texts(language)
keyboard = []
if 'installationStep' in app and 'buttons' in app['installationStep']:
app_buttons = []
for button in app['installationStep']['buttons']:
button_text = _get_localized_value(button.get('buttonText'), language)
button_link = button.get('buttonLink')
if not button_text or not button_link:
continue
app_buttons.append(InlineKeyboardButton(text=f'📥 {button_text}', url=button_link))
if len(app_buttons) == 2:
keyboard.append(app_buttons)
app_buttons = []
if app_buttons:
keyboard.append(app_buttons)
additional_before_buttons = _build_additional_buttons(
app.get('additionalBeforeAddSubscriptionStep'),
# Reuse the connection guide keyboard logic — same buttons, just always shows "Other apps"
return get_connection_guide_keyboard(
subscription_url,
app,
device_type,
language,
has_other_apps=True,
)
for button in additional_before_buttons:
keyboard.append([button])
connect_link = create_deep_link(app, subscription_url)
if connect_link:
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
url=connect_link,
)
elif settings.is_happ_cryptolink_mode():
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='open_subscription_link',
)
else:
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
url=subscription_url,
)
keyboard.append([connect_button])
additional_after_buttons = _build_additional_buttons(
app.get('additionalAfterAddSubscriptionStep'),
language,
)
for button in additional_after_buttons:
keyboard.append([button])
keyboard.extend(
[
[
InlineKeyboardButton(
text=texts.t('OTHER_APPS_BUTTON', '📋 Другие приложения'), callback_data=f'app_list_{device_type}'
)
],
[
InlineKeyboardButton(
text=texts.t('CHOOSE_ANOTHER_DEVICE', '📱 Выбрать другое устройство'),
callback_data='subscription_connect',
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_TO_SUBSCRIPTION', '⬅️ К подписке'), callback_data='menu_subscription'
)
],
]
)
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> InlineKeyboardMarkup:
texts = get_texts(language)
+9 -4
View File
@@ -663,6 +663,7 @@
"ADMIN_SETTINGS_MAINTENANCE": "🔧 Maintenance",
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Privacy policy",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer",
"ADMIN_SETTINGS_REQUIRED_CHANNELS": "📢 Required channels",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n",
"ADMIN_SQUAD_ADD_ALL": "👥 Add all users",
@@ -905,7 +906,6 @@
"BALANCE_INFO": "\n💰 <b>Balance: {balance}</b>\n\nChoose an action:\n",
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
"BALANCE_TOPUP": "💳 Top up balance",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 <b>Balance top-up required</b>\\n\\nYour cart contains items totaling {total_amount}, but your current balance is insufficient.\\n\\n💳 <b>Top up your balance</b> to complete the purchase.\\n\\nChoose a top-up method:",
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Your {period} subscription was purchased automatically after topping up your balance.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Subscription automatically extended for {period}.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ New expiration date: {date}.",
@@ -930,9 +930,10 @@
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
"CHANNEL_CHECK_NOT_SUBSCRIBED": "You are not subscribed to all required channels. Please subscribe and try again.",
"CHANNEL_REQUIRED_TEXT": "Please subscribe to the required channels and then press the button below.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "Please subscribe to all required channels first!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
"CHECK_STATUS_BUTTON": "📊 Check status",
"CHECK_STATUS_NO_CHANGES": "Status has not changed",
@@ -1327,6 +1328,8 @@
"RESET_TRAFFIC_BUTTON": "🔄 Reset traffic",
"NO_SAVED_SUBSCRIPTION_ORDER": "No pending subscription order was found.",
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
"BALANCE_TOPPED_UP_CART_SUFFICIENT": "✅ Balance topped up by {amount}!\n\n💰 Current balance: {balance}\n\n🛒 You have a saved cart for {cart_total}\nYour balance is sufficient to proceed.",
"BALANCE_TOPPED_UP_CART_INSUFFICIENT": "✅ Balance topped up by {amount}!\n\n💰 Current balance: {balance}\n\n🛒 You have a saved cart for {cart_total}\nStill needed: {missing}",
"RULES_ACCEPT": "✅ I accept the rules",
"RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...",
"RULES_DECLINE": "❌ I do not accept",
@@ -1366,7 +1369,7 @@
"SKIP_BUTTON": "Skip ➡️",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\n⚠️ <b>Important:</b> Balance top-up does not automatically activate your subscription. Please activate your subscription separately!\n\n🔄 If you have a saved subscription cart and auto-purchase is enabled, your subscription will be automatically purchased after balance top-up.\n\nThank you for topping up! 🚀",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.",
@@ -1431,10 +1434,12 @@
"SUBSCRIPTION_NO_SERVERS": "No servers",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "Your subscription has been paused because you left a required channel.\n\nSubscribe to all channels to restore VPN access.",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "Your subscription has been restored!\n\nThank you for subscribing to the channels. VPN is active again.",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Settings",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Subscription settings</b>\n\n📊 <b>Current parameters:</b>\n🌐 Countries: {countries_count}\n📈 Traffic: {traffic_used} / {traffic_limit}\n📱 Devices: {devices_used} / {devices_limit}\n\nChoose what you want to change:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions",
+8 -3
View File
@@ -666,6 +666,7 @@
"ADMIN_SETTINGS_MAINTENANCE": "🔧 تعمیرات",
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ حریم خصوصی",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 شرایط استفاده",
"ADMIN_SETTINGS_REQUIRED_CHANNELS": "📢 کانال‌های اجباری",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "مدیریت Remnawave، مانیتورینگ و تنظیمات:",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **تنظیمات سیستم**",
"ADMIN_SETTINGS_TARIFFS": "📦 تعرفه‌ها",
@@ -925,7 +926,6 @@
"BALANCE_INFO": "\n💰 <b>موجودی: {balance}</b>\n\nعملیات:\n",
"BALANCE_SUPPORT_REQUEST": "🛠️ درخواست از پشتیبانی",
"BALANCE_TOPUP": "💳 شارژ موجودی",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 <b>شارژ موجودی لازم است</b>\n\nسبد خرید شما {total_amount} است ولی موجودی کافی نیست.\n\nلطفاً موجودی را شارژ کنید.\n",
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ اشتراک {period} پس از شارژ خودکار فعال شد.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ اشتراک خودکار {period} تمدید شد.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ انقضای جدید: {date}.",
@@ -951,9 +951,10 @@
"CHANGE_DEVICES_TITLE": "📱 تغییر تعداد دستگاه",
"CHANGE_TARIFF_BUTTON": "📦 تعرفه",
"CHANNEL_CHECK_BUTTON": "✅ عضو شدم",
"CHANNEL_REQUIRED_TEXT": "🔒 برای استفاده از ربات در کانال خبری عضو شوید، سپس دکمه زیر بزنید.",
"CHANNEL_CHECK_NOT_SUBSCRIBED": "شما هنوز در همه کانال‌ها عضو نشده‌اید! لطفاً عضو شوید و دوباره امتحان کنید.",
"CHANNEL_REQUIRED_TEXT": "لطفاً در کانال‌های اجباری عضو شوید و سپس دکمه زیر را فشار دهید.",
"CHANNEL_SUBSCRIBE_BUTTON": "📢 عضویت در کانال",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ هنوز عضو کانال نشده‌اید!",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "لطفاً ابتدا در همه کانال‌های اجباری عضو شوید!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ ممنون از عضویت",
"CHECK_STATUS_BUTTON": "📊 بررسی وضعیت",
"CHECK_STATUS_NO_CHANGES": "وضعیت تغییر نکرده",
@@ -1348,6 +1349,8 @@
"RESET_TRAFFIC_BUTTON": "🔄 بازنشانی ترافیک",
"NO_SAVED_SUBSCRIPTION_ORDER": "❗️ سفارش ذخیره‌شده یافت نشد.",
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ بازگشت به خرید اشتراک",
"BALANCE_TOPPED_UP_CART_SUFFICIENT": "✅ موجودی شارژ شد به مبلغ {amount}!\n\n💰 موجودی فعلی: {balance}\n\n🛒 شما یک سبد ذخیره‌شده به مبلغ {cart_total} دارید\nموجودی شما برای ادامه کافی است.",
"BALANCE_TOPPED_UP_CART_INSUFFICIENT": "✅ موجودی شارژ شد به مبلغ {amount}!\n\n💰 موجودی فعلی: {balance}\n\n🛒 شما یک سبد ذخیره‌شده به مبلغ {cart_total} دارید\nمبلغ باقیمانده: {missing}",
"RULES_ACCEPT": "✅ می‌پذیرم",
"RULES_ACCEPTED_PROCESSING": "✅ قوانین پذیرفته شد. در حال پردازش...",
"RULES_DECLINE": "❌ نمی‌پذیرم",
@@ -1452,10 +1455,12 @@
"SUBSCRIPTION_NO_SERVERS": "❌ سروری در دسترس نیست",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "📱 <b>اشتراک شما</b>\n\n📊 وضعیت: {status}\n📅 اعتبار: {expiry}\n📈 ترافیک: {traffic}\n📱 دستگاه‌ها: {devices}\n🌍 سرورها: {servers}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 موجودی: {balance}\n📱 اشتراک: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 اطلاعات اشتراک\n🎭 نوع: {subscription_type}\n📈 ترافیک: {traffic}\n🌍 سرورها: {servers}\n📱 دستگاه‌ها: {devices}\n🌍 کشورها: {countries}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "اشتراک شما متوقف شده است زیرا از کانال اجباری خارج شدید.\n\nبرای بازیابی دسترسی VPN در همه کانال‌ها عضو شوید.",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ تخفیف اضافی {percent}% فعال شد.\n\nبا سایر تخفیف‌ها جمع می‌شود!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ تخفیف اضافی {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "\n⏳ اعتبار تخفیف: {time_left} <code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 اشتراک خریداری شد!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "اشتراک شما بازیابی شد!\n\nبا تشکر از عضویت در کانال‌ها. VPN دوباره فعال است.",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ تنظیمات",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>تنظیمات اشتراک</b>",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ تنظیمات فقط برای اشتراک پولی",
+9 -4
View File
@@ -666,6 +666,7 @@
"ADMIN_SETTINGS_MAINTENANCE": "🔧 Техработы",
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Политика конф.",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
"ADMIN_SETTINGS_REQUIRED_CHANNELS": "📢 Обязательные каналы",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
"ADMIN_SETTINGS_TARIFFS": "📦 Тарифы",
@@ -925,7 +926,6 @@
"BALANCE_INFO": "\n💰 <b>Баланс: {balance}</b>\n\nВыберите действие:\n",
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
"BALANCE_TOPUP": "💳 Пополнить баланс",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 <b>Требуется пополнение баланса</b>\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\n\nВыберите способ пополнения:",
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Подписка автоматически продлена на {period}.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ Новая дата окончания: {date}.",
@@ -951,9 +951,10 @@
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
"CHANGE_TARIFF_BUTTON": "📦 Тариф",
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
"CHANNEL_CHECK_NOT_SUBSCRIBED": "Вы ещё не подписались на все каналы! Подпишитесь и попробуйте снова.",
"CHANNEL_REQUIRED_TEXT": "Пожалуйста, подпишитесь на обязательные каналы и нажмите кнопку ниже.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "Пожалуйста, подпишитесь на все обязательные каналы!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
"CHECK_STATUS_NO_CHANGES": "Статус не изменился",
@@ -1348,6 +1349,8 @@
"RESET_TRAFFIC_BUTTON": "🔄 Сбросить трафик",
"NO_SAVED_SUBSCRIPTION_ORDER": "❗️ Сохраненный заказ не найден.",
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки",
"BALANCE_TOPPED_UP_CART_SUFFICIENT": "✅ Баланс пополнен на {amount}!\n\n💰 Текущий баланс: {balance}\n\n🛒 У вас есть сохранённая корзина на {cart_total}\nСредств на балансе достаточно для оформления.",
"BALANCE_TOPPED_UP_CART_INSUFFICIENT": "✅ Баланс пополнен на {amount}!\n\n💰 Текущий баланс: {balance}\n\n🛒 У вас есть сохранённая корзина на {cart_total}\nНе хватает: {missing}",
"RULES_ACCEPT": "✅ Принимаю правила",
"RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...",
"RULES_DECLINE": "❌ Не принимаю",
@@ -1387,7 +1390,7 @@
"SKIP_BUTTON": "⏭️ Пропустить",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n\n🆔 ID транзакции: {transaction_id}...\n\n⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. Обязательно активируйте подписку отдельно!\n\n🔄 При наличии сохранённой корзины подписки и включенной автопокупке, подписка будет приобретена автоматически после пополнения баланса.\n\nСпасибо за пополнение! 🚀",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.",
@@ -1452,10 +1455,12 @@
"SUBSCRIPTION_NO_SERVERS": "Нет серверов",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "Ваша подписка приостановлена, так как вы отписались от обязательного канала.\n\nПодпишитесь на все каналы для восстановления доступа к VPN.",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "Ваша подписка восстановлена!\n\nСпасибо за подписку на каналы. VPN снова активен.",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Настройки подписки</b>\n\n📊 <b>Текущие параметры:</b>\n🌐 Стран: {countries_count}\n📈 Трафик: {traffic_used} / {traffic_limit}\n📱 Устройства: {devices_used} / {devices_limit}\n\nВыберите что хотите изменить:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок",
+9 -4
View File
@@ -593,6 +593,7 @@
"ADMIN_SETTINGS_MAINTENANCE": "🔧 Техроботи",
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Політика конф.",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публічна оферта",
"ADMIN_SETTINGS_REQUIRED_CHANNELS": "📢 Обов'язкові канали",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Керування Remnawave, моніторингом та іншими налаштуваннями:",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Налаштування системи**\n\n",
"ADMIN_SQUAD_ADD_ALL": "👥 Додати всіх користувачів",
@@ -846,7 +847,6 @@
"BALANCE_INFO": "\n💰 <b>Баланс: {balance}</b>\n\nОберіть дію:\n",
"BALANCE_SUPPORT_REQUEST": "🛠️ Запит через підтримку",
"BALANCE_TOPUP": "💳 Поповнити баланс",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 <b>Потрібне поповнення балансу</b>\n\nУ вашому кошику знаходяться товари на загальну суму {total_amount}, але на балансі недостатньо коштів.\n\n💳 <b>Поповніть баланс</b>, щоб завершити покупку.\n\nОберіть спосіб поповнення:",
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Підписку на {period} автоматично оформлено після поповнення балансу.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Підписку автоматично продовжено на {period}.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ Нова дата закінчення: {date}.",
@@ -871,9 +871,10 @@
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n  ✅ Кількість пристроїв збільшено!\n\n  📱 Було: {old_count} → Стало: {new_count}\n  💰 Списано: {amount}\n  ",
"CHANGE_DEVICES_TITLE": "📱 Зміна кількості пристроїв",
"CHANNEL_CHECK_BUTTON": "✅ Я підписався",
"CHANNEL_REQUIRED_TEXT": "🔒 Для використання бота підпишіться на канал новин, а потім натисніть кнопку нижче.",
"CHANNEL_CHECK_NOT_SUBSCRIBED": "Ви ще не підписалися на всі канали! Підпишіться і спробуйте знову.",
"CHANNEL_REQUIRED_TEXT": "Будь ласка, підпишіться на обов'язкові канали та натисніть кнопку нижче.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Підписатися",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Ви не підписалися на канал!",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "Будь ласка, підпишіться на всі обов'язкові канали!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Дякуємо за підписку",
"CHECK_STATUS_BUTTON": "📊 Перевірити статус",
"CHECK_STATUS_NO_CHANGES": "Статус не змінився",
@@ -1264,6 +1265,8 @@
"RESET_TRAFFIC_BUTTON": "🔄 Скинути трафік",
"NO_SAVED_SUBSCRIPTION_ORDER": "❗️ Збережене замовлення не знайдено.",
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Повернутися до оформлення підписки",
"BALANCE_TOPPED_UP_CART_SUFFICIENT": "✅ Баланс поповнено на {amount}!\n\n💰 Поточний баланс: {balance}\n\n🛒 У вас є збережений кошик на {cart_total}\nКоштів на балансі достатньо для оформлення.",
"BALANCE_TOPPED_UP_CART_INSUFFICIENT": "✅ Баланс поповнено на {amount}!\n\n💰 Поточний баланс: {balance}\n\n🛒 У вас є збережений кошик на {cart_total}\nНе вистачає: {missing}",
"RULES_ACCEPT": "✅ Приймаю правила",
"RULES_ACCEPTED_PROCESSING": "✅ Правила прийнято! Завершуємо реєстрацію...",
"RULES_DECLINE": "❌ Не приймаю",
@@ -1297,7 +1300,7 @@
"SKIP_BUTTON": "⏭️ Пропустити",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Сталася помилка при зарахуванні коштів. Зверніться до підтримки, платіж буде перевірено вручну.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Технічна помилка при обробці платежу. Зверніться до підтримки для вирішення проблеми.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платіж успішно оброблено!</b>\n\n⭐ Витрачено зірок: {stars_spent}\n💰 Зараховано на баланс: {amount} ₽\n\n🆔 ID транзакції: {transaction_id}...\n\n⚠️ <b>Важливо:</b> Поповнення балансу не активує підписку автоматично. Обов'язково активуйте підписку окремо!\n\n🔄 За наявності збереженого кошика підписки та увімкненої автопокупки, підписка буде придбана автоматично після поповнення балансу.\n\nДякуємо за поповнення! 🚀",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платіж успішно оброблено!</b>\n\n⭐ Витрачено зірок: {stars_spent}\n💰 Зараховано на баланс: {amount} ₽\n🆔 ID транзакції: {transaction_id}...\n\nДякуємо за поповнення! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Помилка: користувача не знайдено. Зверніться до підтримки.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Помилка валідації платежу. Спробуйте ще раз.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Технічна помилка. Спробуйте пізніше.",
@@ -1362,10 +1365,12 @@
"SUBSCRIPTION_NO_SERVERS": "Немає серверів",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📅 Діє до: {end_date}\n⏰ Залишилося: {time_left}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "Вашу підписку призупинено, оскільки ви відписались від обов'язкового каналу.\n\nПідпишіться на всі канали для відновлення доступу до VPN.",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активовано дод. знижку {percent}%. \n\nСумується з іншими знижками!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Дод. знижка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Знижка діє ще: {time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 Підписку успішно придбано!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "Вашу підписку відновлено!\n\nДякуємо за підписку на канали. VPN знову активний.",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Налаштування підписки",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Налаштування підписки</b>\n\n📊 <b>Поточні параметри:</b>\n🌐 Країн: {countries_count}\n📈 Трафік: {traffic_used} / {traffic_limit}\n📱 Пристрої: {devices_used} / {devices_limit}\n\nОберіть що хочете змінити:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Налаштування доступні лише для платних підписок",
File diff suppressed because it is too large Load Diff

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