Compare commits

..

2896 Commits

Author SHA1 Message Date
dependabot[bot] 7bc29c3421 deps(deps): bump the python-dependencies group across 1 directory with 10 updates
---
updated-dependencies:
- dependency-name: aiogram
  dependency-version: 3.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: qrcode[pil]
  dependency-version: '8.2'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: aiohttp
  dependency-version: 3.13.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic
  dependency-version: 2.12.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic-settings
  dependency-version: 2.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: python-dotenv
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: uvicorn
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: python-multipart
  dependency-version: 0.0.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: apscheduler
  dependency-version: 3.11.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: email-validator
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-16 04:34:17 +00:00
Egor 448be6e512 Merge pull request #2606 from BEDOLAGA-DEV/dev
Dev
2026-02-16 07:31:48 +03:00
Fringg 871ceb866c fix: replace deprecated Query(regex=) with pattern= 2026-02-16 07:11:58 +03:00
Fringg 8e61fe4774 fix: handle TelegramBadRequest in ticket edit_message_text calls
Wrap all edit_message_text calls in ticket handlers with try/except
TelegramBadRequest fallback to message.answer(). Fixes crash when
the prompt message was deleted or has no text (e.g. photo message).
2026-02-16 07:09:06 +03:00
Fringg d4dfa235e5 chore: update all dependencies to latest stable versions
Security: cryptography 41.0→44.0+ (4 CVEs patched)
Major: redis 5.0→7.1, fastapi 0.115→0.129, bcrypt 4.2→5.0
Minor: sqlalchemy 2.0.46, alembic 1.18.4, asyncpg 0.31,
  aiosqlite 0.22, qrcode 8.0, packaging 26.0, pyjwt 2.11,
  yookassa 3.10, pyyaml 6.0.3
2026-02-16 06:59:48 +03:00
Fringg 97ec39aa80 fix: add promo code anti-abuse protections
- Rate-limit on brute-force: 5 failed attempts per 5 min blocks user
- Daily stacking limit: max 5 promo activations per 24h (in-memory + DB)
- Format validation: only alphanumeric/hyphen/underscore, 3-50 chars
2026-02-16 06:52:45 +03:00
Fringg 61a97220d3 fix: add /start burst rate-limit to prevent spam abuse
Sliding window limiter: max 3 /start calls per 60 seconds per user.
Runs before the general 0.5s throttle. Shows cooldown timer on block.
Lazy cleanup of start_buckets when size exceeds 500 entries.
2026-02-16 06:41:14 +03:00
Egor 2d04f2aa28 Merge pull request #2605 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.12.0
2026-02-16 02:20:45 +03:00
github-actions[bot] d6e79161e7 chore(main): release 3.12.0 2026-02-15 23:20:25 +00:00
Egor 45fd543206 Merge pull request #2604 from BEDOLAGA-DEV/dev
Dev
2026-02-16 02:20:02 +03:00
Fringg ba0a5e9abd fix: handle tariff_extend callback without period (back button crash)
The 'Back' button on tariff extend confirmation sends
tariff_extend:{id} without a period segment, which crashed
select_tariff_extend_period with IndexError on parts[2].
Now redirects to show_tariff_extend when period is missing.
2026-02-16 01:38:04 +03:00
Fringg d712ab8301 fix: remove redundant trial inactivity monitoring checks
Remnawave already sends user.not_connected webhooks, making the
monitoring service's 1h/24h trial inactivity checks redundant.
The monitoring checks caused false positives because they relied on
traffic_used_gb which may not be synced in real-time.

Removed:
- _check_trial_inactivity_notifications from monitoring cycle
- _send_trial_inactive_notification method
- trial_inactive_1h / trial_inactive_24h notification settings
- Admin UI toggles and preview buttons for these notifications
2026-02-16 00:58:24 +03:00
Fringg 1e2a7e3096 fix: webhook notification 'My Subscription' button uses unregistered callback_data
Changed callback_data from 'subscription' (no handler) to 'menu_subscription'
(registered handler) in _get_subscription_keyboard and _get_traffic_keyboard.
In cabinet mode the button opens a WebApp URL so the bug was invisible,
but in default MAIN_MENU_MODE the callback went unhandled.
2026-02-16 00:30:17 +03:00
Fringg 64a684cd2f fix: filter out traffic packages with zero price from purchase options 2026-02-15 23:32:15 +03:00
Fringg e4c207ecff chore: format files with ruff 2026-02-15 23:18:44 +03:00
Fringg 80914c1af7 fix: daily tariff subscriptions stuck in expired/disabled with no resume path
- Keyboard now shows "Возобновить" for disabled/expired daily tariffs
  instead of useless "Приостановить"
- resume_daily_subscription handles EXPIRED→ACTIVE (not only DISABLED)
- Pause handler detects inactive status and calls resume directly
- subscription_extend redirects daily tariffs to subscription info
  (daily tariffs have no period_prices, so extend page was empty)
2026-02-15 23:17:45 +03:00
Fringg e1822800ab fix: handle photo message in ticket creation flow
Ticket creation crashed with "there is no text in the message to edit"
when initiated from the tickets list (rendered as photo with logo).
2026-02-15 22:58:31 +03:00
Fringg 68773b7e77 feat: add per-button enable/disable toggle and custom labels per locale
- Add enabled flag to hide/show each button section in main menu
- Add per-locale custom labels (ru, en, ua, zh, fa) for button text
- Deep-copy nested labels dict in cache to prevent reference leaks
- Validate label entries from DB (type + locale key checks)
- Use selective merge in PATCH handler instead of blind .update()
2026-02-12 23:42:55 +03:00
Fringg 10538e7351 feat: add 'default' (no color) option for button styles
Allow admins to set buttons to Telegram's default style with no color
override. Refactors style resolution from or-chain to explicit if/elif/else
so that 'default' does not fall through to global config or hardcoded defaults.
2026-02-12 23:25:42 +03:00
Fringg a9687912df feat: add per-section button style and emoji customization via admin API
Add cabinet admin API for configuring button colors (primary/success/danger)
and custom emoji IDs per menu section (home, subscription, balance, referral,
support, info, admin). Styles are stored as JSON in system_settings and cached
in-process for fast resolution.

Style resolution chain: explicit param > per-section DB > global config > defaults.
2026-02-12 23:15:58 +03:00
Fringg 46c1a69456 fix: pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults 2026-02-12 22:43:30 +03:00
Fringg bf2b2f1c56 feat: add button style and emoji support for cabinet mode (Bot API 9.4)
- Upgrade aiogram to 3.25.0 for style/icon_custom_emoji_id support
- Add CABINET_BUTTON_STYLE config for global color override
- Per-section default styles: subscription (green), balance (blue),
  referral (green), admin (red), home (blue)
- Style priority: explicit > CABINET_BUTTON_STYLE > per-section default
- Add icon_custom_emoji_id pass-through for Premium bot owners
- Admin panel setting for button style with color picker
2026-02-12 22:34:38 +03:00
Fringg 9ac6da490d feat: add web admin button for admins in cabinet mode 2026-02-12 22:22:28 +03:00
Fringg ad87c5fb5e feat: rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections
- Rename mode from 'text' to 'cabinet' (text/text_only/minimal kept as aliases)
- Add build_cabinet_url() for joining MINIAPP_CUSTOM_URL with section paths
- Cabinet main menu now has section-specific buttons: subscription, balance,
  referral, support, info — each opens the corresponding cabinet page
- Add CALLBACK_TO_CABINET_PATH mapping for automatic deep-linking from
  callback_data to cabinet routes (/subscription, /balance, /referral, etc.)
- Unmapped callback_data gracefully falls back to regular Telegram callbacks
- Add startup validation warning when cabinet mode is active without MINIAPP_CUSTOM_URL
- Update admin broadcast buttons with section-specific routing
- Backward compatible: is_text_main_menu_mode() kept as alias for is_cabinet_mode()
2026-02-12 22:21:08 +03:00
Egor 7ac73e5745 Merge pull request #2600 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.11.0
2026-02-12 21:12:59 +03:00
github-actions[bot] 61be89743d chore(main): release 3.11.0 2026-02-12 18:12:13 +00:00
Egor d174d9a927 Merge pull request #2599 from BEDOLAGA-DEV/dev
Dev
2026-02-12 21:11:44 +03:00
Fringg 4048aebb9f chore: format models.py 2026-02-12 21:08:05 +03:00
Fringg bfd66c42c1 fix: add passive_deletes to Subscription relationships to prevent NOT NULL violation on cascade delete 2026-02-12 20:59:28 +03:00
Fringg 351c95bac1 chore: change SALES_MODE default to tariffs 2026-02-12 20:55:52 +03:00
Fringg 1d43ae5e25 fix: add startup warning for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE in guide mode 2026-02-12 20:43:12 +03:00
Fringg 476b89fe8e feat: add startup warnings for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE and MINIAPP_CUSTOM_URL 2026-02-12 20:38:33 +03:00
Fringg 14e13177b5 chore: change CONNECT_BUTTON_MODE default to miniapp_subscription 2026-02-12 20:35:34 +03:00
Fringg 760c833b74 fix: ticket creation crash and webhook PendingRollbackError
- tickets.py: remove ENABLE_LOGO_MODE branches that used edit_message_caption
  on text messages (prompt is always text, not photo with caption)
- webhook_service: add db.rollback() before retrying DB ops in _handle_user_deleted
  when subscription was cascade-deleted, catch PendingRollbackError alongside StaleDataError
2026-02-12 20:32:52 +03:00
Fringg 1a476c49c1 feat: add cabinet admin API for pinned messages management
- Full CRUD + broadcast/unpin/activate/deactivate endpoints
- Admin auth required on all endpoints (get_current_admin_user)
- Broadcast cooldown (60s) on all mass operation endpoints
- Cached Bot singleton to prevent aiohttp session leaks
- Guard against deleting active pinned messages (409 Conflict)
- Route ordering: /active/* before /{message_id}/* to prevent path conflicts
- Pydantic schemas with proper validation (file_id max_length=255)
2026-02-12 19:13:51 +03:00
Fringg 454b83138e fix: flood control handling in pinned messages and XSS hardening in HTML sanitizer
- Add retry loop with backoff to _unpin_message_for_user (max 3 attempts)
- Add TelegramRetryAfter handling in _send_and_pin_message (unpin + send phases)
- Fix missing failed_count increment when all broadcast retries exhaust (for/else)
- Remove dead code in unpin_active_pinned_message (unreachable TelegramRetryAfter catch)
- Harden sanitize_html: allowlist URI schemes (http/https/tg/mailto/tel), whitelist
  tag attributes, strip all attrs from tags without explicit whitelist, full HTML
  entity decoding via html.unescape
2026-02-12 19:13:40 +03:00
Fringg 2de438426a fix: suppress expired callback query error in AuthMiddleware
Catch TelegramBadRequest with "query is too old" before generic Exception handler
to prevent it from being logged as error and triggering error reports.
2026-02-12 18:43:16 +03:00
Egor 6039db997c Merge pull request #2597 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.10.3
2026-02-12 07:10:05 +03:00
github-actions[bot] 940959c951 chore(main): release 3.10.3 2026-02-12 04:06:02 +00:00
Egor e688110129 Merge pull request #2596 from BEDOLAGA-DEV/dev
Dev
2026-02-12 07:05:38 +03:00
Fringg 57dc1ff47f fix: resolve deadlock on server_squads counter updates and add webhook notification toggles
- Fix deadlock: enforce sorted lock ordering in add_user_to_servers/remove_user_from_servers
- Fix cross-call deadlock: add update_server_user_counts() for atomic add+remove in one sorted pass
- Fix deadlock in squad migration: use sorted dict iteration for counter updates
- Fix broken "Buy traffic" button: subscription_add_traffic → buy_traffic callback_data
- Add 12 webhook notification toggle settings (WEBHOOK_NOTIFY_*) with master toggle
- Add admin UI category "Уведомления от вебхуков" with hints in BotConfigurationService
- Add toggle check in _notify_user() respecting master and per-event settings
2026-02-12 06:47:26 +03:00
Fringg fc42916b10 fix: harden backup create/restore against serialization and constraint errors
- Backup creation: handle Decimal, float NaN/Inf, fallback for JSON column dumps
- Restore users: savepoint per INSERT to survive duplicate telegram_id/email/referral_code
- Restore associations: savepoint per INSERT to survive FK or duplicate constraint violations
- Restore table records: savepoint already added in prior commit
2026-02-12 03:41:24 +03:00
Fringg 5893874776 fix: handle unique constraint conflicts during backup restore without clear_existing 2026-02-12 03:37:36 +03:00
Egor 60305d8d5b Merge pull request #2595 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.10.2
2026-02-12 03:07:43 +03:00
github-actions[bot] 07ef3b46d9 chore(main): release 3.10.2 2026-02-12 00:07:06 +00:00
Egor f9d58e964c Merge pull request #2594 from BEDOLAGA-DEV/dev
Dev
2026-02-12 03:06:34 +03:00
Fringg d3c14ac303 fix: UnboundLocalError for get_logo_media in required_sub_channel_check 2026-02-12 02:56:14 +03:00
Fringg fda9f3beec fix: suppress bot-blocked-by-user error in AuthMiddleware 2026-02-12 02:53:05 +03:00
Fringg 27365b3c75 fix: handle time/date types in backup JSON serialization 2026-02-12 02:51:20 +03:00
Fringg 3dac332a9f chore: ruff format 7 files 2026-02-11 21:50:49 +03:00
Fringg c5124b97b6 fix: payment race conditions, balance atomicity, renewal rollback safety
- YooKassa: SELECT FOR UPDATE on payment row to prevent concurrent double-processing
- subtract_user_balance: row locking to prevent concurrent balance race conditions
- subtract_user_balance: transaction creation before commit for atomicity
- subscription renewal: compensating refund if extend_subscription fails after charge
- StaleDataError: use savepoint instead of full rollback to protect parent transaction
2026-02-11 21:49:37 +03:00
Fringg ee2e79db31 refactor: remove modem functionality from classic subscriptions
Remove all modem purchase/management code:
- Delete modem handler, service, and tests
- Remove modem button from keyboards and admin panel
- Remove modem pricing from calculations
- Remove modem REST API endpoint and schemas
- Remove modem decorator, config settings, and notification formatting
- Keep DB column and migration for backwards compatibility
2026-02-11 21:14:08 +03:00
Fringg d05ff678ab fix: HTML parse fallback, email change race condition, username length limit
- start.py: retry welcome message with parse_mode=None on TelegramBadRequest HTML parse error
- auth.py: handle IntegrityError race condition on email change, wrap email sending in try-except
- config.py: truncate RemnaWave username to 36 chars (API limit) instead of 64
2026-02-11 20:51:50 +03:00
Fringg fcaa9dfb27 fix: clean stale squad UUIDs from tariffs during server sync
When squads are deleted from the RemnaWave panel and servers are synced,
the bot cleaned subscription connected_squads but left stale UUIDs in
tariff.allowed_squads. This caused errors when users tried to purchase
or extend subscriptions with tariffs referencing deleted squads.

Now sync_with_remnawave also removes stale UUIDs from all tariffs.
2026-02-11 18:37:19 +03:00
Fringg c30c2feee1 fix: handle StaleDataError in webhook user.deleted server counter decrement
When a user is deleted from the panel, the subscription may already be
cascade-deleted by the time the webhook handler tries to decrement
server counters. This caused StaleDataError followed by
PendingRollbackError when accessing subscription.id in the error handler.

- Save subscription.id before DB operations to avoid lazy load after rollback
- Catch StaleDataError explicitly and rollback the session
- Re-fetch subscription/user after potential rollback in _handle_user_deleted
- Skip subscription cleanup if it was already cascade-deleted
2026-02-11 18:35:36 +03:00
Fringg 640da34736 fix: remove DisplayNameRestrictionMiddleware
Blocking users based on display name patterns caused false positives
for legitimate users. Removed middleware registration from dispatcher.
2026-02-11 18:31:50 +03:00
Fringg 93bb8e0eb4 fix: allow email change for unverified emails
Unverified email users could not change their email (e.g. to fix a typo)
because the endpoint required email_verified=True. Now unverified emails
are replaced directly without code verification, and a new verification
email is sent to the updated address.
2026-02-11 18:28:52 +03:00
Fringg 7d9ced8f4f fix: delete subscription_servers before subscription to prevent FK violation
reset_user_subscription and reset_trial endpoints did not clean up
subscription_servers rows before deleting the subscription, causing
ForeignKeyViolationError on subscription_servers.subscription_id_fkey.

Also fixed the same missing cleanup in user_service.hard_delete_user.
2026-02-11 18:25:42 +03:00
Fringg b5998ea9d2 fix: use traffic topup config and add WATA 429 retry
- Cabinet API: use get_traffic_topup_packages() instead of
  get_traffic_packages() in classic mode endpoints (lines 622, 727, 2410)
  to prevent infinite free traffic exploit via initial-purchase packages
- WATA service: add retry logic for 429 rate limit responses with
  Retry-After parsing from header and response body, up to 2 retries,
  downgrade 429 from error to warning log level
2026-02-11 18:20:30 +03:00
Egor aabadf1ffd Merge pull request #2592 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.10.1
2026-02-11 06:11:43 +03:00
github-actions[bot] e5e5bb3354 chore(main): release 3.10.1 2026-02-11 03:11:02 +00:00
Egor ea41b0af7a Merge pull request #2591 from BEDOLAGA-DEV/dev
Dev
2026-02-11 06:10:38 +03:00
Fringg 3193ffbd1b fix: change CryptoBot URL priority to bot_invoice_url for Telegram opening 2026-02-11 05:50:43 +03:00
Egor 5da01cc6df Merge pull request #2590 from BEDOLAGA-DEV/main
w
2026-02-11 04:47:13 +03:00
Fringg 887ea9cf5a style: format subscription.py with ruff 2026-02-11 04:45:42 +03:00
Fringg bee4aa4284 fix: protect server counter callers and fix tariff change detection
- Wrap unprotected add/remove_user_to/from_servers calls in try/except
  in miniapp.py and cabinet subscription.py to prevent 500 errors
- Fix is_tariff_change to include classic-to-tariff transitions
  (subscription.tariff_id=None → new tariff_id) so purchased traffic
  is properly reset when switching modes
2026-02-11 04:44:15 +03:00
Fringg b167ed3dd1 fix: preserve purchased traffic when extending same tariff
extend_subscription was unconditionally resetting purchased_traffic_gb
and deleting TrafficPurchase records whenever traffic_limit_gb was passed,
even when extending the same tariff (not changing). Now only resets
on actual tariff change (is_tariff_change=True), preserving purchased
traffic on same-tariff extensions.
2026-02-11 04:38:08 +03:00
Fringg 6cec024e46 fix: use flush instead of commit in server counter functions
add_user_to_servers and remove_user_from_servers were calling
db.commit() internally, breaking transaction atomicity for all
callers that perform additional operations afterward. Changed to
db.flush() so the caller controls the commit boundary.
2026-02-11 04:15:50 +03:00
Fringg 2094886990 fix: address review issues in backup, updates, and webhook handlers
- backup: add DATE column parsing in restore, use is_file() in delete_backup
- updates: add missing callback.answer() in show_updates_menu early return
- webhook: add server counter decrement and SubscriptionServer cleanup on user deletion, use single commit
2026-02-11 04:09:39 +03:00
Fringg b0fd38d60c fix: clear subscription data when user deleted from Remnawave panel
Previously only status was set to expired and remnawave_uuid cleared.
Now also clears subscription_url, subscription_crypto_link,
remnawave_short_uuid, and connected_squads so the bot correctly
shows no active subscription after panel deletion.
2026-02-11 04:02:09 +03:00
Fringg 3a680b41b0 fix: suppress 'message is not modified' error in updates panel
- Remove dangling version_info['repo_url'] expression
- Handle 'message is not modified' in all three update handlers
  to prevent error screen on repeated button clicks
2026-02-11 03:47:30 +03:00
Fringg 02e40bd6f7 fix: expand backup coverage to all 68 models and harden restore
- Add 37 missing models to backup (payment providers, polls, contests,
  wheel, FAQ, promo offers, webhooks, configs, menu buttons, etc.)
- Add tariff_promo_groups and payment_method_promo_groups association tables
- Replace hardcoded association restore with generic handler
- Fix transaction atomicity: flush instead of commit in inner methods,
  remove inner rollback calls, single commit/rollback in outer handler
- Fix composite PK support for UserPromoGroup (was only detecting first PK)
- Fix duplicate insert bug when clear_existing=True and record already exists
- Add cabinet_refresh_tokens to clear list, fix support_audit_logs deletion order
- Add Time column parsing for ReferralContest.daily_summary_time
- Security: tarfile filter='data', path traversal protection in _restore_files
  and delete_backup, os.sep in startswith checks
2026-02-11 03:35:16 +03:00
Fringg 19dabf3851 fix: allow purchase when recalculated price is lower than cached
Only block purchase when the price increased (user would overpay).
When a promo discount activates between viewing price and confirming,
the recalculated price is lower — allow the purchase at the new price
instead of forcing the user to restart the checkout flow.
2026-02-11 02:30:40 +03:00
Fringg eaf3a07579 fix: use callback fallback when MINIAPP_CUSTOM_URL is not set
Only consider MINIAPP_CUSTOM_URL for miniapp buttons, not the
purchase-only MINIAPP_PURCHASE_URL which cannot display subscription
info and loads indefinitely. When no custom URL is configured, fall
back to regular callback_data so the bot shows subscription natively.
2026-02-11 01:58:40 +03:00
Fringg be1da976e1 fix: ignore 'message is not modified' on privacy policy decline
User clicking Decline twice produced the same edit_text causing
TelegramBadRequest. Silently ignore it and remove pointless retry.
2026-02-11 01:40:49 +03:00
Fringg a1ffd5bda6 fix: prevent cascading greenlet errors after sync rollback
After db.rollback() all ORM objects expire. Subsequent attribute access
triggers lazy load in async context causing greenlet_spawn errors for
every remaining user. Break the sync loop after rollback instead of
continuing with a corrupted session.

Also downgrade TelegramNetworkError to warning in channel_checker.
2026-02-11 01:39:39 +03:00
Fringg d58a80f3ea fix: handle StaleDataError in webhook when user already deleted
When a user is deleted via cabinet, RemnaWave sends user.disabled webhook
but the subscription row is already cascade-deleted. This caused
StaleDataError on commit + PendingRollbackError when logging user.id.

Save user_id before handler call and catch StaleDataError as warning.
2026-02-11 01:18:58 +03:00
Egor 45c7afe34c Update README.md 2026-02-11 01:03:58 +03:00
Fringg e43a8d6ce4 fix: downgrade Telegram timeout errors to warning in monitoring service
Add TelegramNetworkError handling before generic Exception catch in all
notification methods to prevent timeout errors from generating error
reports in chat. Timeouts are transient network issues, not bugs.
2026-02-10 23:11:48 +03:00
Fringg e94b93d0c1 fix: handle nullable traffic_limit_gb and end_date in subscription model
Add None-safety guards to Subscription model properties (is_active,
is_expired, should_be_expired, actual_status, days_left,
traffic_used_percent) and pricing handler comparisons to prevent
TypeError when nullable columns contain None values.
2026-02-10 20:35:42 +03:00
Egor 2ad26a9156 Merge pull request #2588 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.10.0
2026-02-10 07:59:27 +03:00
github-actions[bot] 3383b9c790 chore(main): release 3.10.0 2026-02-10 04:58:24 +00:00
Egor 6acaf18203 Merge pull request #2587 from BEDOLAGA-DEV/dev
Release: dev → main
2026-02-10 07:57:54 +03:00
Fringg 019fbc12b6 fix: webhook:close button not working due to channel check timeout
Channel checker middleware called bot.get_chat_member() which could
timeout (60s), causing callback.answer() to fail with "query too old".

Skip channel check for lightweight UI callbacks (webhook:close,
ban_notify:delete, noop). Also answer callback before delete attempt
and add fallback to remove keyboard if delete fails.
2026-02-10 07:54:24 +03:00
Fringg 5156d635f0 fix: sync subscription status from panel in user.modified webhook
When subscription was extended in panel, webhook updated end_date but
left status as expired. Now syncs ACTIVE/DISABLED status from panel
payload when end_date is in the future.
2026-02-10 07:49:10 +03:00
Fringg f77922522a fix: allow non-HTTP deep links in crypto link webhook updates
_is_valid_url only accepted http(s), silently dropping valid deep links
like happ://, vless://, ss:// from revoked webhook payloads.
Added _is_valid_link that accepts any URI scheme.
2026-02-10 07:41:32 +03:00
Fringg 0db00a8f90 style: format cryptobot.py with ruff 2026-02-10 07:33:42 +03:00
Fringg fe54640885 fix: add missing placeholders to Arabic SUBSCRIPTION_INFO template
Was just a header text without {status}, {type}, etc. placeholders,
causing KeyError when .format() was called.
2026-02-10 07:30:52 +03:00
Fringg ec8eaf52bf fix: downgrade transient API errors (502/503/504) to warning level
502/503/504 are transient errors that don't need ERROR reports in chat.
Also downgrade API connection test failure to warning.
2026-02-10 07:27:20 +03:00
Fringg fe5f5ded96 feat: add MULENPAY_WEBSITE_URL setting for post-payment redirect
Previously website_url was hardcoded to WEBHOOK_URL, redirecting users
to the webhook endpoint after payment. Now configurable via env var.
2026-02-10 07:25:58 +03:00
Fringg 2cb6d731e9 fix: stop CryptoBot webhook retry loop and save cabinet payments to DB
Cabinet was calling CryptoBotService.create_invoice() directly without
saving CryptoBotPayment to DB. When webhook arrived, payment lookup
failed and returned HTTP 400, causing infinite retries.

Now cabinet uses PaymentService.create_cryptobot_payment() (same as
miniapp) with proper USD conversion via currency_converter.

Also return HTTP 200 for unknown invoice_ids to stop retry spam.
2026-02-10 07:25:54 +03:00
Fringg 184c52d4ea feat: webhook protection — prevent sync/monitoring from overwriting webhook data
Add last_webhook_update_at timestamp to Subscription model. When a webhook
handler modifies a subscription, it stamps this field. Auto-sync, monitoring,
and force-check services skip subscriptions updated by webhook within the
last 60 seconds, preventing stale panel data from overwriting fresh
real-time changes.

- Add last_webhook_update_at column + migration
- Stamp all 8 webhook handlers with commit in every code path
- Add is_recently_updated_by_webhook() guard in 12 sync/monitoring paths
- Add REMNAWAVE_WEBHOOK_* variables to .env.example
- Add webhook setup documentation to README with Caddy/nginx examples
- Fix pre-existing yookassa webhook test (mock AsyncSessionLocal)
2026-02-10 07:16:22 +03:00
Fringg 8e85e244cb feat: handle errors.bandwidth_usage_threshold_reached_max_notifications webhook
Last remaining unhandled RemnaWave backend event — sends admin
notification when the bandwidth threshold notification limit is reached.
2026-02-10 06:34:20 +03:00
Fringg 43a326a98c feat: handle service.subpage_config_changed webhook event
Add admin notification when subscription page config is
created, updated or deleted in RemnaWave panel.
2026-02-10 06:32:52 +03:00
Fringg d9de15a5a0 feat: add close button to all webhook notifications
Add dismissible close button (✖️) to every webhook notification message.
Users can now close any webhook notification by tapping the button,
which deletes the message via webhook:close callback handler.
2026-02-10 06:22:17 +03:00
Fringg 17ce64037f fix: build composite device name from platform + hwid short suffix
Show "tag (platform)" when tag is set, "iOS (ab12cd34)" when only
platform and hwid available, or just platform as last resort.
2026-02-10 06:16:37 +03:00
Fringg 79793c47bb fix: extract device name from nested hwidUserDevice object
RemnaWave sends device info in data.hwidUserDevice, not top-level.
Try tag, deviceName, platform, hwid fields from nested object first.
2026-02-10 06:14:01 +03:00
Fringg 7091eb9c14 fix: add action buttons to webhook notifications and fix empty device names
- Add keyboard buttons to all webhook notifications: renew, connect,
  my subscription, buy traffic — context-appropriate per event type
- Extract device name from multiple possible payload fields (deviceName,
  tag, hwid, device, platform, name) with fallback to dash
- Log payload keys for device events to identify correct field names
2026-02-10 06:09:00 +03:00
Fringg dc1e96bbe9 fix: security and architecture fixes for webhook handlers
- Add html.escape() to all untrusted webhook data in admin and device
  notifications (prevents HTML/Telegram injection)
- Add public send_webhook_notification() and is_enabled property to
  AdminNotificationService (eliminates private method access)
- Add dedicated NotificationType enum values for device and not_connected
  events (fixes incorrect semantic mapping)
- Extend user resolution to handle nested user objects and userUuid for
  device-scope events
- Replace manual __anext__() DB session with AsyncSessionLocal context
  manager; skip DB session for admin-only events
- Replace deprecated datetime.utcnow() with datetime.now(UTC)
- Use db.flush() instead of db.commit() in handlers (router commits)
- Wrap _notify_user in try/except to prevent notification failures from
  rolling back successful DB mutations
2026-02-10 05:55:48 +03:00
Fringg 1e37fd9dd2 feat: add all remaining RemnaWave webhook events (node, service, crm, device)
Handle all 44 webhook events: admin alerts for node health (connection
lost/restored), service security (login attempts), CRM billing reminders,
plus user-facing device added/deleted and not_connected notifications
with localized messages across all 5 languages.
2026-02-10 05:47:35 +03:00
Fringg 9aa22af339 fix: use event field directly as event_name (already includes scope prefix)
RemnaWave sends event as "user.modified", not "modified".
Concatenating scope + event produced "user.user.modified" which
didn't match any handler keys.
2026-02-10 05:31:39 +03:00
Fringg 26637f0ae5 feat: unified notification delivery for webhook events (email + WS support)
- Replace direct bot.send_message with notification_delivery_service
- Email-only and OAuth users now receive webhook notifications via email/WS
- Add 10 new NotificationType enum values for webhook subscription events
- Map all webhook text_keys to NotificationType for unified routing
2026-02-10 05:16:59 +03:00
Fringg 6d67cad3e7 feat: add RemnaWave incoming webhooks for real-time subscription events
- Add FastAPI webhook endpoint with HMAC-SHA256 signature verification
- Handle 16 user events: expired, disabled, enabled, limited, traffic_reset,
  modified, deleted, revoked, created, expires_in_72h/48h/24h,
  expired_24h_ago, first_connected, bandwidth_threshold
- URL validation for subscription_url/subscription_crypto_link (XSS prevention)
- 64KB body size limit, 32-char minimum secret enforcement
- Sanitized percent value in bandwidth threshold notifications
- DB rollback on handler errors to prevent dirty session commits
- Localization for all 5 languages (ru, en, ua, zh, fa)
2026-02-10 05:13:39 +03:00
Fringg 90d9df8f0e fix: preserve payment initiation time in transaction created_at
Transaction created_at and completed_at showed identical timestamps
because webhook handlers created transactions with is_completed=True
in a single step. Now all 10 payment providers pass payment.created_at
to the transaction so created_at reflects when the user initiated
the payment, not when the webhook processed it.

Also: remove duplicate datetime import in inline.py, upgrade button
stats DB error logging from debug to warning, add index on
button_click_logs.button_type for analytics queries.
2026-02-10 04:26:23 +03:00
Egor ef654a09bb Merge pull request #2586 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.9.1
2026-02-10 04:05:23 +03:00
github-actions[bot] 74f3b388d8 chore(main): release 3.9.1 2026-02-10 01:04:57 +00:00
Egor 5af1f358b2 Merge pull request #2585 from BEDOLAGA-DEV/dev
Release: dev -> main
2026-02-10 04:04:30 +03:00
Fringg 994325360c fix: don't delete Heleket invoice message on status check
_process_heleket_payload deleted the invoice message on every call,
including manual "check status" presses. Now only deletes on final
statuses (paid, cancel, fail, etc.) so the payment UI stays visible
while the user is still waiting.

Also includes subscription fallback query fix (actual DB columns).
2026-02-10 03:50:21 +03:00
Fringg f0e7f8e3be fix: use actual DB columns for subscription fallback query
Subscription.is_active is a Python property, not a column — query
status/end_date/is_trial columns instead. Also restore subscription=None
initialization to avoid UnboundLocalError on line 112.
2026-02-10 03:44:34 +03:00
Fringg 40d8a6dc8b fix: safe HTML preview truncation and lazy-load subscription fallback
Rules editor crashed when preview truncated mid-HTML tag (e.g.
<blockquote> cut to <blockquo), causing Telegram parse error.
Strip HTML tags before truncating preview text.

Also fix MissingGreenlet in build_topup_success_keyboard: fall back
to a direct DB query instead of showing wrong button text.
2026-02-10 03:32:20 +03:00
Egor 6488dcfcb2 Merge pull request #2584 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.9.0
2026-02-09 23:08:05 +03:00
github-actions[bot] 9ec5f7f59e chore(main): release 3.9.0 2026-02-09 20:07:13 +00:00
Egor 0621a3febc Merge pull request #2582 from BEDOLAGA-DEV/dev
Release: remove auto-activation, Flask cleanup, production bug fixes
2026-02-09 22:42:36 +03:00
Fringg ebd6bee05e feat: allow tariff deletion with active subscriptions
Remove blocking check that prevented tariff deletion when subscriptions
exist. DB schema already supports SET NULL on tariff FK, so subscriptions
gracefully become "legacy" and users pick a new tariff on renewal.
Return affected_subscriptions count in API response.
2026-02-09 22:30:26 +03:00
Fringg 119f463c36 refactor: remove Flask, use FastAPI exclusively for all webhooks
Delete dead Flask-based PAL24 webhook server (app/external/pal24_webhook.py).
PAL24 webhooks already handled by unified FastAPI server on port 8080.

- Remove flask dependency from pyproject.toml and requirements.txt
- Remove PAL24_WEBHOOK_PORT config (unused, FastAPI uses shared port)
- Remove pal24_webhook module reference from log filter
- Update docs: webhook example rewritten from Flask to FastAPI
- Uninstall flask, werkzeug, blinker, itsdangerous
2026-02-09 21:54:15 +03:00
Fringg a3903a252e refactor: remove smart auto-activation & activation prompt, fix production bugs
Remove AUTO_ACTIVATE_AFTER_TOPUP and SHOW_ACTIVATION_PROMPT_AFTER_TOPUP
features from all payment providers, config, system settings, and tests.
Cart auto-purchase (AUTO_PURCHASE_AFTER_TOPUP) is preserved.

Bug fixes:
- fix KeyError 'months' in devices.py for custom locale overrides
- fix IntegrityError on trial subscription retry (update existing PENDING instead of INSERT)
- fix PendingRollbackError cascade by adding db.rollback() before recovery
- fix TelegramForbiddenError not caught in photo_message.py
- fix "query is too old" spam in required_sub_channel_check
- add missing trial locale keys (TRIAL_PAYMENT_DESCRIPTION, TRIAL_REFUND_DESCRIPTION, TRIAL_ACTIVATION_ERROR)
2026-02-09 21:39:53 +03:00
Egor 65ba50c2cf Merge pull request #2547 from DenyaBanan/patch-1
Fix 401 error
2026-02-09 21:10:04 +03:00
Egor cc54a7ad2f Merge pull request #2580 from xenral/main
feat(localization): add Persian (fa) locale support and wire it across app flows
2026-02-09 21:09:43 +03:00
PEDZEO 7b0403a307 feat: add lite mode functionality with endpoints for retrieval and update
Introduced a new feature for lite mode, including a GET endpoint to retrieve the current lite mode setting and a PATCH endpoint to update it. Added corresponding response and update models for lite mode management.
2026-02-09 18:18:56 +03:00
Fringg 142ff14a50 perf: cache logo file_id to avoid re-uploading on every message
After first logo upload, Telegram returns a file_id that can be reused
for all subsequent sends. This eliminates 3-4 second delay per message
caused by re-uploading the same file from disk every time.
2026-02-09 18:14:54 +03:00
Ali Morshedzadeh 29a3b395b6 feat: add Persian (fa) locale with complete translations
Translate all bot strings to Persian, including admin panel, user interface, payment flows, contests, monitoring, and promotional features. Add RTL text support and Persian-specific formatting for dates, numbers, and currency displays.
2026-02-09 18:24:28 +03:30
Fringg 49871f82f3 fix: prevent sync from overwriting end_date for non-ACTIVE panel users
sync_users_to_panel uses _safe_expire_at_for_panel which replaces past
end_dates with now+1min for expired subscriptions. When sync_users_from_panel
reads these artificial dates back, it treated them as legitimate "newer"
dates and overwrote all expired subscriptions' end_date to approximately
current time. This caused all subscription end dates to show as "just now"
after sync.

Fix: only update end_date from panel when the panel user status is ACTIVE.
For EXPIRED/DISABLED users, the panel date may be a _safe_expire_at artifact
and should not override the real expiry date in the local database.
2026-02-09 17:39:25 +03:00
Fringg efa3a5d457 refactor: remove "both" mode from BOT_RUN_MODE, keep only polling and webhook 2026-02-09 17:32:17 +03:00
Fringg 0b86f379b4 fix: nullify payment FK references before deleting transactions in user restoration
The user restoration flow deleted transactions without first clearing
foreign key references from payment tables (yookassa_payments,
cryptobot_payments, etc.) and referral_earnings. This caused
IntegrityError when a deleted user had payment records linked to
transactions.
2026-02-09 17:19:45 +03:00
Fringg 1cae7130bc fix: promo code max_uses=0 conversion and trial UX after promo activation
- Convert max_uses=0 to 999999 (unlimited) in cabinet and webapi routes,
  matching bot handler behavior. Fixes miniapp-created promo codes being
  immediately invalid due to is_valid check (current_uses < max_uses).
- Skip trial offer in post-registration keyboard when promo code already
  activated a subscription, showing "back to menu" button instead.
2026-02-09 17:13:11 +03:00
Fringg 45410168af fix: use selection.period.days instead of selection.period_days
PurchaseSelection dataclass has period: PurchasePeriodConfig (with .days),
not period_days. This caused admin notification to fail silently on every
subscription purchase from cabinet.
2026-02-09 16:45:36 +03:00
Ali Morshedzadeh 5482e609f8 Add initial Persian locale support and language handling updates 2026-02-09 16:53:50 +03:30
Fringg e79f598d17 fix: skip users with active subscriptions in admin inactive cleanup
Admin "Clear all" button was deleting inactive users regardless of
subscription status, destroying paid subscriptions. Now matches the
monitoring service behavior by checking is_active before deletion.
2026-02-09 05:53:30 +03:00
Egor 056070b6a4 Merge pull request #2578 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.8.0
2026-02-08 23:36:16 +03:00
github-actions[bot] 8b53c73ce8 chore(main): release 3.8.0 2026-02-08 20:35:58 +00:00
Egor e6ebf81752 Merge pull request #2577 from BEDOLAGA-DEV/dev
feat: admin panel enhancements & bug fixes
2026-02-08 23:35:17 +03:00
Fringg 11b8ab1959 feat: add admin updates endpoint for bot and cabinet releases
GET /cabinet/admin/updates/releases returns release history
and version info for both projects from GitHub API with caching.
2026-02-08 23:20:47 +03:00
Fringg 17e9259eb1 fix: include additional devices in tariff renewal price and display
Tariff renewal showed tariff.device_limit (default) instead of
subscription.device_limit (actual) and didn't add extra device
cost to the renewal price. Fixed in show_tariff_extend,
select_tariff_extend_period, and confirm_tariff_extend.
2026-02-08 23:01:11 +03:00
Fringg 02c30f8e7e feat: add system info endpoint for admin dashboard
Exposes bot version, Python version, uptime, total users and active
subscriptions via GET /cabinet/admin/stats/system-info.
2026-02-08 22:52:12 +03:00
Fringg 15c7cc2a58 feat: add server-side sorting for enrichment columns 2026-02-08 22:39:25 +03:00
Fringg f2dbab6171 feat: add enrichment data to CSV export
Extract _build_enrichment() helper, reuse in both GET /enrichment
endpoint and CSV export. CSV now includes: Connected Devices,
Total Spent (RUB), Sub Start, Sub End, Last Node columns.
2026-02-08 22:36:45 +03:00
Fringg 17af51ce0b fix: use correct pagination params (start/size) for bulk HWID devices
Remnawave API uses start/size (not take/skip) with default size=25.
Now fetches all devices with size=1000 per page. Remove debug logging.
2026-02-08 22:32:20 +03:00
Fringg 8f7fa76e6a fix: revert device pagination, add raw user data field discovery
Bulk device endpoint ignores take/skip params, causing duplicates.
Revert to single call. Add logging to discover extra fields in
panel user response that might include device count.
2026-02-08 22:26:06 +03:00
Fringg 4648a82da9 fix: paginate bulk device endpoint to fetch all HWID devices
The GET /api/hwid/devices endpoint returns only 25 devices by default.
Add take/skip pagination to fetch all devices across all pages.
2026-02-08 22:21:55 +03:00
Fringg 5be82f2d78 fix: add enrichment device mapping debug logs 2026-02-08 22:18:46 +03:00
Fringg 9e3aa23f69 chore: remove debug logging from enrichment endpoint 2026-02-08 22:14:35 +03:00
Fringg 46da31d89c fix: add debug logging for bulk device response structure 2026-02-08 22:11:54 +03:00
Fringg 5f219c33e6 fix: use bulk device endpoint instead of per-user calls
Replace O(users) per-user GET /api/hwid/devices/{uuid} calls
with single GET /api/hwid/devices bulk call to avoid rate limiting.
2026-02-08 22:06:15 +03:00
Fringg 94fcf20d17 fix: add email field to traffic table for OAuth/email users
Include user email in UserTrafficItem schema, search filter,
CSV export, and frontend display (shown below name when no
Telegram username exists).
2026-02-08 22:04:42 +03:00
Fringg 9d39901f78 fix: use per-user panel endpoints for reliable device counts and last node data
Replace bulk /api/hwid/devices and /api/subscriptions calls with
proven per-user endpoints: get_all_users() (paginated) for last
connected node and get_user_devices() with semaphore for device counts.
2026-02-08 22:01:32 +03:00
Fringg 5cf3f2f76e feat: add traffic usage enrichment endpoint with devices, spending, dates, last node
Add GET /admin/traffic/enrichment that returns per-user enrichment data
(connected devices, total spending, subscription dates, last connected node)
via bulk panel API calls with 5-min server-side cache.
2026-02-08 21:49:42 +03:00
Fringg 2f90f9134d feat: add admin traffic packages and device limit management
Add TrafficPurchaseItem schema, extend subscription info with traffic
purchases, add add_traffic/remove_traffic/set_device_limit actions,
extend tariff builder with device/traffic config fields.
2026-02-08 21:13:44 +03:00
Fringg c57de1081a feat: add admin device management endpoints
Add GET/DELETE endpoints for managing user devices from admin panel:
- GET /{user_id}/devices - list connected devices
- DELETE /{user_id}/devices/{hwid} - remove single device
- DELETE /{user_id}/devices - reset all devices
2026-02-08 20:49:04 +03:00
Fringg 33d5155a8d style: format schemas and remnawave_service with ruff 2026-02-08 20:39:22 +03:00
Fringg 9828ff0845 fix: read bot version from pyproject.toml when VERSION env is not set
Previously the bot only checked os.getenv('VERSION'), returning
'UNKNOW' when unset. Now falls back to importlib.metadata and
direct pyproject.toml parsing, so the version stays correct after
release-please updates it.
2026-02-08 20:38:17 +03:00
Fringg da6f746b09 feat: add endpoint for updating user referral commission percent
POST /{user_id}/referral-commission allows admins to set individual
referral commission percentage (0-100) or null for system default.
2026-02-08 20:29:53 +03:00
Fringg 165965d8ea fix: add email/UUID fallback for OAuth user panel sync
OAuth users registering via cabinet have no telegram_id, causing
panel sync failures. All RemnaWave panel lookups now use a 3-level
chain: UUID → telegram_id → email. Also pass email and user_id to
format_remnawave_username to generate unique panel usernames.
2026-02-08 19:55:34 +03:00
Egor e7e01ce9c8 Merge pull request #2576 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.7.2
2026-02-08 19:03:29 +03:00
github-actions[bot] c4c49571ec chore(main): release 3.7.2 2026-02-08 16:03:08 +00:00
Egor 4a63124818 Merge pull request #2575 from BEDOLAGA-DEV/dev
Release: dev → main
2026-02-08 19:02:40 +03:00
Fringg d6fa86b870 fix: remove dots from Remnawave username sanitization
Remnawave API only allows letters, numbers, underscores and dashes in
usernames. The sanitizer regex was also allowing dots, causing OAuth
users with email-based usernames (e.g. john.doe@gmail.com) to fail
subscription creation with "Validation failed: invalid_string".
2026-02-08 19:00:03 +03:00
Fringg 55d281b0e3 fix: handle FK violation in create_yookassa_payment when user is deleted
Catch IntegrityError on INSERT into yookassa_payments when user_id
references a deleted user. Rollback the session and return None instead
of letting the unhandled exception propagate. Protects all callers
(webhook restore, bot handlers, cabinet API, miniapp API).
2026-02-08 18:52:34 +03:00
Egor a42bc9b281 Merge pull request #2574 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.7.1
2026-02-08 18:03:31 +03:00
github-actions[bot] 5bc5567ab1 chore(main): release 3.7.1 2026-02-08 15:03:06 +00:00
Egor d88ca980ec Merge pull request #2573 from BEDOLAGA-DEV/dev
fix: release-please config — remove blocked workflow files
2026-02-08 18:02:46 +03:00
Fringg 0ef4f55304 fix: resolve merge conflict in release-please config 2026-02-08 18:02:20 +03:00
Fringg 5070bb34e8 fix: remove workflow files and pyproject.toml from release-please extra-files
GitHub Actions cannot modify .github/workflows/ files (403 "Resource not
accessible by integration"), causing "Error adding to tree" failure.
pyproject.toml is already handled natively by python release type.
Only Dockerfile needs the generic updater for x-release-please-version markers.
2026-02-08 18:00:59 +03:00
Egor 02d38d7891 Merge pull request #2572 from BEDOLAGA-DEV/dev
Release: dev → main
2026-02-08 17:55:51 +03:00
Fringg c46cc85144 style: format tariff.py with ruff 2026-02-08 17:54:07 +03:00
Fringg 071c23dd52 fix: resolve multiple production errors and performance issues
- tickets.py: guard against non-text messages in waiting_for_title FSM state
- payments.py: fix Wata webhook using wrong field name (order_id vs orderId),
  add full payload to error log
- tariff.py: stop overwriting admin tariff settings on every bot restart,
  sync_default_tariff_from_config now only creates if no tariff exists
- start.py: catch TelegramBadRequest specifically for "message is not modified"
  instead of bare except with useless retry
- admin/tickets.py: downgrade ticket notification log from error to warning
  for expected case of OAuth/email users without telegram_id
- pricing.py, countries.py, purchase.py: guard against expired FSM state
  causing KeyError on 'period_days'
- blacklist_service.py: add 5-min in-memory cache to is_user_blacklisted()
  to reduce DB load from per-request checks
- remnawave_service.py: fix "Session is closed" race condition — create
  new RemnaWaveAPI instance per get_api_client() call instead of reusing
  shared instance whose aiohttp session gets overwritten by parallel coroutines
2026-02-08 17:40:51 +03:00
Egor 5f3e426750 Merge pull request #2571 from BEDOLAGA-DEV/fix/hwid-reset-and-webhook-fk-check
fix: resolve HWID reset and webhook FK violation
2026-02-08 16:48:50 +03:00
Fringg a9eee19c95 fix: resolve HWID reset context manager bug and webhook FK violation
- Fix async context manager usage in sync_users: __aenter__() result
  was not assigned, so hwid_api_client held the context manager object
  instead of the actual API client, causing AttributeError on
  reset_user_devices()
- Add user existence check in _restore_missing_yookassa_payment before
  INSERT to prevent ForeignKeyViolationError when user_id from payment
  metadata no longer exists in users table
2026-02-08 16:48:07 +03:00
Fringg 552a8ff8d8 chore: fix release-please to auto-bump Dockerfile and workflow versions
- Switch release-please to manifest mode (config-file + manifest-file)
- Add Dockerfile and docker workflow files as generic extra-files
- Add x-release-please-version annotations for automatic version replacement
- Bump hardcoded v3.6.0 to v3.7.0 to match current release
2026-02-07 13:57:54 +03:00
Egor bec78beb25 Merge pull request #2569 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.7.0
2026-02-07 13:51:12 +03:00
github-actions[bot] a6561a4788 chore(main): release 3.7.0 2026-02-07 10:49:47 +00:00
Egor c49acc956f Merge pull request #2568 from BEDOLAGA-DEV/dev
chore: release bot updates
2026-02-07 13:49:14 +03:00
Egor 4c40b5b370 Merge pull request #2567 from BEDOLAGA-DEV/feat/traffic-filters-daterange
feat: traffic filters, date range & risk columns in CSV export
2026-02-07 13:30:34 +03:00
Fringg 7c1a142653 feat: add risk columns to traffic CSV export
- Add total_threshold_gb and node_threshold_gb to ExportCsvRequest
- Compute GB/day, risk level, risk ratio for each user when thresholds set
- CSV includes Total GB/day, Risk Level, Risk Ratio, Risk GB/day columns
2026-02-07 13:29:16 +03:00
Egor a161e2f904 Merge pull request #2566 from BEDOLAGA-DEV/feat/traffic-filters-daterange
feat: node/status filters + custom date range for traffic page
2026-02-07 11:54:54 +03:00
Fringg ad260d9fe0 feat: add node/status filters and custom date range to traffic page
- Add node filter: filter traffic by selected nodes, recalculate totals
- Add status filter: filter by subscription status (active/trial/expired/disabled)
- Add custom date range: support start_date/end_date params alongside period
- Refactor _aggregate_traffic to use date strings with stable 5-min cache keys
- Add cache eviction for expired entries to prevent memory leaks
- CSV export now respects all active filters and custom date range
- Extract _get_status helper, add _compute_date_range helper
2026-02-07 11:53:04 +03:00
Fringg 3fd3bce2cf Revert "Merge pull request #2565 from BEDOLAGA-DEV/feat/traffic-filters-devices"
This reverts commit ad6522f547, reversing
changes made to 61bb8fcafd.
2026-02-07 11:29:31 +03:00
Egor ad6522f547 Merge pull request #2565 from BEDOLAGA-DEV/feat/traffic-filters-devices
feat: add node/status filters, date range, devices to traffic page
2026-02-07 11:21:41 +03:00
Fringg 9ea533a864 feat: add node/status filters, custom date range, connected devices to traffic page
- Add node filter (comma-separated UUIDs) and status filter query params
- Add custom date range (start_date/end_date) as alternative to period
- Fetch connected device count per user via HWID API (semaphore=10)
- Cache key changed to (start_str, end_str) tuple for both modes
- CSV export now respects all active filters and date range
- Backend returns available_statuses and filtered nodes list
- Validate future dates, max 31-day range
2026-02-07 11:19:45 +03:00
Egor 61bb8fcafd Merge pull request #2564 from BEDOLAGA-DEV/fix/yookassa-cabinet-payment-db-record
fix: use PaymentService for cabinet YooKassa payments
2026-02-07 10:36:12 +03:00
Fringg ff5bba3fc5 fix: use PaymentService for cabinet YooKassa payments to save local DB record
Cabinet was calling YooKassaService.create_payment() directly, bypassing
PaymentService which saves the payment record to the local database.
When YooKassa webhook arrived, the payment was not found in the DB,
causing payment processing failures.

Now uses PaymentService.create_yookassa_payment() and
create_yookassa_sbp_payment() consistently with all other payment methods.
Also standardizes metadata key from 'type' to 'purpose' to match bot flow.
2026-02-07 10:35:08 +03:00
Egor cc1c8bacb4 Merge pull request #2563 from BEDOLAGA-DEV/fix/traffic-legacy-endpoint
fix: use legacy per-node endpoint for traffic aggregation
2026-02-07 10:06:22 +03:00
Fringg b707b7995b fix: use legacy per-node endpoint with correct response format 2026-02-07 10:05:49 +03:00
Egor a076dfb550 Merge pull request #2562 from BEDOLAGA-DEV/fix/traffic-node-users-parsing
fix: correct response parsing for non-legacy node-users endpoint
2026-02-07 10:01:07 +03:00
Fringg 91ac90c2ae fix: correct response parsing for non-legacy node-users endpoint 2026-02-07 10:00:29 +03:00
Egor b12544d3ea Merge pull request #2561 from BEDOLAGA-DEV/fix/traffic-429-rate-limit
fix: resolve 429 rate limiting on traffic page
2026-02-07 09:49:21 +03:00
Fringg 38018514dc style: apply ruff formatting 2026-02-07 09:48:54 +03:00
Fringg 924d6bc09c fix: resolve 429 rate limiting on traffic page
- Switch from per-user to per-node API strategy in _aggregate_traffic
  (O(nodes) calls instead of O(users), ~10 vs ~200 requests)
- Add retry with exponential backoff for 429 in _make_request
- Reduce concurrency limit from 20 to 5 to prevent request bursts
2026-02-07 09:46:59 +03:00
Egor 1021c2cdcd Merge pull request #2560 from BEDOLAGA-DEV/feat/traffic-tariff-filter
feat: tariff filter + fix traffic data aggregation
2026-02-07 09:32:15 +03:00
Fringg fa01819674 feat: add tariff filter, fix traffic data aggregation
- Switch from get_bandwidth_stats_node_users (broken UUID matching) to
  get_bandwidth_stats_user per user (same API as working detail page)
- Add tariff filter with available_tariffs in response
- Add concurrency-limited parallel per-user bandwidth stats fetching
2026-02-07 09:31:47 +03:00
Egor eeed2d6369 Merge pull request #2559 from BEDOLAGA-DEV/fix/traffic-sort-type-error
fix: handle mixed types in traffic sort
2026-02-07 09:14:30 +03:00
Fringg a194be0843 fix: handle mixed types in traffic sort for string fields
Sort by tariff_name/full_name crashed with TypeError when some values
were None (fallback to 0) mixed with strings. Use empty string fallback
for string fields with case-insensitive comparison.
2026-02-07 09:13:57 +03:00
Egor aa1cd3829c Merge pull request #2558 from BEDOLAGA-DEV/feat/admin-traffic-usage
feat: add admin traffic usage API
2026-02-07 09:06:06 +03:00
Fringg 6c2c25d2cc feat: add admin traffic usage API with per-node statistics
Add paginated GET /admin/traffic endpoint aggregating per-user traffic
across all nodes with server-side sorting, search, and 5-min in-memory
cache. Add POST /admin/traffic/export-csv to generate CSV and send
to admin via Telegram DM.
2026-02-07 09:04:52 +03:00
Egor 0b61c7fe48 Merge pull request #2557 from BEDOLAGA-DEV/fix/version-notification-html-tags
fix: close unclosed HTML tags in version notification
2026-02-07 08:21:50 +03:00
Fringg b6745508da fix: close unclosed HTML tags when truncating version notification
Telegram API rejects messages with mismatched HTML tags. When
truncate_for_blockquote cuts the description mid-way, it can leave
tags like <i>, <b> unclosed inside the blockquote. Telegram then
fails with "Unmatched end tag" error.

Add _close_open_tags helper that scans for unclosed tags and appends
closing tags in reverse order. Also ensure the total length with
closing tags still fits within the message budget.
2026-02-07 08:18:39 +03:00
Egor f5391c3159 Merge pull request #2556 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.6.0
2026-02-07 07:24:28 +03:00
github-actions[bot] 9a81932d2b chore(main): release 3.6.0 2026-02-07 04:23:45 +00:00
Egor 8b50fde9aa Merge pull request #2555 from BEDOLAGA-DEV/dev
chore: sync dev → main (v3.6.0)
2026-02-07 07:23:01 +03:00
Fringg 8b924df64f chore: bump version to 3.6.0 in Dockerfile and workflows 2026-02-07 07:15:15 +03:00
Egor 7102c50f52 Merge pull request #2554 from BEDOLAGA-DEV/feat/node-usage-30day-cache
feat: return 30-day daily breakdown for node usage
2026-02-07 06:51:04 +03:00
Fringg e4c65ca220 feat: return 30-day daily breakdown for node usage
Always fetch 30 days with daily_bytes per node and categories.
Frontend computes period totals locally without extra API calls.
Removes days query param.
2026-02-07 06:50:47 +03:00
Egor 557dbf3ebe Merge pull request #2553 from BEDOLAGA-DEV/fix/parse-bandwidth-series
fix: parse bandwidth stats series format for node usage
2026-02-07 06:42:08 +03:00
Fringg 462f7a99b9 fix: parse bandwidth stats series format for node usage
Response is {categories, series: [{uuid, name, countryCode, total}]}.
Parse series array instead of treating dict keys as node UUIDs.
2026-02-07 06:42:03 +03:00
Egor c68c4e5984 Merge pull request #2552 from BEDOLAGA-DEV/fix/node-usage-single-api-call
fix: reduce node usage to 2 API calls to avoid 429 rate limit
2026-02-07 06:37:18 +03:00
Fringg f00a051bb3 fix: reduce node usage to 2 API calls to avoid 429 rate limit
Per-node queries (8+ calls) hit Remnawave rate limit. Switch back to
single get_bandwidth_stats_user call with %Y-%m-%d date format (same
as traffic_monitoring_service). Add response logging to debug format.
Also optimize panel-info to use accessible-nodes instead of all-nodes.
2026-02-07 06:36:38 +03:00
Egor b94e3edf80 Merge pull request #2551 from BEDOLAGA-DEV/fix/node-usage-per-node-query
fix: query per-node legacy endpoint for user traffic breakdown
2026-02-07 06:30:10 +03:00
Fringg 51ca3e42b7 fix: query per-node legacy endpoint for user traffic breakdown
The /api/bandwidth-stats/users/{uuid} endpoint rejects date params.
Switch to querying each accessible node via the working legacy
endpoint /api/bandwidth-stats/nodes/{uuid}/users/legacy and finding
the user in the per-node results.
2026-02-07 06:29:44 +03:00
Egor 943e9a86aa Merge pull request #2550 from BEDOLAGA-DEV/fix/node-usage-accessible-nodes
fix: use accessible nodes API and fix date format for node usage
2026-02-07 06:22:45 +03:00
Fringg c4da591731 fix: use accessible nodes API and fix date format for node usage
- Add get_user_accessible_nodes() to fetch user's available nodes
- Fix date format from ISO datetime to date-only (Y-m-d) for bandwidth stats
- Show all accessible nodes (with zero traffic if no stats)
- Add country_code to node usage response
2026-02-07 06:22:07 +03:00
Egor 287a43ba65 Merge pull request #2549 from BEDOLAGA-DEV/feature/admin-user-detail-enhanced
feat: add panel info, node usage endpoints and campaign to user detail
2026-02-07 06:09:13 +03:00
Fringg 070321230b feat: add panel info, node usage endpoints and campaign to user detail
- Add campaign_name/campaign_id to UserDetailResponse
- Add GET /admin/users/{user_id}/panel-info endpoint (config, links, traffic, connection)
- Add GET /admin/users/{user_id}/node-usage endpoint (per-node traffic breakdown)
- Add UserPanelInfoResponse, UserNodeUsageItem, UserNodeUsageResponse schemas
2026-02-07 06:07:10 +03:00
Egor 8886d0dea2 Merge pull request #2548 from BEDOLAGA-DEV/feat/user-tickets-tab
feat: add user_id filter to admin tickets endpoint
2026-02-07 05:22:20 +03:00
Fringg d3819c492f feat: add user_id filter to admin tickets endpoint
Allow filtering tickets by user_id query parameter in GET /admin/tickets.
2026-02-07 05:21:22 +03:00
Egor 3cbb9ef024 Merge pull request #2546 from BEDOLAGA-DEV/feature/oauth-authorization
feat: OAuth 2.0 authorization (Google, Yandex, Discord, VK)
2026-02-07 02:37:46 +03:00
Fringg 41633af763 refactor: fix transaction boundaries, extract _finalize_oauth_login, replace deprecated datetime.utcnow 2026-02-07 02:35:55 +03:00
DenyaBanan 916ad9d567 Fix 401 error
If there is a token, the bot checks it anyway, and cannot connect to the remnawave panel.
2026-02-07 03:33:16 +04:00
Fringg ccd9ab02c5 refactor: remove duplicated helpers, import from auth.py 2026-02-07 02:31:56 +03:00
Fringg d0a9cfe6a9 refactor: replace dataclass with BaseModel for OAuthUserInfo 2026-02-07 02:29:01 +03:00
Fringg 333a3c5901 fix: increase OAuth HTTP timeout to 30s 2026-02-07 02:23:02 +03:00
Fringg 0de6418bca refactor: add strict typing to OAuth providers, replace urlencode with httpx params 2026-02-07 02:14:37 +03:00
Fringg e9b98b837a feat: migrate OAuth state storage from in-memory to Redis 2026-02-07 02:08:02 +03:00
Fringg 97be4afbff feat: add OAuth 2.0 authorization (Google, Yandex, Discord, VK)
- Add OAuth provider config vars and helpers to config.py
- Add google_id, yandex_id, discord_id, vk_id columns to User model
- Create OAuth provider service with state management and 4 providers
- Add CRUD functions for OAuth user lookup, linking, and creation
- Add 3 API endpoints: providers list, authorize URL, callback
- Add alembic migration and universal_migration support
- Fix trial disable logic to cover OAuth auth_types
2026-02-07 01:58:55 +03:00
Egor 9ca24efe43 Merge pull request #2545 from BEDOLAGA-DEV/feature/disposable-email-blocking
feat: block registration with disposable email addresses
2026-02-07 00:36:37 +03:00
Fringg 116c8453bb feat: block registration with disposable email addresses
Add DisposableEmailService that fetches ~72k disposable email domains
from github.com/disposable/disposable-email-domains into an in-memory
frozenset with 24h auto-refresh via asyncio background task.

Integrated into three email entry points in cabinet auth routes:
- POST /email/register (link email to Telegram account)
- POST /email/register/standalone (standalone email registration)
- POST /email/change (change existing email)

Controlled by DISPOSABLE_EMAIL_CHECK_ENABLED setting (default: true).
Falls back to allowing all emails if domain list fetch fails.
2026-02-07 00:34:11 +03:00
Egor 4e7438b9f9 Merge pull request #2544 from BEDOLAGA-DEV/feature/trial-disabled-for-user-type
feat: disable trial by user type (email/telegram/all)
2026-02-07 00:20:38 +03:00
Fringg c4794db1dd feat: add TRIAL_DISABLED_FOR setting to disable trial by user type
New setting allows granular control over trial availability:
- none: trial available for all (default)
- email: trial disabled for email users
- telegram: trial disabled for telegram users
- all: trial disabled for everyone

Enforced in bot handlers, cabinet API, and miniapp routes.
Automatically appears in admin panel as dropdown via CHOICES.
2026-02-07 00:19:25 +03:00
Fringg 1ffb8a5b85 fix: pass tariff object instead of tariff_id to set_tariff_promo_groups 2026-02-07 00:01:55 +03:00
Egor 7ab1a7b88d Merge pull request #2543 from BEDOLAGA-DEV/dev
chore: sync dev → main (v3.5.0)
2026-02-06 23:57:09 +03:00
Fringg e3f932afe4 chore: bump version to 3.5.0 in Dockerfile and workflows 2026-02-06 23:55:36 +03:00
Egor 5ca2f62854 Merge pull request #2542 from BEDOLAGA-DEV/main
chore: sync main → dev
2026-02-06 23:48:19 +03:00
c0mrade 8afe613451 Merge pull request #2541 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.5.0
2026-02-06 23:44:40 +03:00
github-actions[bot] 8de9c6e532 chore(main): release 3.5.0 2026-02-06 20:42:00 +00:00
Egor b69fcbde11 Merge pull request #2540 from BEDOLAGA-DEV/dev
Release 3.4.1
2026-02-06 23:33:38 +03:00
Fringg 44d6b6b266 chore: bump version to 3.4.1 2026-02-06 23:31:46 +03:00
c0mrade 4234769e92 revert: remove signature pop from HMAC validation
Telegram includes signature in the hash computation, so removing it
from the data-check-string breaks HMAC validation for all users.
2026-02-06 22:27:57 +03:00
c0mrade c2cabbee09 fix: restore unquote for user data parsing in telegram auth
parse_qsl does not fully decode nested URL-encoded JSON in the user
field, so unquote() is still needed before json.loads().
2026-02-06 22:13:32 +03:00
c0mrade 067b1b6716 chore: remove unused unquote import 2026-02-06 21:55:45 +03:00
c0mrade 5b64046137 fix: exclude signature field from Telegram initData HMAC validation
Telegram Bot API 8.0+ adds a `signature` field to WebApp initData.
Per the official spec, both `hash` and `signature` must be excluded
from the data-check-string before HMAC verification. Without this,
users with newer Telegram clients get a hash mismatch and 401.

Also remove redundant `unquote()` in telegram_auth.py — `parse_qsl`
already URL-decodes values, so the extra decode could corrupt user
data containing percent-like sequences.
2026-02-06 21:51:38 +03:00
c0mrade 085a61721a Merge pull request #2538 from BEDOLAGA-DEV/feat/tariff-sorting-dnd
feat: tariff reorder API endpoint
2026-02-06 17:45:27 +03:00
Fringg 4c2e11e64b feat: add tariff reorder API endpoint
Add PUT /cabinet/admin/tariffs/order endpoint for drag-and-drop
tariff sorting in admin cabinet. Move db.commit() from CRUD to
route level for consistency.
2026-02-06 17:42:01 +03:00
c0mrade 7c5f35b1cf Merge pull request #2539 from BEDOLAGA-DEV/feat/remnawave-original-config-format
Feat/remnawave original config format
2026-02-06 17:35:13 +03:00
Egor 561708b777 Merge pull request #2537 from BEDOLAGA-DEV/fix/blacklist-middleware
fix: enforce blacklist via middleware
2026-02-06 15:54:01 +03:00
Fringg 806a959662 style: format blacklist middleware 2026-02-06 15:52:19 +03:00
Fringg 966a599c2c fix: enforce blacklist via middleware instead of per-handler checks
Add BlacklistMiddleware for aiogram that blocks all message/callback/pre_checkout
from blacklisted users globally. Add blacklist check to cabinet API dependency.
Fix case-insensitive username matching. Remove 10 redundant manual checks from handlers.
2026-02-06 15:48:21 +03:00
c0mrade 0ed98c39b6 fix: improve button URL resolution and pass uiConfig to frontend
- Add {{HAPP_CRYPT3_LINK}} template support in _resolve_button_url
- Only resolve templates for subscriptionLink and copyButton, not external
- Always send subscriptionUrl and subscriptionCryptoLink (hideLink is display-only flag)
- Pass uiConfig from RemnaWave config for block renderer selection
2026-02-05 20:08:47 +03:00
c0mrade 095bc00b33 feat: pass platform-level fields from RemnaWave config to frontend
Preserve svgIconKey, displayName and other platform-level fields
instead of only forwarding apps array. Build platformNames from
RemnaWave displayName with English-only fallback.
2026-02-05 14:27:46 +03:00
c0mrade 43762ce8f4 feat: serve original RemnaWave config from app-config endpoint
- Return original blocks/svgLibrary instead of converting to steps
- Enrich apps with deepLink and buttons with resolvedUrl
- Add _resolve_button_url helper for template substitution
- Keep legacy file-based format as fallback
2026-02-05 08:29:57 +03:00
Egor 51752713b3 Merge pull request #2536 from BEDOLAGA-DEV/dev
Release v3.4.0
2026-02-05 07:49:30 +03:00
Fringg b6fc63e33c chore: bump version to 3.4.0
Update version references across all files:
- pyproject.toml
- Dockerfile
- docker-hub.yml
- docker-registry.yml
- .release-please-manifest.json
2026-02-05 07:49:02 +03:00
Egor 488d5c99f7 Merge pull request #2535 from BEDOLAGA-DEV/feat/release-workflows
feat(ci): add release-please and release workflows
2026-02-05 07:42:50 +03:00
Fringg 9151882245 feat(ci): add release-please and release workflows
- Add release-please workflow for automated changelog and version bumps
- Add release workflow with categorized changelog (features, fixes, perf)
- Include contributors section and diff stats in release notes
- Add Docker pull instructions in release body
- Configure changelog sections for conventional commits
2026-02-05 07:37:46 +03:00
Egor 02eca28bc0 Merge pull request #2534 from BEDOLAGA-DEV/feat/version-notification-redesign
feat(notifications): redesign version update notification
2026-02-05 07:32:46 +03:00
Fringg 3f7ca7be3a feat(notifications): redesign version update notification
- Add GitHub Markdown to Telegram HTML converter utility
- Place release description in blockquote expandable
- Auto-truncate description to fit 4096 char message limit
- Clean compact layout with clickable version link
- Convert markdown headers, bold, italic, code, links, strikethrough
2026-02-05 07:29:55 +03:00
Egor f7abe03dba Merge pull request #2533 from BEDOLAGA-DEV/fix/autopay-notification-cooldown
fix(autopay): add 6h cooldown for insufficient balance notifications
2026-02-05 07:18:51 +03:00
Fringg 992a5cb97f fix(autopay): add 6h cooldown for insufficient balance notifications
- Use Redis key with 6h TTL to prevent notification spam on each monitoring cycle
- Fallback to sending notification if Redis is unavailable
- Key auto-expires when user tops up balance and autopay succeeds
2026-02-05 07:17:25 +03:00
Egor 3d94e63c3c Merge pull request #2532 from BEDOLAGA-DEV/fix/daily-tariff-autopay
fix(autopay): exclude daily subscriptions from global autopay
2026-02-05 07:12:03 +03:00
Egor 79569510d2 Merge pull request #2531 from BEDOLAGA-DEV/fix/broadcast-stability
fix(broadcast): stabilize mass broadcast for 100k+ users
2026-02-05 07:12:01 +03:00
Fringg b9352a5bd5 fix(autopay): exclude daily subscriptions from global autopay
- Skip daily tariff subscriptions in monitoring autopay cycle
- Filter daily subscriptions in get_subscriptions_for_autopay CRUD
- Block autopay menu and toggle for daily tariffs in bot handler
- Reject autopay enable for daily subscriptions in Cabinet API (HTTP 400)
- Reject autopay enable for daily subscriptions in MiniApp API (HTTP 400)
2026-02-05 07:10:52 +03:00
Fringg 13ebfdb5c4 fix(broadcast): stabilize mass broadcast for 100k+ users
- Add real-time progress bar with updates every 500 msgs / 5 sec
- Fix Telegram rate limiting: batch=25, delay=1.0s (~25 msg/sec)
- Add global flood_wait_until to prevent semaphore slot starvation
- Add parse_mode=HTML for web API broadcasts
- Separate error handling for FloodWait, Forbidden, BadRequest
- Convert ORM objects to scalars before long broadcast operations
- Add email recipients dataclass to prevent detached ORM state
2026-02-05 07:10:43 +03:00
Egor e8a413c3c3 Merge pull request #2530 from BEDOLAGA-DEV/fix/cabinet-promo-discounts
fix(cabinet): apply promo group discounts to addons and tariff switch
2026-02-05 06:30:00 +03:00
Fringg aa1d3289e1 fix(cabinet): apply promo group discounts to device/traffic purchase and tariff switch
- Add discount calculation for purchase_devices and get_device_price endpoints
- Fix traffic purchase discount to use period-aware calculation
- Apply period discount to tariff switch upgrade_cost
- Return discount info in API responses for frontend display
2026-02-05 06:26:17 +03:00
Egor 94a00ab269 Merge pull request #2529 from BEDOLAGA-DEV/fix/sqlalchemy-connection-closed
fix(broadcast): resolve SQLAlchemy connection closed errors
2026-02-05 05:49:11 +03:00
Fringg b8682adbbf fix(broadcast): resolve SQLAlchemy connection closed errors during long broadcasts
- Extract scalar values from ORM objects before long operations
- Create fresh DB sessions for persist operations with retry mechanism
- Replace ORM User objects with telegram_id integers in broadcast loops
- Update .gitignore to exclude Python cache, IDE files, and local configs

Fixes: InterfaceError "connection is closed" and MissingGreenlet errors
during mass message broadcasts
2026-02-05 05:42:31 +03:00
Egor cf10eeda53 Merge pull request #2528 from BEDOLAGA-DEV/main
Update docker-registry.yml
2026-02-05 05:04:32 +03:00
Egor 3cfac7e2dc Update docker-registry.yml 2026-02-05 05:04:00 +03:00
Egor 39e111c91b Merge pull request #2527 from BEDOLAGA-DEV/main
W
2026-02-05 04:55:04 +03:00
Egor ba42517808 Update README.md 2026-02-05 00:25:48 +03:00
Egor 13846d621a Update README.md 2026-02-05 00:25:07 +03:00
Egor e612e2f383 Merge pull request #2526 from BEDOLAGA-DEV/dev
Dev
2026-02-04 05:02:03 +03:00
Egor 1f524ccd80 Add files via upload 2026-02-04 04:51:35 +03:00
Egor 37bde2985e Add files via upload 2026-02-04 04:51:12 +03:00
Egor c6a5e0d4be Add files via upload 2026-02-04 04:50:38 +03:00
Egor 3985053636 Add files via upload 2026-02-04 04:49:37 +03:00
Egor 4cfb1dd38f Add files via upload 2026-02-04 04:49:14 +03:00
Egor 117a417ce0 Add files via upload 2026-02-04 04:48:49 +03:00
Egor a2e0474572 Add files via upload 2026-02-04 04:48:29 +03:00
Egor e992891691 Add files via upload 2026-02-04 04:48:01 +03:00
Egor 57cf8687d4 Add files via upload 2026-02-04 04:47:39 +03:00
Egor 27870bbdcb Update main.py 2026-02-04 04:47:24 +03:00
Egor 21d48078ed Merge pull request #2525 from BEDOLAGA-DEV/dev
Update admin_notification_service.py
2026-02-04 03:58:14 +03:00
Egor afb4f162d0 Update admin_notification_service.py 2026-02-04 03:55:59 +03:00
Egor 96d479780f Merge pull request #2524 from BEDOLAGA-DEV/dev
Update admin_promo_offers.py
2026-02-04 03:23:53 +03:00
Egor 2e0cd5d54c Update admin_promo_offers.py 2026-02-04 03:23:35 +03:00
Egor c4374ce483 Merge pull request #2523 from BEDOLAGA-DEV/dev
Dev
2026-02-04 03:05:46 +03:00
Egor bd1a0d4a4e Update wata.py 2026-02-04 03:05:22 +03:00
Egor 97f4cc0f7c Update subscription.py 2026-02-04 02:57:41 +03:00
Egor 3ebbb42096 Update inline.py 2026-02-04 02:57:12 +03:00
Egor 07ae7c2a7f Update traffic.py 2026-02-04 02:56:47 +03:00
Egor 5c3505aec9 Merge pull request #2522 from BEDOLAGA-DEV/dev
Update menu.py
2026-02-04 02:16:23 +03:00
Egor fffa231b7e Update menu.py 2026-02-04 02:15:45 +03:00
Egor f8db099d0f Merge pull request #2521 from BEDOLAGA-DEV/dev
Dev
2026-02-04 02:10:58 +03:00
Egor 9483517258 Add files via upload 2026-02-04 02:08:18 +03:00
Egor 0c0ab58236 Update promocode.py 2026-02-04 02:07:27 +03:00
Egor 92ec1219fa Add files via upload 2026-02-04 02:06:47 +03:00
Egor bf72e81d55 Update promocode_service.py 2026-02-04 02:06:13 +03:00
Egor 5a008e59a2 Merge pull request #2520 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-03 23:57:22 +03:00
Egor 61b8d586d3 Update subscription.py 2026-02-03 23:56:55 +03:00
Egor df62e2bd96 Merge pull request #2519 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-03 23:45:08 +03:00
Egor 07e8990ddb Update subscription.py 2026-02-03 23:44:15 +03:00
Egor 40fc4d7267 Merge pull request #2518 from BEDOLAGA-DEV/dev
Dev
2026-02-03 04:38:57 +03:00
Egor 5ffd3093ea Update users.py 2026-02-03 04:38:01 +03:00
Egor 01ac2c7ed0 Update admin_users.py 2026-02-03 04:37:25 +03:00
Egor 937a25aafd Update pyproject.toml 2026-02-03 04:29:27 +03:00
Egor aeee018a53 Merge pull request #2517 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-03 04:14:40 +03:00
Egor 5cef11f32b Update subscription.py 2026-02-03 04:14:15 +03:00
Egor 467f67907f Merge pull request #2516 from BEDOLAGA-DEV/dev
Dev
2026-02-03 03:58:59 +03:00
Egor 6d38531f42 Add files via upload 2026-02-03 03:58:31 +03:00
Egor 2dd057a911 Update subscription.py 2026-02-03 03:56:27 +03:00
Egor 4178e4b024 Add files via upload 2026-02-03 03:53:18 +03:00
Egor 21bcde26e5 Add files via upload 2026-02-03 03:52:56 +03:00
Egor 878606d745 Add files via upload 2026-02-03 03:52:29 +03:00
Egor 45a90876da Add files via upload 2026-02-03 03:52:08 +03:00
Egor bb4f496b21 Add files via upload 2026-02-03 03:50:33 +03:00
Egor 47c1de1cc8 Add files via upload 2026-02-03 03:50:05 +03:00
Egor f69156d7ee Update subscription.py 2026-02-03 03:43:51 +03:00
Egor c9d559e3f2 Update subscription.py 2026-02-03 03:41:18 +03:00
Egor e28a48853d Add files via upload 2026-02-03 03:40:08 +03:00
Egor b13da1f2e8 Add files via upload 2026-02-03 03:39:45 +03:00
Egor ba1bf677d6 Add files via upload 2026-02-03 03:39:21 +03:00
Egor ccdff05dca Add files via upload 2026-02-03 03:38:51 +03:00
Egor 03875b593e Add files via upload 2026-02-03 03:38:14 +03:00
Egor 06224d798d Add files via upload 2026-02-03 03:37:43 +03:00
Egor 966f436723 Merge pull request #2514 from BEDOLAGA-DEV/dev
Update cloudpayments.py
2026-02-03 03:32:10 +03:00
Egor 4941fe9469 Update cloudpayments.py 2026-02-03 03:31:48 +03:00
Egor fe56078481 Merge pull request #2513 from BEDOLAGA-DEV/dev
Update cloudpayments.py
2026-02-03 03:28:13 +03:00
Egor 39742499b8 Update cloudpayments.py 2026-02-03 03:27:53 +03:00
Egor d8207fa1f0 Merge pull request #2512 from BEDOLAGA-DEV/dev
Update cloudpayments.py
2026-02-03 03:23:48 +03:00
Egor 7eb302aab0 Update cloudpayments.py 2026-02-03 03:23:23 +03:00
Egor 8cb5da4f74 Merge pull request #2511 from BEDOLAGA-DEV/dev
Dev
2026-02-03 03:17:18 +03:00
Egor cad786f6ee Add files via upload 2026-02-03 03:15:34 +03:00
Egor 0adc7145a1 Update user_service.py 2026-02-03 03:14:39 +03:00
Egor d35f54a4db Merge pull request #2510 from BEDOLAGA-DEV/main
w
2026-02-03 03:14:01 +03:00
c0mrade 000d670869 Merge pull request #2506 from BEDOLAGA-DEV/fix/ticket-settings-route-order
fix: move /settings routes before /{ticket_id} to fix route matching
2026-02-02 09:01:30 +03:00
c0mrade 0c9b69deb0 fix: move /settings routes before /{ticket_id} to fix route matching
Static routes must be defined before dynamic routes in FastAPI.
Previously /settings was matched as ticket_id parameter causing parsing error.
2026-02-02 08:58:07 +03:00
Egor 63e31e84fc Merge pull request #2505 from BEDOLAGA-DEV/dev
Dev
2026-02-02 05:27:02 +03:00
Egor 1bd301f21a Add files via upload 2026-02-02 05:26:41 +03:00
Egor 3dbaf99733 Update inline.py 2026-02-02 05:26:09 +03:00
Egor 4380611bee Merge pull request #2504 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-02 05:18:52 +03:00
Egor 5aca466d72 Update subscription.py 2026-02-02 05:18:11 +03:00
Egor 86970f0398 Update subscription.py 2026-02-02 05:16:58 +03:00
c0mrade 733be09658 Merge pull request #2503 from BEDOLAGA-DEV/fix/promo-groups-async
fix: add refresh before assigning promo_groups to avoid async lazy lo…
2026-02-02 04:58:00 +03:00
c0mrade 5e75210c8b fix: add refresh before assigning promo_groups to avoid async lazy load error 2026-02-02 04:55:27 +03:00
Egor cb0ccc9c77 Merge pull request #2502 from BEDOLAGA-DEV/dev
Dev
2026-02-02 03:37:47 +03:00
Egor 078eebfbb1 Update tariffs.py 2026-02-02 03:37:21 +03:00
Egor 4049e0d9ff Update decorators.py 2026-02-02 03:36:38 +03:00
Egor 7bf7b942ba Merge pull request #2501 from BEDOLAGA-DEV/dev
Update blocked_users_service.py
2026-02-02 03:19:08 +03:00
Egor 4f1c14fda0 Update blocked_users_service.py 2026-02-02 03:18:46 +03:00
Egor 7bd6ae3c26 Merge pull request #2500 from BEDOLAGA-DEV/dev
Update blocked_users_service.py
2026-02-02 03:10:50 +03:00
Egor caeafc7abd Update blocked_users_service.py 2026-02-02 03:10:27 +03:00
Egor 4d249a3bc9 Merge pull request #2499 from BEDOLAGA-DEV/dev
Update blocked_users.py
2026-02-02 03:02:20 +03:00
Egor 1f26b522b4 Update blocked_users.py 2026-02-02 03:01:56 +03:00
Egor d1ed6c1b18 Merge pull request #2498 from BEDOLAGA-DEV/dev
Dev
2026-02-02 02:59:40 +03:00
Egor d4d89ec20d Update blocked_users.py 2026-02-02 02:59:17 +03:00
Egor 6a2fd5a7de Add files via upload 2026-02-02 02:58:22 +03:00
Egor f41c5a10b6 Update blocked_users.py 2026-02-02 02:57:29 +03:00
Egor 3e873a05a4 Update blocked_users_service.py 2026-02-02 02:56:58 +03:00
Egor c22f41cbf2 Merge pull request #2497 from BEDOLAGA-DEV/dev
Update blocked_users_service.py
2026-02-02 02:53:39 +03:00
Egor f3851b9ecc Update blocked_users_service.py 2026-02-02 02:53:10 +03:00
Egor 9a258744f2 Merge pull request #2496 from BEDOLAGA-DEV/dev
Dev
2026-02-02 02:50:14 +03:00
Egor 8780ae9407 Update blocked_users_service.py 2026-02-02 02:49:23 +03:00
Egor 3f886df44f Update blocked_users.py 2026-02-02 02:48:57 +03:00
Egor e3c6d4a5a1 Add files via upload 2026-02-02 02:47:49 +03:00
Egor c3215a1e54 Update bot.py 2026-02-02 02:47:00 +03:00
Egor fdd5a8aa6e Update admin.py 2026-02-02 02:46:39 +03:00
Egor c1792f487a Add files via upload 2026-02-02 02:46:03 +03:00
Egor f27f0f8bca Merge pull request #2495 from BEDOLAGA-DEV/dev
Update startup_notification_service.py
2026-02-02 02:21:02 +03:00
Egor c70ddfe157 Update startup_notification_service.py 2026-02-02 02:20:41 +03:00
Egor 554d776b77 Merge pull request #2494 from BEDOLAGA-DEV/dev
Dev
2026-02-02 02:08:12 +03:00
Egor 34ac9eb6ed Update global_error.py 2026-02-02 02:05:50 +03:00
Egor 6cc48d2872 Update startup_notification_service.py 2026-02-02 02:05:28 +03:00
Egor 9fc31c25b2 Update startup_notification_service.py 2026-02-02 02:00:01 +03:00
Egor 73585ebc82 Update global_error.py 2026-02-02 01:59:25 +03:00
Egor e9d3e9a2be Merge pull request #2493 from BEDOLAGA-DEV/dev
Update global_error.py
2026-02-02 01:43:28 +03:00
Egor 3e6e2f577c Update global_error.py 2026-02-02 01:43:05 +03:00
Egor 39c644b505 Merge pull request #2492 from BEDOLAGA-DEV/dev
Dev
2026-02-02 01:36:39 +03:00
Egor 62500a6369 Update startup_notification_service.py 2026-02-02 01:34:40 +03:00
Egor 9e16b56d9a Update global_error.py 2026-02-02 01:34:06 +03:00
Egor 9dfadcda72 Merge pull request #2491 from BEDOLAGA-DEV/dev
Update global_error.py
2026-02-02 01:19:08 +03:00
Egor e2afdc28f3 Update global_error.py 2026-02-02 01:18:28 +03:00
Egor 86bd24edd4 Merge pull request #2490 from BEDOLAGA-DEV/dev
Dev
2026-02-02 01:07:29 +03:00
Egor 2bda556c4b Update main.py 2026-02-02 01:07:06 +03:00
Egor 60724a0354 Update startup_notification_service.py 2026-02-02 01:05:30 +03:00
Egor 250edec20e Merge pull request #2489 from BEDOLAGA-DEV/dev
Dev
2026-02-02 00:56:21 +03:00
Egor 6b1e78f990 Update maintenance_service.py 2026-02-02 00:55:56 +03:00
Egor 56f784c8bf Update maintenance_service.py 2026-02-02 00:54:10 +03:00
Egor ce822ead2b Update maintenance_service.py 2026-02-02 00:51:13 +03:00
Egor e606c1d4d5 Update startup_notification_service.py 2026-02-02 00:50:51 +03:00
Egor c204194b8b Update main.py 2026-02-02 00:50:01 +03:00
Egor 5a878239f3 Add files via upload 2026-02-02 00:49:29 +03:00
Egor 08aa8dabfd Merge pull request #2488 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-02 00:34:18 +03:00
Egor 20ed6071e2 Update subscription.py 2026-02-02 00:33:59 +03:00
Egor 8d34a4b3d2 Merge pull request #2487 from BEDOLAGA-DEV/dev
Dev
2026-02-02 00:28:21 +03:00
Egor 94a9528397 Update subscription.py 2026-02-02 00:27:51 +03:00
Egor 403052a840 Update tariff_purchase.py 2026-02-02 00:27:21 +03:00
Egor bb8e5bb6ca Update admin_notification_service.py 2026-02-02 00:26:53 +03:00
Egor 11ee8764a6 Merge pull request #2486 from BEDOLAGA-DEV/dev
Update admin_notification_service.py
2026-02-02 00:18:02 +03:00
Egor f9be0e6315 Update admin_notification_service.py 2026-02-02 00:17:20 +03:00
Egor 9e56c56528 Update admin_notification_service.py 2026-02-02 00:11:28 +03:00
Egor 85cf96b813 Merge pull request #2485 from BEDOLAGA-DEV/dev
Dev
2026-02-01 18:34:17 +03:00
Egor c16eee4ef2 Add files via upload 2026-02-01 18:33:12 +03:00
Egor b6bd2625c2 Update subscription.py 2026-02-01 18:32:29 +03:00
Egor 020343cdf8 Merge pull request #2484 from BEDOLAGA-DEV/dev
Dev
2026-02-01 18:11:23 +03:00
Egor c07fffd809 Update tariff_purchase.py 2026-02-01 18:10:59 +03:00
Egor afaeeaf7f1 Update subscription.py 2026-02-01 18:10:22 +03:00
Egor e946cc7354 Merge pull request #2483 from BEDOLAGA-DEV/dev
Dev
2026-02-01 17:28:42 +03:00
Egor bf6a966668 Update subscription.py 2026-02-01 17:28:21 +03:00
Egor 7f12fa7003 Update devices.py 2026-02-01 17:27:44 +03:00
Egor 5f6ef5993c Merge pull request #2482 from BEDOLAGA-DEV/dev
Dev
2026-02-01 17:22:07 +03:00
Egor f1ac67e511 Update devices.py 2026-02-01 17:18:00 +03:00
Egor 48f9f606aa Update subscription.py 2026-02-01 17:17:02 +03:00
Egor 7d9d1b0a6f Update inline.py 2026-02-01 17:16:28 +03:00
Egor afadf7160c Merge pull request #2481 from BEDOLAGA-DEV/main
w
2026-02-01 16:50:38 +03:00
Egor b546fbd1cc Merge pull request #2452 from Gy9vin/main
реф система и другое
2026-02-01 16:45:33 +03:00
Egor b8f1785783 Delete migrations/.DS_Store 2026-02-01 16:44:15 +03:00
gy9vin 7ee8c8ff4d Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2026-02-01 14:41:56 +03:00
gy9vin 551112d2d9 make fix 2026-02-01 14:41:45 +03:00
Mikhail e9dc9630b2 Merge branch 'BEDOLAGA-DEV:main' into main 2026-02-01 14:38:39 +03:00
Egor b068c1fb12 Merge pull request #2480 from BEDOLAGA-DEV/dev
Dev
2026-02-01 12:34:47 +03:00
Egor 9f66e176f7 Add files via upload 2026-02-01 12:34:24 +03:00
Egor c66bad99f0 Update devices.py 2026-02-01 12:33:57 +03:00
Egor f6a29760a9 Merge pull request #2479 from BEDOLAGA-DEV/dev
Dev
2026-02-01 11:29:14 +03:00
Egor d7e1b8fd5d Update user.py 2026-02-01 11:28:36 +03:00
Egor 54e9175bdf Update Dockerfile 2026-02-01 11:25:42 +03:00
gy9vin f581b10e19 fix 2026-02-01 11:23:42 +03:00
gy9vin 1ae6ea18b7 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2026-02-01 11:18:59 +03:00
gy9vin bea6c02d89 kassa ai 2026-02-01 11:18:54 +03:00
Mikhail 153083d791 Merge branch 'main' into main 2026-02-01 11:11:23 +03:00
Egor 80538b39ac Update Dockerfile 2026-02-01 01:10:51 +03:00
Egor 205f9e8e3c Update docker-registry.yml 2026-02-01 01:10:17 +03:00
Egor 97ccfd3af0 Update docker-hub.yml 2026-02-01 01:10:03 +03:00
Egor 6288d3fb7f Merge pull request #2478 from BEDOLAGA-DEV/dev
Dev
2026-02-01 00:54:44 +03:00
Egor d611eecece Update user.py 2026-02-01 00:54:17 +03:00
Egor 779cccffe6 Update tribute.py 2026-02-01 00:53:50 +03:00
Egor 04144dc7a5 Merge pull request #2477 from BEDOLAGA-DEV/dev
Update subscription.py
2026-01-31 21:28:51 +03:00
Egor b7bfdbb485 Update subscription.py 2026-01-31 21:28:35 +03:00
Egor 21b7c039b7 Merge pull request #2476 from BEDOLAGA-DEV/dev
Dev
2026-01-31 21:15:22 +03:00
Egor dc7d2eb02a Add files via upload 2026-01-31 21:13:54 +03:00
Egor 6c26d5c7f8 Update texts.py 2026-01-31 21:13:04 +03:00
Egor 06e99d5a43 Merge pull request #2475 from BEDOLAGA-DEV/dev
Dev
2026-01-31 20:49:48 +03:00
Egor a7712c7151 Add files via upload 2026-01-31 20:46:25 +03:00
Egor 418d329b75 Update subscription_auto_purchase_service.py 2026-01-31 20:45:55 +03:00
Egor a4d5b8067c Merge pull request #2474 from BEDOLAGA-DEV/dev
Update subscription.py
2026-01-31 20:34:27 +03:00
Egor f61ee0f64a Update subscription.py 2026-01-31 20:33:39 +03:00
Egor 841b1e4c52 Merge pull request #2473 from BEDOLAGA-DEV/dev
Dev
2026-01-31 20:16:36 +03:00
Egor 927ec91e0e Update admin_users.py 2026-01-31 20:12:58 +03:00
Egor 9cc2a285dc Update user.py 2026-01-31 20:12:25 +03:00
Egor f11173d5aa Merge pull request #2472 from BEDOLAGA-DEV/dev
Dev
2026-01-31 19:52:58 +03:00
Egor 7bd5d13cc8 Update websocket.py 2026-01-31 19:50:47 +03:00
Egor c30e22e1b1 Update subscription_auto_purchase_service.py 2026-01-31 19:50:03 +03:00
Egor ffff91466e Merge pull request #2471 from BEDOLAGA-DEV/dev
Dev
2026-01-31 19:42:07 +03:00
Egor a7669d0f35 Update devices.py 2026-01-31 19:41:16 +03:00
Egor 1371f21d17 Update subscription_auto_purchase_service.py 2026-01-31 19:40:18 +03:00
Egor ec553d3334 Update subscription.py 2026-01-31 19:39:28 +03:00
Egor 638644d1e9 Update subscription.py 2026-01-31 19:02:30 +03:00
Egor 8ae6ef5cb8 Merge pull request #2470 from BEDOLAGA-DEV/dev
Update universal_migration.py
2026-01-31 18:13:59 +03:00
Egor 1092301767 Update universal_migration.py 2026-01-31 18:13:42 +03:00
Egor 365f75447f Update universal_migration.py 2026-01-31 18:12:53 +03:00
Egor 2e3dbaa18c Merge pull request #2469 from BEDOLAGA-DEV/dev
Dev
2026-01-31 17:50:21 +03:00
Egor 68df186f72 Update admin_broadcasts.py 2026-01-31 17:50:00 +03:00
Egor 8d3bedefb0 Update broadcast_service.py 2026-01-31 17:49:30 +03:00
Egor 701a4d51de Update universal_migration.py 2026-01-31 17:49:02 +03:00
Egor fbb45c10c1 Update main.py 2026-01-31 17:47:43 +03:00
Egor e4f182ffc6 Update broadcasts.py 2026-01-31 17:47:00 +03:00
Egor fa94042284 Update admin_broadcasts.py 2026-01-31 17:46:37 +03:00
Egor b258715cc1 Update broadcast_service.py 2026-01-31 17:45:59 +03:00
Egor 4e9f01d439 Add files via upload 2026-01-31 17:45:19 +03:00
Egor 5dad41953a Merge pull request #2468 from BEDOLAGA-DEV/dev
Update channel_checker.py
2026-01-31 17:10:40 +03:00
Egor dd9b33af83 Update channel_checker.py 2026-01-31 17:10:04 +03:00
Egor fbf2325a42 Merge pull request #2467 from BEDOLAGA-DEV/dev
Dev
2026-01-31 17:06:05 +03:00
Egor 11eb27437b Update devices.py 2026-01-31 17:05:35 +03:00
Egor 1bb5ef85aa Update inline.py 2026-01-31 17:04:59 +03:00
Egor 35c5d78963 Merge pull request #2466 from BEDOLAGA-DEV/dev
Dev
2026-01-31 16:58:57 +03:00
Egor 38ff15e794 Update subscription.py 2026-01-31 16:58:32 +03:00
Egor 2992dfbada Update subscription_service.py 2026-01-31 16:58:01 +03:00
Egor a1e5a71ad3 Merge pull request #2465 from BEDOLAGA-DEV/dev
Update subscription_auto_purchase_service.py
2026-01-31 15:22:36 +03:00
Egor 28dbe3dca7 Update subscription_auto_purchase_service.py 2026-01-31 15:22:11 +03:00
gy9vin 4f77ece187 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2026-01-30 23:43:29 +03:00
gy9vin 56a69fa1ba правки 2026-01-30 23:43:26 +03:00
Mikhail 5c94bda60a Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-30 23:41:34 +03:00
gy9vin b8d0e6eefb Новый фильтр и кричиеский баг
Теперь при подписке на канал:
  -  Обычные пользователи — подписка реактивируется
  - 🚫 Заблокированные — пропуск с логом, подписка НЕ активируется
2026-01-30 23:40:46 +03:00
Egor 49b48164fd Merge pull request #2464 from BEDOLAGA-DEV/dev
Update monitoring_service.py
2026-01-30 23:02:46 +03:00
Egor f688c74aee Update monitoring_service.py 2026-01-30 23:02:00 +03:00
Egor fe42548dd7 Merge pull request #2462 from BEDOLAGA-DEV/dev
Dev
2026-01-30 21:05:05 +03:00
Egor 7000cd5bc2 Update balance.py 2026-01-30 21:04:46 +03:00
Egor e3901c8d39 Add files via upload 2026-01-30 21:04:10 +03:00
Egor 23f72dc4ec Merge pull request #2461 from BEDOLAGA-DEV/dev
Dev
2026-01-30 20:44:07 +03:00
Egor 55d817bcad Update user_service.py 2026-01-30 20:43:45 +03:00
Egor 8a9994e539 Update user_service.py 2026-01-30 20:41:42 +03:00
Egor f050a62bc6 Merge pull request #2460 from BEDOLAGA-DEV/main
w
2026-01-30 20:40:52 +03:00
c0mrade e26464ddad Merge pull request #2459 from BEDOLAGA-DEV/feat/websocket-subscription-balance-notifications
feat/websocket subscription balance notifications
2026-01-30 20:12:45 +03:00
c0mrade 3263606702 fix: resolve circular import with lazy websocket imports
Move websocket notification imports inside functions to avoid
circular dependency when module is loaded.
2026-01-30 19:17:25 +03:00
c0mrade 86350424d5 feat(websocket): add real-time notifications for subscription and balance events
- Import and call notify_user_subscription_renewed in auto-extend flows
- Import and call notify_user_subscription_activated for new subscriptions
- Add WebSocket notifications to _auto_purchase_tariff and _auto_purchase_daily_tariff
- Add WebSocket notifications to auto_activate_subscription_after_topup
- Add notify_user_balance_topup call in payment common mixin
2026-01-30 19:04:44 +03:00
Egor d2ade1d9ed Merge pull request #2457 from BEDOLAGA-DEV/dev
Update subscription.py
2026-01-30 18:17:05 +03:00
Egor cc47cea268 Update subscription.py 2026-01-30 18:16:49 +03:00
Egor 6f420264f8 Merge pull request #2456 from BEDOLAGA-DEV/dev
Update user_cart_service.py
2026-01-30 17:47:39 +03:00
Egor 5949460572 Update user_cart_service.py 2026-01-30 17:46:55 +03:00
Egor 4330775d01 Merge pull request #2454 from BEDOLAGA-DEV/dev
Dev
2026-01-30 16:58:59 +03:00
Egor fa5c217dd0 Update heleket.py 2026-01-30 16:58:40 +03:00
Egor aa270c9ab4 Update subscription_auto_purchase_service.py 2026-01-30 16:58:12 +03:00
Mikhail b7af25644a Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-30 16:40:25 +03:00
Egor 8b742082a4 Merge pull request #2453 from BEDOLAGA-DEV/dev
Dev
2026-01-30 16:09:42 +03:00
Egor 8078a4e64d Update remnawave_service.py 2026-01-30 16:09:13 +03:00
Egor 79f2cc0da5 Update .env.example 2026-01-30 16:07:47 +03:00
Egor 0c769ac16d Update config.py 2026-01-30 16:06:28 +03:00
Egor 25aba75413 Update user.py 2026-01-30 16:06:06 +03:00
Egor 9afe370a98 Add files via upload 2026-01-30 16:05:36 +03:00
Egor 6202801793 Update auth.py 2026-01-30 16:03:34 +03:00
Egor 4d48418b2c Update auth.py 2026-01-30 15:59:26 +03:00
Egor a21dfa75f0 Update email_service.py 2026-01-30 15:59:00 +03:00
Egor e09d9b6607 Update email_verification.py 2026-01-30 15:58:12 +03:00
Mikhail e312767247 Merge branch 'main' into main 2026-01-30 09:36:50 +03:00
gy9vin 1dfa243736 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2026-01-30 09:35:23 +03:00
gy9vin e0d667df28 fix реф системы! фишки конкурсной систем! проверка логов по рефералам и начисления бонусов 2026-01-30 09:35:17 +03:00
Egor 68a734051d Merge pull request #2451 from BEDOLAGA-DEV/dev
Update remnawave_service.py
2026-01-29 02:03:15 +03:00
Egor 3f43371e60 Update remnawave_service.py 2026-01-29 02:02:59 +03:00
Egor cb8d233a16 Merge pull request #2450 from BEDOLAGA-DEV/dev
Dev
2026-01-29 01:29:57 +03:00
Egor d95aa7ca5c Rename SubscriptionResponse to SubscriptionData 2026-01-29 01:03:01 +03:00
Egor f7fc7d5cb0 Refactor subscription endpoint to return SubscriptionStatusResponse 2026-01-29 01:02:40 +03:00
Egor 5d6d3b962b Update remnawave_service.py 2026-01-29 01:02:18 +03:00
Egor 6bf8f85c80 Merge pull request #2449 from BEDOLAGA-DEV/dev
Improve payload management and subscription validation
2026-01-29 00:09:25 +03:00
Egor 0a254b1903 Improve payload management and subscription validation
Refactor payload handling and user subscription check logic.
2026-01-29 00:07:52 +03:00
Egor 9f33331618 Merge pull request #2448 from BEDOLAGA-DEV/dev
Dev
2026-01-28 20:17:18 +03:00
Egor 595ecff396 Update payment_method_config_service.py 2026-01-28 20:16:58 +03:00
Egor ad96e8951d Merge pull request #2447 from BEDOLAGA-DEV/main
ц
2026-01-28 20:16:17 +03:00
Mikhail 385e1b4287 Merge pull request #2446 from Gy9vin/main
fix
2026-01-28 13:55:37 +03:00
Mikhail 6551ac1fe9 Merge branch 'main' into main 2026-01-28 13:55:24 +03:00
Egor 327ba81d25 Merge pull request #2445 from BEDOLAGA-DEV/dev
Update remnawave_service.py
2026-01-28 12:48:51 +03:00
Egor bf65e16d4d Update remnawave_service.py 2026-01-28 12:48:29 +03:00
Egor 9cf24deb93 Update remnawave_service.py 2026-01-28 12:47:25 +03:00
Egor 3791f1db5a Merge pull request #2444 from BEDOLAGA-DEV/dev
Dev
2026-01-28 11:59:11 +03:00
Egor 0c3070a0cc Add Kassa AI payment method support 2026-01-28 11:56:02 +03:00
Egor a37ec7a308 Update payment_method_config_service.py 2026-01-28 11:55:29 +03:00
Egor b161a8604e Merge pull request #2443 from BEDOLAGA-DEV/dev
Dev
2026-01-28 11:46:47 +03:00
Egor 1e93f24f78 Add files via upload 2026-01-28 11:46:12 +03:00
Egor f8cd3076e9 Add files via upload 2026-01-28 11:43:22 +03:00
Egor e557504309 Implement panel_datetime_to_naive_utc function
Add function to convert panel datetime to naive UTC.
2026-01-28 11:42:45 +03:00
Egor 4602f72030 Update remnawave_service.py 2026-01-28 11:41:40 +03:00
Egor d811321808 Merge pull request #2442 from BEDOLAGA-DEV/dev
Dev
2026-01-28 11:07:08 +03:00
Egor dffb637e8d Update remnawave_service.py 2026-01-28 11:06:51 +03:00
Egor 32129bebc0 Merge pull request #2441 from BEDOLAGA-DEV/main
w
2026-01-28 11:06:01 +03:00
gy9vin dd5ee45ab5 Keep local customs over main 2026-01-27 23:51:07 +03:00
gy9vin 95b7152c05 касса и прочее 2026-01-27 23:47:39 +03:00
Egor aa093a074a Delete miniapp directory 2026-01-27 18:37:07 +03:00
Egor 2796cd0a1e Update Dockerfile 2026-01-27 18:28:13 +03:00
Egor 3d0497211a Update version number to 3.2.0 in workflow 2026-01-27 18:27:59 +03:00
Egor d489c3bbfd Update versioning scheme to 3.2.0 2026-01-27 18:27:29 +03:00
Egor c634456fb8 Merge pull request #2440 from BEDOLAGA-DEV/dev
Dev
2026-01-27 16:48:16 +03:00
Egor 4086824a11 Add logger initialization in email_template_overrides.py 2026-01-27 16:47:32 +03:00
Egor ea512ff153 Update test_kassa_ai_notifications.py 2026-01-27 16:47:02 +03:00
Egor c8d1254203 Fix string quotes in test_kassa_ai_notifications.py 2026-01-27 16:45:18 +03:00
Egor 66773261bc Simplify total_before_discount calculation
Refactor total_before_discount calculation for clarity.
2026-01-27 16:44:30 +03:00
Egor 4b908ce3cf Update messages.py 2026-01-27 16:43:51 +03:00
Egor 0131b39861 Update email_template_overrides.py 2026-01-27 16:43:11 +03:00
Egor 9217d1fefa Merge pull request #2439 from BEDOLAGA-DEV/main
ц
2026-01-27 16:41:05 +03:00
Egor 3eefbbf48f Merge pull request #2438 from BEDOLAGA-DEV/dev
Update messages.py
2026-01-27 16:40:46 +03:00
Egor 0bfe665b70 Add files via upload 2026-01-27 16:40:27 +03:00
Egor a85ac342ed Update messages.py 2026-01-27 16:38:13 +03:00
Egor a7f6008995 Merge pull request #2431 from Gy9vin/main
Окончалтельный фикс простой покупки!
2026-01-27 16:14:36 +03:00
Egor b07cbaabd5 Merge pull request #2437 from BEDOLAGA-DEV/dev
Update admin_stats.py
2026-01-27 16:14:19 +03:00
Egor affca76ec0 Update admin_stats.py 2026-01-27 16:13:14 +03:00
Egor ef3491fb22 Merge pull request #2436 from BEDOLAGA-DEV/dev
Update pricing_utils.py
2026-01-27 16:06:36 +03:00
Egor 67a75235fd Update pricing_utils.py 2026-01-27 16:06:13 +03:00
Egor 26922b942f Merge pull request #2435 from BEDOLAGA-DEV/dev
Update notification.py
2026-01-27 15:15:40 +03:00
Egor 866f89aea4 Update notification.py 2026-01-27 15:15:24 +03:00
Egor 75c4dea603 Merge pull request #2434 from BEDOLAGA-DEV/dev
Dev
2026-01-27 14:30:35 +03:00
Egor e85a2a58cf Update middleware.py 2026-01-27 14:29:40 +03:00
Egor 9a4acf2016 Adjust database connection pool settings 2026-01-27 14:29:13 +03:00
Egor 1ea513b3b5 Update websocket.py 2026-01-27 14:28:45 +03:00
Egor c912d6a4de Merge pull request #2433 from BEDOLAGA-DEV/dev
Dev
2026-01-27 14:17:19 +03:00
Egor d18945d0ee Update referral_contest.py 2026-01-27 01:38:42 +03:00
Egor c5c8eb880f Update universal_migration.py 2026-01-27 01:37:18 +03:00
Egor 282ba43b42 Add files via upload 2026-01-27 01:36:47 +03:00
Egor 6ce42357c5 Add files via upload 2026-01-27 01:36:18 +03:00
Egor 13bb03ac91 Add files via upload 2026-01-27 01:34:28 +03:00
Egor 2f58d35c80 Add files via upload 2026-01-27 01:33:35 +03:00
Egor 021918dd99 Update branding.py 2026-01-27 01:16:54 +03:00
Egor eb1dd5aa12 Update menu.py 2026-01-27 00:58:03 +03:00
Egor 545365d923 Update photo_message.py 2026-01-27 00:57:30 +03:00
Egor 0ce3e44058 Update photo_message.py 2026-01-27 00:52:11 +03:00
Egor a8b51f4aee Update photo_message.py 2026-01-27 00:46:13 +03:00
Egor 0efae905bc Update email_template_overrides.py 2026-01-27 00:35:55 +03:00
Egor c1f2c4c066 Add files via upload 2026-01-27 00:25:21 +03:00
Egor 93004b7636 Add files via upload 2026-01-27 00:24:50 +03:00
Egor 7a42d34bc9 Update notification_delivery_service.py 2026-01-27 00:23:50 +03:00
Egor 0aa9fc3723 Merge pull request #2432 from BEDOLAGA-DEV/dev
Dev
2026-01-26 23:28:44 +03:00
Egor e7850a3777 Update subscription.py 2026-01-26 23:21:26 +03:00
Egor c8e8087d69 Update tariff_purchase.py 2026-01-26 23:20:08 +03:00
Egor 89b958c726 Update subscription_auto_purchase_service.py 2026-01-26 23:19:37 +03:00
Egor 6bb22b76f7 Update subscription.py 2026-01-26 23:19:02 +03:00
Mikhail b5df07c7a7 Merge branch 'main' into main 2026-01-26 23:15:04 +03:00
gy9vin dd423efe08 Окончалтельный фикс простой покупки! 2026-01-26 23:10:51 +03:00
Egor 7019799f4c Add files via upload 2026-01-26 22:37:32 +03:00
Egor 441bc44a24 Add files via upload 2026-01-26 22:36:48 +03:00
Egor 84b933a0e4 Add files via upload 2026-01-26 22:36:13 +03:00
Egor 7d69e466d0 Update main.py 2026-01-26 22:35:33 +03:00
Egor cca3563422 Merge pull request #2430 from BEDOLAGA-DEV/dev
Update universal_migration.py
2026-01-26 22:07:04 +03:00
Egor aaf258fbad Update universal_migration.py 2026-01-26 22:06:46 +03:00
Egor d6ab9f6092 Merge pull request #2429 from BEDOLAGA-DEV/dev
Dev
2026-01-26 22:00:37 +03:00
Egor caa19331ac Add files via upload 2026-01-26 21:58:14 +03:00
Egor e9af145934 Add files via upload 2026-01-26 21:57:44 +03:00
Egor a7abce9a4e Add files via upload 2026-01-26 21:57:08 +03:00
Egor d01492f982 Update universal_migration.py 2026-01-26 21:55:56 +03:00
Egor 37567c0090 Add files via upload 2026-01-26 21:55:13 +03:00
Egor 25b1d7cf69 Merge pull request #2428 from BEDOLAGA-DEV/main
ц
2026-01-26 21:54:39 +03:00
Egor 990a1ddc66 Merge pull request #2426 from Gy9vin/main
Фикс
2026-01-26 21:12:51 +03:00
Egor 01eaa8bbd5 Merge pull request #2427 from BEDOLAGA-DEV/dev
Update purchase.py
2026-01-26 21:11:40 +03:00
Egor da09be3af7 Update purchase.py 2026-01-26 21:11:24 +03:00
gy9vin c188ff805e Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2026-01-26 21:06:59 +03:00
Egor e71e2bbaa0 Merge pull request #2425 from BEDOLAGA-DEV/dev
Dev
2026-01-26 21:04:14 +03:00
Egor 5f5ea6b1ef Update purchase.py 2026-01-26 21:03:49 +03:00
gy9vin 94c9b6232e Фикс простой прокупки 2026-01-26 20:38:30 +03:00
Egor 36acd7a66a Update balance.py 2026-01-26 20:34:18 +03:00
Egor 2e86380e2c Merge pull request #2424 from BEDOLAGA-DEV/dev
Update subscription_service.py
2026-01-26 20:26:45 +03:00
Egor 82d24eea5b Update subscription_service.py 2026-01-26 20:26:23 +03:00
Egor 8306fa63c0 Merge pull request #2423 from BEDOLAGA-DEV/dev
Update universal_migration.py
2026-01-26 20:01:40 +03:00
Egor f85097698a Update universal_migration.py 2026-01-26 20:01:05 +03:00
Egor f7928e68e5 Merge pull request #2422 from BEDOLAGA-DEV/dev
Dev
2026-01-26 19:20:48 +03:00
Egor 5aac3481a0 Update payment_verification_service.py 2026-01-26 19:20:16 +03:00
Egor 715520320d Add files via upload 2026-01-26 19:13:59 +03:00
Egor b636aec03a Merge pull request #2421 from BEDOLAGA-DEV/main
w
2026-01-26 19:07:15 +03:00
Egor 59f05efcb3 Merge pull request #2419 from Gy9vin/main
Багфиксы и плюшки для Кассааи
2026-01-26 19:05:41 +03:00
Egor a3fafc6dfc Merge pull request #2418 from BEDOLAGA-DEV/c0mrade/setup-pre-commit
с0mrade/setup pre commit
2026-01-26 19:05:15 +03:00
Egor d0ad9f6e2e Merge pull request #2420 from BEDOLAGA-DEV/dev
Dev
2026-01-26 19:04:16 +03:00
Egor 0d84e0ba8f Add files via upload 2026-01-26 19:03:16 +03:00
Egor 964b7c19b4 Update admin_stats.py 2026-01-26 18:43:32 +03:00
Egor b3391139f1 Update traffic_monitoring_service.py 2026-01-26 18:42:58 +03:00
gy9vin 0d9498f169 Багфиксы и плюшки для Кассааи 2026-01-26 12:45:08 +03:00
c0mrade 1b212cc8d6 ci: add ruff lint workflow and fix formatting 2026-01-25 19:31:00 +03:00
c0mrade bd18e6c187 Merge pull request #2417 from BEDOLAGA-DEV/email
Email
2026-01-25 18:33:44 +03:00
c0mrade 02f8826132 fix(config): make SMTP credentials optional for servers without AUTH 2026-01-25 14:43:59 +03:00
c0mrade 989da445be fix(email): handle SMTP servers without AUTH support 2026-01-25 14:35:51 +03:00
Egor ac892cc899 Update subscription.py 2026-01-25 13:39:47 +03:00
Egor f8564f353f Merge pull request #2415 from BEDOLAGA-DEV/email
Email
2026-01-25 13:30:35 +03:00
Egor 99c60793b8 Update remnawave_api.py 2026-01-25 13:29:52 +03:00
Egor af45f1aac0 Update dependencies.py 2026-01-25 13:29:26 +03:00
Egor b797a93105 Update notification_delivery_service.py 2026-01-25 13:28:59 +03:00
Egor 4f84dc6324 Update database.py 2026-01-25 13:28:28 +03:00
Egor 91102d66f9 Merge pull request #2413 from BEDOLAGA-DEV/email
Email
2026-01-25 12:34:19 +03:00
Egor 4eb6e035db Update subscription.py 2026-01-25 12:34:02 +03:00
Egor d7ac622ddc Update cloudpayments_service.py 2026-01-25 12:28:48 +03:00
Egor df91ac51a9 Update payments.py 2026-01-25 12:27:56 +03:00
Egor 658b48f154 Merge pull request #2412 from BEDOLAGA-DEV/email
Email
2026-01-25 11:55:22 +03:00
Egor 3c47dab510 Update payments.py 2026-01-25 11:55:09 +03:00
Egor e612e38e46 Update cloudpayments.py 2026-01-25 11:54:14 +03:00
Egor 5d3af39137 Add files via upload 2026-01-25 11:53:40 +03:00
Egor 6f40cdd09c Update user.py 2026-01-25 11:52:24 +03:00
Egor df189dcf17 Update user_service.py 2026-01-25 11:33:58 +03:00
Egor e7a82a7e6e Add files via upload 2026-01-25 11:32:48 +03:00
Egor c990a10c5b Update auth.py 2026-01-25 11:32:11 +03:00
Egor 52a19f884f Update auth.py 2026-01-25 11:25:33 +03:00
Egor fc0e7a3347 Update auth.py 2026-01-25 11:20:14 +03:00
Egor 832deccfe6 Update auth.py 2026-01-25 11:01:46 +03:00
Egor d80983ec25 Update auth.py 2026-01-25 10:58:12 +03:00
Egor 7ab108a74d Update auth.py 2026-01-25 10:52:37 +03:00
Egor 5d66cc21bb Update remnawave_api.py 2026-01-25 10:52:13 +03:00
Egor 294810fb94 Update auth.py 2026-01-25 10:35:59 +03:00
Egor 45edbd53d0 Add files via upload 2026-01-25 10:35:18 +03:00
Egor b547bea807 Update remnawave_api.py 2026-01-25 10:34:51 +03:00
Egor d6f4ea1d43 Update config.py 2026-01-25 09:56:00 +03:00
Egor 1b1cc7312d Update auth.py 2026-01-25 09:55:30 +03:00
Egor 9dec4186ed Update .env.example 2026-01-25 09:44:49 +03:00
Egor 9578a91a9e Add files via upload 2026-01-25 09:43:49 +03:00
Egor 7b42bfd02d Update config.py 2026-01-25 09:43:15 +03:00
Egor 41fb6b0f9b Update .env.example 2026-01-25 09:23:54 +03:00
Egor 182b9c47cf Update auth.py 2026-01-25 09:23:22 +03:00
Egor 8b492d7ebb Update auth.py 2026-01-25 09:22:56 +03:00
Egor 5677fca06f Update auth.py 2026-01-25 08:43:00 +03:00
Egor 9a32a108c4 Update auth.py 2026-01-25 08:42:03 +03:00
Egor 98677b90d0 Merge pull request #2411 from BEDOLAGA-DEV/chore/setup-uv-ruff
Chore/setup uv ruff
2026-01-24 17:55:17 +03:00
c0mrade 9a2aea038a chore: add uv package manager and ruff linter configuration
- Add pyproject.toml with uv and ruff configuration
- Pin Python version to 3.13 via .python-version
- Add Makefile commands: lint, format, fix
- Apply ruff formatting to entire codebase
- Remove unused imports (base64 in yookassa/simple_subscription)
- Update .gitignore for new config files
2026-01-24 17:45:27 +03:00
Egor 6c12dd8ce7 Merge pull request #2407 from BEDOLAGA-DEV/dev5
Dev5
2026-01-24 12:07:40 +03:00
Egor 345f3c0f1f Update payments.py 2026-01-24 12:07:08 +03:00
Egor 8c7a6ebc2b Update cloudpayments_service.py 2026-01-24 12:06:47 +03:00
Egor b5f563f80b Update cloudpayments.py 2026-01-24 12:05:51 +03:00
Egor d155493b7a Merge pull request #2406 from BEDOLAGA-DEV/dev5
Dev5
2026-01-24 11:53:05 +03:00
Egor e1b0863642 Update payment_verification_service.py 2026-01-24 11:52:16 +03:00
Egor 9ff8cfab5d Update cloudpayments.py 2026-01-24 11:51:58 +03:00
Egor f0ab05e02b Merge pull request #2405 from BEDOLAGA-DEV/dev5
Update cloudpayments.py
2026-01-24 11:36:21 +03:00
Egor 7fd6450180 Update cloudpayments.py 2026-01-24 11:35:59 +03:00
Egor 5b9b5a4eed Merge pull request #2404 from BEDOLAGA-DEV/dev5
Update cloudpayments.py
2026-01-24 11:31:56 +03:00
Egor f44a59f365 Update cloudpayments.py 2026-01-24 11:31:43 +03:00
Egor 73446e0360 Merge pull request #2403 from BEDOLAGA-DEV/dev5
Update cloudpayments.py
2026-01-24 11:28:22 +03:00
Egor 7d64abf6f8 Update cloudpayments.py 2026-01-24 11:28:05 +03:00
Egor 328183d942 Merge pull request #2402 from BEDOLAGA-DEV/dev5
Dev5
2026-01-24 11:24:49 +03:00
Egor 34105f8e97 Update daily_subscription_service.py 2026-01-24 11:24:16 +03:00
Egor 7e37bc4409 Update subscription.py 2026-01-24 11:23:28 +03:00
Egor e12b19099f Merge pull request #2401 from BEDOLAGA-DEV/dev5
Dev5
2026-01-24 11:13:45 +03:00
Egor 890c219d90 Update universal_migration.py 2026-01-24 11:13:30 +03:00
Egor c27128aa30 Update models.py 2026-01-24 11:12:42 +03:00
Egor cd80164bee Update user.py 2026-01-24 09:56:06 +03:00
Egor c0032c1c0d Merge pull request #2399 from BEDOLAGA-DEV/dev5
Update user.py
2026-01-24 09:51:53 +03:00
Egor 3d9a8c39dc Update user.py 2026-01-24 09:51:13 +03:00
Egor cc5aa4278d Update .env.example 2026-01-23 11:30:08 +03:00
Egor fe73128a9e Update config.py 2026-01-23 11:29:50 +03:00
Egor c9cbb81054 Add files via upload 2026-01-23 11:29:08 +03:00
Egor 7e16d0edee Add files via upload 2026-01-23 11:28:32 +03:00
Egor 80e4cdb791 Add files via upload 2026-01-23 11:27:30 +03:00
Egor 37797ba3f6 Add files via upload 2026-01-23 11:26:42 +03:00
Egor 334130e587 Add files via upload 2026-01-23 11:25:38 +03:00
Egor 1ce729629d Add files via upload 2026-01-23 11:24:27 +03:00
Egor 0a0fc48463 Add files via upload 2026-01-23 11:23:51 +03:00
Egor b576cb4486 Add files via upload 2026-01-23 11:22:31 +03:00
Egor e79f86e5ec Merge pull request #2398 from BEDOLAGA-DEV/main
ц
2026-01-23 04:58:05 +03:00
Egor 0b31d7b27f Update Dockerfile 2026-01-23 03:55:00 +03:00
Egor bd245076a5 Update docker-registry.yml 2026-01-23 03:54:28 +03:00
Egor fd2e032205 Update docker-hub.yml 2026-01-23 03:54:17 +03:00
Egor fcf84aa41e Merge pull request #2397 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-23 03:46:28 +03:00
Egor db01725582 Update subscription.py 2026-01-23 03:45:50 +03:00
Egor 9269770703 Merge pull request #2396 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-23 00:36:42 +03:00
Egor e91cc23156 Update subscription.py 2026-01-23 00:36:25 +03:00
Egor ffc9453b76 Merge pull request #2395 from BEDOLAGA-DEV/dev5
Update purchase.py
2026-01-22 23:25:04 +03:00
Egor 3a9404c349 Update purchase.py 2026-01-22 23:23:25 +03:00
Egor de2f3de28a Merge pull request #2394 from BEDOLAGA-DEV/dev5
Update admin_promo_offers.py
2026-01-22 23:08:48 +03:00
Egor 25318c1c41 Update admin_promo_offers.py 2026-01-22 23:08:19 +03:00
Egor c233ba8a8c Merge pull request #2393 from BEDOLAGA-DEV/dev5
Dev5
2026-01-22 22:39:19 +03:00
Egor 2a82b037d8 Add files via upload 2026-01-22 22:39:01 +03:00
Egor 67083980a3 Update payment_service.py 2026-01-22 22:38:31 +03:00
Egor 05f65af8e9 Merge pull request #2392 from BEDOLAGA-DEV/dev5
Dev5
2026-01-22 22:34:24 +03:00
Egor 085459dfd3 Update transaction.py 2026-01-22 22:34:09 +03:00
Egor 65af46cdae Update reporting_service.py 2026-01-22 22:33:40 +03:00
Egor f218852f5f Merge pull request #2391 from BEDOLAGA-DEV/main
w
2026-01-22 22:33:00 +03:00
Egor 6635666112 Merge pull request #2390 from Gy9vin/main
fix(referral): исправить потерю реферальных кодов при обязательной по…
2026-01-22 22:00:27 +03:00
Egor 83f9d05fe3 Update payments.py 2026-01-22 21:59:10 +03:00
gy9vin d47a65c29f fix(referral): исправить потерю реферальных кодов при обязательной подписке на канал
Проблема: у некоторых пользователей реферальный код из deep link терялся,
  потому что pending_start_payload сохранялся только в FSM state, который
  мог быть недоступен (state=None) в edge cases.
                                                            Исправления:
  - Добавлен Redis fallback для хранения payload (TTL 1 час)
  - _capture_start_payload() теперь сохраняет в FSM state И в Redis
  - cmd_start() и required_sub_channel_check() проверяют Redis если FSM state
пуст
  - Добавлено логирование warning при state=None
  - Изменён уровень лога успешного сохранения с debug на info

  Изменённые файлы:
  - app/middlewares/channel_checker.py — Redis-функции и улучшенное логирование
  - app/handlers/start.py — Redis fallback в обработчиках

  Добавлены тесты:
  - tests/middlewares/test_channel_checker_payload.py (14 тестов)
2026-01-22 21:54:32 +03:00
Egor 626c67a7a7 Update balance.py 2026-01-22 21:52:43 +03:00
Egor e9c6ea9fc9 Update payments.py 2026-01-22 21:44:53 +03:00
Egor 318dda9e04 Update cloudpayments_service.py 2026-01-22 21:44:13 +03:00
Egor c73b0433b9 Merge pull request #2387 from BEDOLAGA-DEV/dev5
Dev5
2026-01-22 16:09:27 +03:00
Egor 0c2293fef2 Update remnawave_service.py 2026-01-22 16:08:55 +03:00
Egor 9f5971563b Merge pull request #2386 from BEDOLAGA-DEV/main
w
2026-01-22 16:06:54 +03:00
Egor 5930506972 Merge pull request #2379 from Gy9vin/main
feat(payments): добавить KassaAI как отдельную платёжную систему
2026-01-21 16:12:00 +03:00
Egor 6b6d79257e Merge pull request #2383 from BEDOLAGA-DEV/dev5
Dev5
2026-01-21 15:43:56 +03:00
Egor 86c2092eff Update subscription.py 2026-01-21 15:43:29 +03:00
Egor 7bd838f0b0 Update subscription_checker.py 2026-01-21 15:42:13 +03:00
Egor 5563314718 Add files via upload 2026-01-21 15:41:33 +03:00
Egor db69af159b Merge pull request #2382 from BEDOLAGA-DEV/main
w
2026-01-21 15:22:48 +03:00
Egor 4a16bcbccf Update auth.py 2026-01-21 15:03:15 +03:00
Egor 8dec623f2d Update README.md 2026-01-21 11:49:53 +03:00
Egor 0cb714b3a9 Update Dockerfile 2026-01-21 10:36:42 +03:00
Egor 2c3c4ba09c Update docker-registry.yml 2026-01-21 10:36:27 +03:00
Egor 48c6c8dd63 Update docker-hub.yml 2026-01-21 10:36:15 +03:00
Egor d01dd47d57 Merge pull request #2380 from BEDOLAGA-DEV/dev5
Update purchase.py
2026-01-21 10:13:19 +03:00
Egor d51d51db55 Update purchase.py 2026-01-21 10:09:27 +03:00
Mikhail 060ae9decf Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-21 09:49:12 +03:00
Egor 275c797566 Merge pull request #2378 from BEDOLAGA-DEV/dev5
Update balance.py
2026-01-21 09:32:05 +03:00
Egor d9a4af341e Update balance.py 2026-01-21 09:31:43 +03:00
Egor a4f337a502 Merge pull request #2377 from BEDOLAGA-DEV/dev5
Update yookassa.py
2026-01-21 09:27:40 +03:00
Egor 1f55d76459 Update yookassa.py 2026-01-21 09:27:17 +03:00
Egor 2760a744db Merge pull request #2376 from BEDOLAGA-DEV/dev5
Update promo.py
2026-01-21 08:21:17 +03:00
Egor f169c08275 Update promo.py 2026-01-21 08:21:02 +03:00
Egor c69b371c53 Merge pull request #2375 from BEDOLAGA-DEV/dev5
Update promo.py
2026-01-21 08:12:34 +03:00
Egor a45d667c89 Update promo.py 2026-01-21 08:12:17 +03:00
Egor 894e4e02b2 Merge pull request #2374 from BEDOLAGA-DEV/dev5
Dev5
2026-01-21 07:56:30 +03:00
Egor c289b96f1a Update subscription_purchase_service.py 2026-01-21 07:56:04 +03:00
Egor 6425cfb0fb Merge pull request #2373 from BEDOLAGA-DEV/main
ц
2026-01-21 07:35:10 +03:00
Egor a56daca368 Update balance.py 2026-01-21 07:34:25 +03:00
Egor d0628eebda Update config.py 2026-01-21 07:33:54 +03:00
Egor 6a36504699 Merge pull request #2372 from BEDOLAGA-DEV/dev5
Update wheel_service.py
2026-01-21 07:04:31 +03:00
Egor 8db061553f Update wheel_service.py 2026-01-21 07:03:07 +03:00
Egor 7eb8750d0f Merge pull request #2371 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-21 06:46:40 +03:00
Egor ae7f63aed0 Update subscription.py 2026-01-21 06:46:07 +03:00
Egor c18b4a3cbb Merge pull request #2370 from BEDOLAGA-DEV/dev5
Dev5
2026-01-21 05:55:28 +03:00
Egor c0cada8fb5 Add files via upload 2026-01-21 05:54:54 +03:00
Egor 1117a1dd34 Merge pull request #2369 from BEDOLAGA-DEV/main
w
2026-01-21 05:16:41 +03:00
Egor abd312dacc Merge pull request #2368 from Gy9vin/main
feat(monitoring): добавить настройки мониторинга трафика в админку
2026-01-21 05:14:36 +03:00
gy9vin 7aa64521d2 feat(payments): добавить KassaAI как отдельную платёжную систему
Новая платёжка KassaAI (api.fk.life) работает параллельно с Freekassa.

  Добавлено:
  - app/services/kassa_ai_service.py — API-сервис
  - app/database/crud/kassa_ai.py — CRUD-операции
  - app/services/payment/kassa_ai.py — KassaAiPaymentMixin
  - app/handlers/balance/kassa_ai.py — хендлеры пополнения

  Изменено:
  - config.py — настройки KASSA_AI_*
  - models.py — PaymentMethod.KASSA_AI, модель KassaAiPayment
  - payment_service.py — подключён KassaAiPaymentMixin
  - webserver/payments.py — webhook /kassa-ai-webhook
  - keyboards/inline.py — кнопка KassaAI
  - handlers/balance/main.py — регистрация хендлеров
  - universal_migration.py — миграция таблицы kassa_ai_payments
  - system_settings_service.py — настройки в админке
  - .env.example — примеры переменных

  Способы оплаты: 44=СБП, 36=Карты РФ, 43=SberPay
2026-01-20 19:09:27 +03:00
Mikhail b99ff79920 Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-20 17:20:46 +03:00
gy9vin dff723aede feat(monitoring): добавить настройки мониторинга трафика в админку
- Добавлена кнопка "⚙️ Настройки трафика" в меню мониторинга
  - Добавлен UI для управления быстрой и суточной проверками трафика
  - Можно включать/выключать проверки, менять пороги и интервалы
  - Настройки сохраняются в БД через BotConfigurationService
  - Добавлены SETTING_HINTS с описаниями параметров
2026-01-20 17:19:57 +03:00
Egor 86097b300e Merge pull request #2367 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 16:57:18 +03:00
Egor f6b795e555 Update miniapp.py 2026-01-20 16:56:59 +03:00
Egor 7719d035a1 Update dependencies.py 2026-01-20 16:56:07 +03:00
Egor 33e11fb25a Update database.py 2026-01-20 16:55:35 +03:00
Egor fcdda41541 Merge pull request #2366 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-20 16:44:59 +03:00
Egor c5183f5a9f Update subscription.py 2026-01-20 16:44:38 +03:00
Egor f200f90150 Merge pull request #2365 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 14:45:45 +03:00
Egor e42421d2ff Update dependencies.py 2026-01-20 14:28:35 +03:00
Egor c783884ace Update miniapp.py 2026-01-20 14:27:37 +03:00
Egor 6ca1bae6f8 Merge pull request #2364 from BEDOLAGA-DEV/dev5
Update freekassa_service.py
2026-01-20 13:18:45 +03:00
Egor e1aeff55d7 Update freekassa_service.py 2026-01-20 13:18:14 +03:00
Egor 10107964ba Merge pull request #2363 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 13:06:16 +03:00
Egor ade2794d52 Update start.py 2026-01-20 13:05:45 +03:00
Egor a462657f96 Merge pull request #2362 from BEDOLAGA-DEV/main
ц
2026-01-20 13:04:40 +03:00
Egor 91af4a3818 Update Dockerfile 2026-01-20 08:04:44 +03:00
Egor 05f29c25ec Update docker-registry.yml 2026-01-20 08:04:30 +03:00
Egor 5b851f1047 Update docker-hub.yml 2026-01-20 08:04:08 +03:00
Egor fac258a40d Merge pull request #2361 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-20 06:37:30 +03:00
Egor d92071e8aa Update subscription.py 2026-01-20 06:37:04 +03:00
Egor f6c9304a40 Merge pull request #2360 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 06:23:22 +03:00
Egor b994a5ffd6 Add files via upload 2026-01-20 06:23:00 +03:00
Egor 0413a0ee6a Update purchase.py 2026-01-20 06:22:24 +03:00
Egor 1fc8d38c70 Merge pull request #2359 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-20 05:39:59 +03:00
Egor f50fbe232d Update subscription.py 2026-01-20 05:39:36 +03:00
Egor 80e1b68863 Merge pull request #2358 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 02:35:07 +03:00
Egor 4f54ffcc2a Update subscription.py 2026-01-20 02:34:35 +03:00
Egor 6fed2542eb Merge pull request #2357 from BEDOLAGA-DEV/main
w
2026-01-20 02:33:50 +03:00
Egor c331c33321 Merge pull request #2356 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 02:20:14 +03:00
PEDZEO 338ee832ea Add fullscreen settings endpoints and models to branding.py 2026-01-20 02:17:35 +03:00
Egor b7a05f7cd1 Update miniapp.py 2026-01-20 02:14:42 +03:00
Egor dd396aa16d Update miniapp.py 2026-01-20 02:14:10 +03:00
Egor 814d224359 Update subscription.py 2026-01-20 02:13:34 +03:00
Egor 95ab0236d9 Update subscription.py 2026-01-20 02:13:01 +03:00
Egor 4fbe07919c Merge pull request #2355 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 01:46:57 +03:00
Egor 1752f52197 Update miniapp.py 2026-01-20 01:39:50 +03:00
Egor 5fe37e57a6 Update subscription.py 2026-01-20 01:39:09 +03:00
PEDZEO da9997609b Add custom logging configuration to WebAPIServer to reduce WebSocket spam 2026-01-20 01:30:17 +03:00
Egor 92b7c0b602 Merge pull request #2354 from BEDOLAGA-DEV/main
ц
2026-01-20 01:24:16 +03:00
Egor 44b3a0dfab Merge pull request #2353 from BEDOLAGA-DEV/dev5
Update subscription_auto_purchase_service.py
2026-01-20 01:23:34 +03:00
Egor b3bbd48723 Update subscription_auto_purchase_service.py 2026-01-20 01:23:06 +03:00
Egor e51afc6bcf Merge pull request #2352 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 01:02:27 +03:00
Egor 0ae70d702e Update purchase.py 2026-01-20 01:00:43 +03:00
Egor 722b800f1d Update subscription_purchase_service.py 2026-01-20 00:59:55 +03:00
Egor cd9287236e Update subscription.py 2026-01-20 00:58:58 +03:00
Egor 18e8e38855 Update miniapp.py 2026-01-20 00:58:24 +03:00
Egor a63d587aad Update subscription_utils.py 2026-01-20 00:54:45 +03:00
Egor aba2df927d Update tariff_purchase.py 2026-01-20 00:53:56 +03:00
Egor d78f85735b Update remnawave_api.py 2026-01-20 00:52:59 +03:00
PEDZEO a7e0f52e27 Merge pull request #2351 from BEDOLAGA-DEV/test 2026-01-20 00:41:18 +03:00
Egor b9ca73a306 Update subscription.py 2026-01-20 00:41:09 +03:00
Egor c26ccb8e96 Update subscription_purchase_service.py 2026-01-20 00:40:22 +03:00
PEDZEO 38bf8633b7 Merge branch 'test' of https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot into test 2026-01-20 00:38:29 +03:00
PEDZEO 73d52f2047 Adjust logging levels for WebSocket connections to reduce verbosity 2026-01-20 00:38:23 +03:00
Egor d781290652 Merge pull request #2350 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 00:28:33 +03:00
Egor 9d453283ec Update global_error.py 2026-01-20 00:28:16 +03:00
Egor 98d01cff49 Update message_patch.py 2026-01-20 00:27:50 +03:00
Egor 255c623143 Merge pull request #2349 from BEDOLAGA-DEV/dev5
Dev5
2026-01-20 00:20:20 +03:00
Egor 2d69d98d81 Update subscription_renewal_service.py 2026-01-20 00:19:26 +03:00
Egor 8ad2d50b25 Add files via upload 2026-01-20 00:18:57 +03:00
Egor 372654683a Merge pull request #2348 from BEDOLAGA-DEV/main
w
2026-01-20 00:17:42 +03:00
Egor 6fd470a02f Merge pull request #2347 from BEDOLAGA-DEV/dev5
Dev5
2026-01-19 23:58:03 +03:00
Egor cb4f79dd6e Update monitoring_service.py 2026-01-19 23:57:45 +03:00
Egor b38e06383d Update user.py 2026-01-19 23:57:03 +03:00
Egor 5523c6bf76 Merge pull request #2346 from BEDOLAGA-DEV/dev5
Dev5
2026-01-19 23:47:01 +03:00
Egor 57981b69c7 Update server_squad.py 2026-01-19 23:46:41 +03:00
PEDZEO 6a3e716ad2 Refactor logging levels in websocket.py files to use debug instead of info and warning for connection attempts and token validation. 2026-01-19 23:46:03 +03:00
Egor c21804f8db Add files via upload 2026-01-19 23:45:53 +03:00
Egor 3937bbffc9 Merge pull request #2345 from BEDOLAGA-DEV/dev5
Update auth.py
2026-01-19 23:36:06 +03:00
Egor 70441da7de Update auth.py 2026-01-19 23:35:12 +03:00
Egor df2626adae Merge pull request #2344 from BEDOLAGA-DEV/dev5
Update subscription.py
2026-01-19 23:17:18 +03:00
Egor 9bffeb4151 Update subscription.py 2026-01-19 23:17:00 +03:00
Egor 8524e2c023 Merge pull request #2343 from BEDOLAGA-DEV/dev5
Dev5
2026-01-19 23:06:38 +03:00
Egor 3553ff615d Update subscription.py 2026-01-19 23:06:21 +03:00
Egor ca0682e48f Merge pull request #2342 from BEDOLAGA-DEV/main
w
2026-01-19 22:52:58 +03:00
Egor 1e1c0e89bc Update subscription.py 2026-01-19 22:51:57 +03:00
Egor 93227b7f41 Update yookassa.py 2026-01-19 22:49:58 +03:00
Egor 0a51e4d9ea Merge pull request #2339 from BEDOLAGA-DEV/dev5
Update branding.py
2026-01-19 10:22:50 +03:00
Egor 9e69b0cf22 Update branding.py 2026-01-19 10:17:33 +03:00
Egor aaaaa25231 Merge pull request #2338 from BEDOLAGA-DEV/dev5
Update admin_promocodes.py
2026-01-19 09:32:03 +03:00
Egor 10fea329ad Update admin_promocodes.py 2026-01-19 09:31:41 +03:00
Egor 408c5c520e Merge pull request #2337 from BEDOLAGA-DEV/dev5
Update admin_stats.py
2026-01-19 09:17:15 +03:00
Egor 6775b06b2e Update admin_stats.py 2026-01-19 08:51:48 +03:00
Egor 9460c86f7a Merge pull request #2336 from BEDOLAGA-DEV/dev5
Dev5
2026-01-19 07:53:16 +03:00
Egor f8d7b3288c Update cloudpayments.py 2026-01-19 07:52:54 +03:00
Egor bd6498fb73 Update subscription.py 2026-01-19 07:52:16 +03:00
Egor cdb3507a56 Update tariff_purchase.py 2026-01-19 06:26:15 +03:00
Egor 0b18c16f47 Update subscription_auto_purchase_service.py 2026-01-19 06:25:30 +03:00
Egor f452434b13 Merge pull request #2335 from BEDOLAGA-DEV/main
w
2026-01-19 06:24:22 +03:00
PEDZEO e6e688a395 fix(routes): reorder notification and ticket routers to prevent route conflicts
- Moved the notifications router to be included before the tickets router to avoid conflicts.
- Updated comments for clarity regarding the order of router inclusion.
2026-01-19 01:30:31 +03:00
PEDZEO b1206a84c7 feat(notifications): enhance notification security and ownership checks
- Added ownership verification for user notifications to ensure only the rightful owner can mark them as read.
- Implemented checks to confirm that admin notifications are correctly identified before allowing them to be marked as read.
- Introduced a new method to retrieve notifications by ID in the TicketNotificationCRUD for improved data handling.
2026-01-19 00:39:36 +03:00
PEDZEO 63e45e12de Merge pull request #2334 from BEDOLAGA-DEV/test
feat(notifications): implement ticket notifications for users and admins
2026-01-19 00:34:03 +03:00
PEDZEO 792ff22471 Merge branch 'main' into test 2026-01-19 00:33:21 +03:00
PEDZEO 346806bce0 feat(notifications): integrate WebSocket notifications for ticket replies and new tickets
- Added WebSocket notifications for admins on new ticket creation and user replies.
- Implemented notification handling in the ticket management routes.
- Enhanced error logging for notification failures.
2026-01-19 00:28:57 +03:00
PEDZEO 5630e99812 Merge branch 'main' of https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot 2026-01-19 00:03:43 +03:00
PEDZEO 67c3dba1cc feat(notifications): implement ticket notifications for users and admins
- Added a new TicketNotification model to handle notifications for ticket events.
- Implemented user and admin notifications for new tickets and replies in the cabinet.
- Introduced settings to enable or disable notifications for users and admins.
- Enhanced ticket settings to include notification preferences.
- Integrated WebSocket notifications for real-time updates.
2026-01-19 00:02:41 +03:00
Egor c1e901ba5e Merge pull request #2333 from BEDOLAGA-DEV/dev5
Update auth.py
2026-01-18 23:34:02 +03:00
Egor b6f2052464 Update auth.py 2026-01-18 23:33:45 +03:00
PEDZEO c63db708cc feat(tickets): notify admins on new ticket creation and replies
- Added functionality to notify admins when a new ticket is created.
- Implemented notification for admins when a user replies to a ticket.
- Included error handling for notification failures.
2026-01-18 23:23:46 +03:00
Egor 3fc702ec65 Merge pull request #2332 from BEDOLAGA-DEV/dev5
Dev5
2026-01-18 22:15:37 +03:00
Egor 0aff94de74 Update main.py 2026-01-18 22:15:03 +03:00
Egor d8c60ce19f Merge pull request #2331 from BEDOLAGA-DEV/main
w
2026-01-18 22:14:34 +03:00
Egor f3995c7ca8 Merge pull request #2330 from DrillUser/patch-1
+убрать очепятку
2026-01-18 21:31:22 +03:00
Egor 1330f394bb Merge pull request #2328 from Gy9vin/main
fix(contests): исправлены критические баги системы конкурсов
2026-01-18 21:31:01 +03:00
Vladislav ec27d63f70 +убрать очепятку 2026-01-18 14:09:04 +03:00
gy9vin 78d785f83f fix(contests): исправлены критические баги системы конкурсов
- Исправлен вызов get_active_rounds в админ-панели (передавалось 2 параметра вместо 1)
- Обновлены кнопки редактирования призов с prize_days на prize_type/prize_value
- Мигрирован Cabinet API с устаревшего prize_days на новые поля
- Добавлена поддержка нескольких типов призов (дни, баланс, кастом)
- Обновлена документация API конкурсов
2026-01-18 10:15:45 +03:00
Egor 04bb20c1e7 Update Dockerfile 2026-01-18 07:33:22 +03:00
Egor 7fdf2dcd51 Update docker-registry.yml 2026-01-18 07:33:13 +03:00
Egor 6d5462b084 Update docker-hub.yml 2026-01-18 07:32:52 +03:00
Egor 956b90e95e Merge pull request #2327 from BEDOLAGA-DEV/dev5
Dev5
2026-01-18 06:01:18 +03:00
Egor 15e7725cf7 Update tariff.py 2026-01-18 05:47:59 +03:00
Egor 4205eed54f Update admin_tariffs.py 2026-01-18 05:46:50 +03:00
Egor 921ae25dd3 Merge pull request #2326 from BEDOLAGA-DEV/main
w
2026-01-18 05:43:05 +03:00
Egor cee4139a64 Update README.md 2026-01-17 19:49:19 +03:00
Egor 92339ec55a Merge pull request #2323 from libkitdev/main
feat(promocodes): добавить тип DISCOUNT для одноразовых процентных скидок
2026-01-17 19:45:10 +03:00
Egor 3372736fa3 Merge pull request #2325 from BEDOLAGA-DEV/dev5
Dev5
2026-01-17 10:52:16 +03:00
Egor cd0ce908a1 Add files via upload 2026-01-17 10:31:40 +03:00
Egor b7f8469895 Add files via upload 2026-01-17 10:31:21 +03:00
Egor 8a96dbd224 Merge pull request #2324 from BEDOLAGA-DEV/dev5
Add files via upload
2026-01-17 10:05:56 +03:00
Egor 840c954203 Add files via upload 2026-01-17 10:05:30 +03:00
libkit 8b6683302d feat(localization): добавить тексты для DISCOUNT промокодов
Добавлены переводы на все 4 языка (ru, en, ua, zh):
- ADMIN_PROMOCODE_TYPE_DISCOUNT - название типа в админке
- PROMOCODE_ACTIVE_DISCOUNT_EXISTS - ошибка при конфликте скидок

Тексты описывают функционал одноразовой процентной скидки.
2026-01-17 11:25:51 +05:00
libkit 7a351d3028 feat(keyboards): добавить кнопку типа DISCOUNT в меню промокодов
Кнопка "💸 Одноразовая скидка" в меню выбора типа промокода.
2026-01-17 11:23:08 +05:00
libkit 5610a91866 feat(admin): добавить UI для создания DISCOUNT промокодов
Добавлена полная поддержка DISCOUNT типа в админке:
- Тип "💸 Одноразовая скидка" в селекторе
- Флоу создания: код → процент (1-100) → макс использований → срок промокода (дни) → срок скидки (часы)
- Валидация процента скидки (1-100)
- Валидация срока действия скидки (0-8760 часов)
- Отображение в списках и странице управления
- Новый стейт setting_discount_hours для ввода срока скидки
2026-01-17 11:22:32 +05:00
libkit 0858388b18 feat(handlers): добавить обработку ошибки active_discount_exists
Пользователь получает понятное сообщение при попытке
активировать промокод когда уже есть активная скидка.
2026-01-17 11:19:24 +05:00
libkit 1793775fe8 feat(services): реализовать логику активации DISCOUNT промокодов
Добавлена обработка нового типа промокода DISCOUNT:
- Проверка конфликта с активными скидками пользователя
- Запись скидки в профиль (promo_offer_discount_percent, promo_offer_discount_expires_at)
- Обработка срока действия скидки (0 часов = бессрочно до первой покупки)
- Логирование активации и ошибок
- Выброс ValueError при попытке активировать скидку при наличии активной
2026-01-17 11:18:46 +05:00
libkit ff45a3e28d feat(models): добавить тип DISCOUNT в PromoCodeType
Добавлен новый тип промокода для одноразовых скидок.
Использует существующие поля без изменения схемы БД:
- balance_bonus_kopeks для хранения процента скидки (1-100)
- subscription_days для хранения срока действия скидки в часах (0-8760)
2026-01-17 11:17:30 +05:00
Egor a59252fbb1 Merge pull request #2322 from BEDOLAGA-DEV/dev5
Dev5
2026-01-17 08:51:55 +03:00
Egor 44a410babf Add files via upload 2026-01-17 08:50:47 +03:00
Egor 19a1d93a15 Update balance.py 2026-01-17 08:41:15 +03:00
Egor 64fe45fcfc Update balance.py 2026-01-17 08:40:37 +03:00
Egor 6d2b16f180 Merge pull request #2321 from BEDOLAGA-DEV/dev5
Update admin_users.py
2026-01-17 06:51:53 +03:00
Egor 1f138aa772 Update admin_users.py 2026-01-17 06:51:20 +03:00
Egor 65f5e31540 Merge pull request #2320 from BEDOLAGA-DEV/dev5
Update admin_users.py
2026-01-17 06:40:27 +03:00
Egor ca4252c9cb Update admin_users.py 2026-01-17 06:40:12 +03:00
Egor ee9682320f Merge pull request #2319 from BEDOLAGA-DEV/dev5
Update admin_users.py
2026-01-17 06:35:27 +03:00
Egor 15386ad4d8 Update admin_users.py 2026-01-17 06:35:08 +03:00
Egor d93db9b199 Merge pull request #2318 from BEDOLAGA-DEV/dev5
Dev5
2026-01-17 06:29:35 +03:00
Egor a54f5347a8 Update users.py 2026-01-17 06:28:42 +03:00
Egor c121855078 Update admin_users.py 2026-01-17 06:28:10 +03:00
Egor c870df4923 Merge pull request #2317 from BEDOLAGA-DEV/dev5
Update admin_users.py
2026-01-17 06:21:31 +03:00
Egor 8956fff7d7 Update admin_users.py 2026-01-17 06:21:14 +03:00
Egor de782e54ce Merge pull request #2316 from BEDOLAGA-DEV/dev5
Dev5
2026-01-17 06:11:46 +03:00
Egor 35b900dfe4 Add files via upload 2026-01-17 05:57:40 +03:00
Egor c1b70436b7 Add files via upload 2026-01-17 05:57:10 +03:00
Egor 5ecdfa14bd Merge pull request #2315 from BEDOLAGA-DEV/dev5
Обновление кампаний
2026-01-17 05:36:17 +03:00
Egor fd68c1c99f Add files via upload 2026-01-17 05:24:12 +03:00
Egor 0e339450f8 Add files via upload 2026-01-17 05:23:44 +03:00
Egor 1eadbfe3f8 Update campaigns.py 2026-01-17 05:14:23 +03:00
Egor bf0627a301 Update campaigns.py 2026-01-17 05:08:14 +03:00
Egor 274f8701a2 Update states.py 2026-01-17 05:06:07 +03:00
Egor ea4570ba71 Update campaigns.py 2026-01-17 05:05:42 +03:00
Egor 5f39821652 Update admin.py 2026-01-17 05:05:12 +03:00
Egor 26c78b6814 Update campaigns.py 2026-01-17 05:04:01 +03:00
Egor 3e1abcbd51 Update campaigns.py 2026-01-17 05:03:32 +03:00
Egor cd6f9a3296 Update campaign_service.py 2026-01-17 05:02:54 +03:00
Egor 73e4009c35 Update start.py 2026-01-17 05:02:07 +03:00
Egor 08d202161c Update campaign.py 2026-01-17 05:01:12 +03:00
Egor c4cde5efa2 Add files via upload 2026-01-17 05:00:47 +03:00
Egor e0868bca18 Merge pull request #2314 from BEDOLAGA-DEV/dev5
Add files via upload
2026-01-17 04:37:48 +03:00
Egor 248cc51e73 Add files via upload 2026-01-17 04:35:37 +03:00
Egor 322ad3b5e4 Merge pull request #2313 from BEDOLAGA-DEV/dev5
Dev5
2026-01-17 03:32:54 +03:00
Egor 63fd318290 Update auth.py 2026-01-17 03:32:10 +03:00
Egor c0e3e329af Update main.py 2026-01-17 03:31:39 +03:00
Egor f5b6f75cf7 Merge pull request #2312 from BEDOLAGA-DEV/dev5
Dev5
2026-01-17 03:28:42 +03:00
Egor e3fb2ec854 Update purchase.py 2026-01-17 03:28:29 +03:00
Egor 8b83f3d08b Update tickets.py 2026-01-17 03:28:01 +03:00
Egor 2493684e71 Merge pull request #2311 from BEDOLAGA-DEV/dev5
Update message_patch.py
2026-01-17 03:12:31 +03:00
Egor acfad93d93 Update message_patch.py 2026-01-17 03:12:15 +03:00
Egor 9bc1a66422 Merge pull request #2310 from BEDOLAGA-DEV/dev5
Update happ.py
2026-01-17 03:07:37 +03:00
Egor 0e4f9c1ce0 Update happ.py 2026-01-17 03:07:06 +03:00
Egor 58b28d9eb7 Merge pull request #2309 from BEDOLAGA-DEV/dev5
Dev5
2026-01-17 03:02:57 +03:00
Egor 94320d4217 Add files via upload 2026-01-17 03:02:28 +03:00
Egor 44592602dc Update links.py 2026-01-17 03:00:13 +03:00
Egor 84702e16df Update stats_service.py 2026-01-17 02:59:28 +03:00
Egor ec1870ef8f Merge pull request #2308 from BEDOLAGA-DEV/dev5
Update purchase.py
2026-01-17 02:54:56 +03:00
Egor 35dc9cf3e2 Update purchase.py 2026-01-17 02:54:40 +03:00
Egor 08fe4df226 Merge pull request #2307 from BEDOLAGA-DEV/dev5
Update config.py
2026-01-17 02:51:22 +03:00
Egor 17b442ad07 Update config.py 2026-01-17 02:49:14 +03:00
Egor 9cfc1e46ac Merge pull request #2306 from BEDOLAGA-DEV/dev5
Add files via upload
2026-01-17 02:46:24 +03:00
Egor c59b823df2 Add files via upload 2026-01-17 02:45:46 +03:00
Egor 6bbfd0caac Merge pull request #2305 from BEDOLAGA-DEV/dev5
Update pricing.py
2026-01-17 02:42:22 +03:00
Egor 39fed57876 Update pricing.py 2026-01-17 02:41:59 +03:00
Egor ed0ae70ff4 Merge pull request #2304 from BEDOLAGA-DEV/dev5
Dev5
2026-01-17 02:38:27 +03:00
Egor c0062b1ca3 Update promocode.py 2026-01-17 02:38:11 +03:00
Egor 5bdb06a280 Update main.py 2026-01-17 02:37:43 +03:00
Egor a42a884eb6 Update purchase.py 2026-01-17 02:37:16 +03:00
Egor 8726e33665 Add files via upload 2026-01-17 02:36:33 +03:00
Egor ba7eb8a1e8 Merge pull request #2303 from BEDOLAGA-DEV/dev5
Dev5
2026-01-17 02:35:39 +03:00
Egor 4599801f83 Update pricing.py 2026-01-17 02:35:23 +03:00
Egor a5f17b6802 Add files via upload 2026-01-17 02:03:06 +03:00
Egor c804505361 Add files via upload 2026-01-17 02:02:40 +03:00
Egor 3de9937442 Merge pull request #2302 from BEDOLAGA-DEV/dev5
clear logs
2026-01-17 01:51:04 +03:00
Egor 782d910f25 Add files via upload 2026-01-17 01:50:34 +03:00
Egor ac87d47bf3 Add files via upload 2026-01-17 01:49:56 +03:00
Egor 3dc7a80d87 Merge pull request #2301 from BEDOLAGA-DEV/dev5
fuck db sessions
2026-01-17 01:33:07 +03:00
Egor c61cd0b42e Update texts.py 2026-01-17 01:19:12 +03:00
Egor 5fcc202542 Add files via upload 2026-01-17 01:18:02 +03:00
Egor 1b4758cdbf Update monitoring.py 2026-01-17 01:16:50 +03:00
Egor 5cb0ce3030 Add files via upload 2026-01-17 01:16:10 +03:00
Egor e7bd52463d Add files via upload 2026-01-17 01:15:28 +03:00
Egor c1f035b13d Add files via upload 2026-01-17 01:14:57 +03:00
Egor 8d888f3e56 Merge pull request #2300 from BEDOLAGA-DEV/main
w
2026-01-17 01:04:26 +03:00
PEDZEO 1a990bd776 Refactor settings handling in AdminBanSystem to improve response parsing
- Introduced a new helper function `_parse_setting_response` to streamline the parsing of settings responses from the API.
- Updated the `get_settings`, `get_setting`, `set_setting`, and `toggle_setting` endpoints to utilize the new parsing function, enhancing code readability and maintainability.
- Improved handling of settings data formats, allowing for both detailed metadata and simple values.
2026-01-16 21:04:48 +03:00
PEDZEO b0f83f3534 Новые вкладки в AdminBanSystem:
1. Traffic (Трафик) - статистика трафика, топ пользователей по трафику, последние нарушения
  2. Reports (Отчёты) - отчёты за период (6h, 12h, 24h, 48h, 72h), статистика активных пользователей и IP, топ нарушителей
  3. Settings (Настройки) - управление настройками системы банов, группировка по категориям, переключатели для bool, ввод для int
  4. Health (Здоровье) - статус системы (healthy/degraded/unhealthy), аптайм, статус компонентов
2026-01-16 20:51:28 +03:00
PEDZEO 723a49f1f1 Fix stats mapping: uptime from tcp_metrics, agents count from connected_nodes
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 19:25:20 +03:00
PEDZEO e31c9118b3 ● Готово! Исправил маппинг для нод и агентов. 2026-01-16 19:21:31 +03:00
PEDZEO 7ef416eff2 Implement logging for Ban System API checks and add endpoint for raw stats retrieval
- Added logging for Ban System API status checks, including whether the system is enabled and its configured URL.
- Introduced a new endpoint `/stats/raw` to fetch raw statistics from the Ban System API for debugging purposes.
- Enhanced logging to capture raw stats response for better monitoring.
2026-01-16 18:51:19 +03:00
PEDZEO 29d0190a05 Исправил маппинг полей. 2026-01-16 18:41:21 +03:00
Egor 5eec0aa1a0 Update balance.py 2026-01-16 18:13:25 +03:00
Egor 02ae44a594 Merge pull request #2298 from Gy9vin/main
Мониторинг трафика v2
2026-01-16 17:56:10 +03:00
Egor b9801614f3 Update subscription.py 2026-01-16 17:51:28 +03:00
Egor 0ea1076761 Update subscription.py 2026-01-16 17:36:28 +03:00
gy9vin 94cd06302a Фиксы UI 2026-01-16 15:58:22 +03:00
gy9vin 5a64dbf209 feat(payments): добавить режим яркого промпта активации подписки
- Реализован режим SHOW_ACTIVATION_PROMPT_AFTER_TOPUP для яркого уведомления пользователей
  - При пополнении баланса отправляется внимание-привлекающее сообщение с восклицательными знаками
  - Динамические кнопки в зависимости от статуса подписки:
    * Активная платная подписка: "🔄 Продлить" + "📱 Изменить устройства"
    * Нет подписки/истекла/триал: "🔥 Активировать подписку"
  - Убраны дублирующие уведомления из yookassa.py (строка 851)
  - Убраны дублирующие уведомления из subscription_auto_purchase_service.py (строки 755, 918)
  - Режим включается через SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=true в .env

  Файлы:
  - app/services/payment/common.py: добавлена логика яркого промпта
  - app/services/payment/yookassa.py: отключено старое уведомление для корзины
  - app/services/subscription_auto_purchase_service.py: отключены 2 блока старых уведомлений
2026-01-16 15:29:44 +03:00
PEDZEO b392a99f56 Интеграция системы мониторинга банов 2026-01-16 15:17:02 +03:00
gy9vin 07417a4877 фикс 2026-01-16 14:28:39 +03:00
gy9vin 1c2dca2c65 fix(traffic): исправлены критические баги мониторинга трафика v2
- Исправлен баг с пустым snapshot {} (не распознавался как существующий)
- Исправлено игнорирование комментариев в TRAFFIC_MONITORED_NODES
- Добавлено исключение пользователей по UUID (TRAFFIC_EXCLUDED_USER_UUIDS)
- Добавлены названия нод в уведомления о превышении трафика
- Улучшено логирование: кулдаун, фильтры, исключённые пользователи
- Исправлен баг с блокировкой имён типа "Сейтмеметов" (ложное срабатывание на "тме")
- Разрешён конфликт слияния в display_name_restriction.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 12:19:29 +03:00
gy9vin ab492f3aef Мониторинг и исправления защиты имени пользователя! 2026-01-16 12:18:04 +03:00
Mikhail 1c7ed68674 Merge branch 'main' into main 2026-01-16 12:16:23 +03:00
Egor fed3fda83e Merge pull request #2297 from BEDOLAGA-DEV/dev5
Сброс трафика на тарифах
2026-01-16 08:51:09 +03:00
Egor 8db459e206 Update admin_tariffs.py 2026-01-16 08:50:34 +03:00
Egor 4f3a849d03 Update tariffs.py 2026-01-16 08:50:08 +03:00
Egor 9eec5ef9c7 Update monitoring_service.py 2026-01-16 08:40:52 +03:00
Egor 7106c89711 Update subscription.py 2026-01-16 08:40:01 +03:00
Egor fd947bc562 Update subscription_service.py 2026-01-16 08:36:37 +03:00
Egor b088328439 Update tariffs.py 2026-01-16 08:36:08 +03:00
Egor dcaf2b8103 Update tariff.py 2026-01-16 08:35:26 +03:00
Egor 1024a88d19 Add files via upload 2026-01-16 08:34:56 +03:00
Egor d98baf23aa Merge pull request #2296 from BEDOLAGA-DEV/main
w
2026-01-16 08:34:23 +03:00
Egor 192f014180 Update Dockerfile 2026-01-16 08:34:01 +03:00
Egor 7f6be1c8f0 Update docker-registry.yml 2026-01-16 08:33:48 +03:00
Egor cfe780122b Update docker-hub.yml 2026-01-16 08:33:36 +03:00
Egor 4b7dcbbca4 Update subscription.py 2026-01-16 07:25:00 +03:00
Egor 81bda22cb7 Update tariffs.py 2026-01-16 06:29:22 +03:00
Egor 9230efcd31 Update admin_tariffs.py 2026-01-16 06:29:00 +03:00
Egor 7cacc73187 Update tariff.py 2026-01-16 06:18:20 +03:00
Egor 53d1828d54 Update admin_tariffs.py 2026-01-16 06:17:44 +03:00
Egor 31fbccca26 Update tariff_purchase.py 2026-01-16 06:12:36 +03:00
Egor dbf4c1f521 Update users.py 2026-01-16 06:12:01 +03:00
Egor a089947aee Update subscription.py 2026-01-16 06:11:34 +03:00
Egor 1337339d23 Update tariffs.py 2026-01-16 06:03:32 +03:00
Egor 0a3f67a1f0 Update tariffs.py 2026-01-16 06:02:47 +03:00
Egor 1c1a15984e Update subscription.py 2026-01-16 06:02:22 +03:00
Egor e22e786c88 Update tariff.py 2026-01-16 06:01:50 +03:00
Egor a32e3fc582 Add files via upload 2026-01-16 06:01:23 +03:00
Egor a7303d765f Update states.py 2026-01-16 06:00:50 +03:00
Egor 4f66e947b9 Update subscription.py 2026-01-16 05:30:01 +03:00
Egor 46efd56e20 Update subscription.py 2026-01-16 05:21:00 +03:00
Egor 2c124f1343 Update subscription.py 2026-01-16 05:13:25 +03:00
Egor 68ce06e97e Update subscription.py 2026-01-16 05:06:54 +03:00
Egor 577d731adb Merge pull request #2295 from BEDOLAGA-DEV/dev5
Dev5
2026-01-16 04:58:38 +03:00
Egor f2e25fe585 Update admin_apps.py 2026-01-16 04:57:54 +03:00
Egor 65c087aefb Update system_settings_service.py 2026-01-16 04:57:04 +03:00
Egor be9d7290cb Update config.py 2026-01-16 04:56:34 +03:00
Egor 9d431bc576 Merge pull request #2294 from BEDOLAGA-DEV/main
w
2026-01-16 04:56:13 +03:00
Egor 0e907d80fa Update balance.py 2026-01-16 04:16:20 +03:00
Egor 192381d148 Update balance.py 2026-01-16 04:10:11 +03:00
Egor 73bf9b757a Update balance.py 2026-01-16 04:09:46 +03:00
Egor 0a67a55c5e Add files via upload 2026-01-16 03:59:49 +03:00
Egor 3ef4183650 Update admin_stats.py 2026-01-16 03:52:35 +03:00
Egor 365bcd15a5 Update admin_stats.py 2026-01-16 03:35:41 +03:00
Egor 6483b37bd3 Update admin_stats.py 2026-01-16 03:28:47 +03:00
Egor ec9eaf4271 Update subscription_conversion.py 2026-01-16 03:28:01 +03:00
Egor 1c03cd8425 Update admin_stats.py 2026-01-16 03:19:21 +03:00
Egor 176d17fe40 Update subscription_conversion.py 2026-01-16 03:18:53 +03:00
Egor 4e69e0ea0f Add files via upload 2026-01-16 02:24:46 +03:00
Egor e33995c446 Update admin_tickets.py 2026-01-16 02:15:39 +03:00
Egor a72e029d1d Update balance.py 2026-01-16 01:59:54 +03:00
Egor d07390f379 Update subscription.py 2026-01-16 01:42:36 +03:00
Egor 4278a84231 Merge pull request #2293 from BEDOLAGA-DEV/dev5
Update referral.py
2026-01-16 01:16:48 +03:00
Egor 088236e136 Update referral.py 2026-01-16 01:16:16 +03:00
Egor 53db561909 Merge pull request #2292 from BEDOLAGA-DEV/dev5
Dev5
2026-01-16 00:59:50 +03:00
Egor 6d2cb32e5f Update subscription.py 2026-01-16 00:58:44 +03:00
Egor 7b23c61703 Add files via upload 2026-01-16 00:57:38 +03:00
Egor 12f592a3dc Merge pull request #2291 from BEDOLAGA-DEV/dev5
Dev5
2026-01-16 00:52:39 +03:00
Egor 9422b9421f Update miniapp.py 2026-01-16 00:51:53 +03:00
Egor 5b7fdff186 Update traffic.py 2026-01-16 00:50:59 +03:00
Egor 395646168d Update subscription.py 2026-01-16 00:50:03 +03:00
Egor 572584710c Merge pull request #2290 from BEDOLAGA-DEV/dev5
Dev5
2026-01-16 00:20:48 +03:00
Egor 23f0be4915 Update info.py 2026-01-15 18:43:34 +03:00
Egor 79904b58a9 Update config.py 2026-01-15 18:42:23 +03:00
Egor bba3b478f5 Merge pull request #2289 from BEDOLAGA-DEV/dev5
Dev5
2026-01-15 17:59:47 +03:00
Egor 829da095e0 Update balance.py 2026-01-15 17:59:21 +03:00
Egor 8bafcc89f5 Update balance.py 2026-01-15 17:58:52 +03:00
Egor f7563dc57c Merge pull request #2288 from BEDOLAGA-DEV/dev5
Dev5
2026-01-15 17:42:58 +03:00
Egor a80f6f8700 Add files via upload 2026-01-15 17:42:17 +03:00
Egor 2e77c5b74e Update miniapp.py 2026-01-15 17:34:39 +03:00
Egor 75108682cc Update miniapp.py 2026-01-15 17:34:16 +03:00
Egor 7113a381c5 Add files via upload 2026-01-15 17:33:28 +03:00
Egor 26865059b8 Update traffic.py 2026-01-15 17:32:20 +03:00
Egor c4382ff69d Add files via upload 2026-01-15 17:31:39 +03:00
Egor 13770a01c6 Add files via upload 2026-01-15 17:31:15 +03:00
Egor a2a09c8914 Update menu.py 2026-01-15 17:02:04 +03:00
Egor bcd3c26843 Update subscription_auto_purchase_service.py 2026-01-15 17:01:35 +03:00
Egor 7f65d398bf Update models.py 2026-01-15 16:56:19 +03:00
PEDZEO a53219525f Merge pull request #2284 from BEDOLAGA-DEV/buttons
Enhance ban notification system with delete functionality and improve…
2026-01-14 14:56:43 +03:00
PEDZEO c868ef3b69 Enhance ban notification system with delete functionality and improved message formatting
- Added a new handler to delete ban notifications upon user interaction.
- Introduced a delete button in ban notifications for better user experience.
- Updated ban notification messages to include node information more prominently.
- Refactored the BanNotificationService to send messages with the delete button included.
2026-01-14 14:50:00 +03:00
PEDZEO 34b87cba88 Merge pull request #2283 from BEDOLAGA-DEV/buttons
Enhance ban notification messages and service to include node informa…
2026-01-14 14:10:24 +03:00
PEDZEO 3aa5e304c3 Enhance ban notification messages and service to include node information
- Updated ban notification messages to provide detailed reasons for account bans, including node information.
- Refactored the BanNotificationService to safely format messages with optional node details.
- Modified API routes and schemas to support the inclusion of node names in ban notifications.
2026-01-14 14:03:09 +03:00
Egor 4b338b3ce2 Update subscription.py 2026-01-14 09:35:16 +03:00
Egor ba4c41a689 Update subscription.py 2026-01-14 09:22:54 +03:00
Egor e4c4b11d74 Update subscription.py 2026-01-14 09:17:49 +03:00
Egor 5fa4cf78c0 Update subscription.py 2026-01-14 09:00:20 +03:00
Egor d2c2f3ced0 Update subscription.py 2026-01-14 08:59:46 +03:00
Egor 7a65dd7fb6 Update branding.py 2026-01-14 08:17:57 +03:00
Egor e33e8bcc2d Update subscription.py 2026-01-14 07:38:59 +03:00
PEDZEO b49031ea72 Merge pull request #2282 from BEDOLAGA-DEV/buttons
Add ban notification messages and refactor notification service
2026-01-14 07:33:02 +03:00
PEDZEO c02c5472a5 Update BanNotificationRequest schema to include new notification type for mobile network bans 2026-01-14 07:31:09 +03:00
PEDZEO 243eef066c Add ban notification messages and refactor notification service
- Introduced new ban notification messages for device limit, WiFi, and mobile network violations in the configuration.
- Refactored the BanNotificationService to utilize the new messages from the configuration for sending notifications.
- Added a new method to handle mobile network ban notifications.
- Updated API routes to support the new notification type for mobile network bans.
2026-01-14 07:25:09 +03:00
PEDZEO e82b6cd4c0 Merge pull request #2281 from BEDOLAGA-DEV/buttons
Add tariff period selection for custom traffic in subscription flow
2026-01-14 06:52:09 +03:00
PEDZEO e1d3363122 Implement discount calculation for custom tariff periods in subscription flow
- Added logic to calculate and apply discounts based on the selected tariff period.
- Updated state management to store discount percentages for custom days and traffic changes.
- Enhanced the tariff price calculation to incorporate discounts when confirming selections.
- Modified the tariff preview to display applicable discounts for better user clarity.
2026-01-14 06:50:19 +03:00
PEDZEO 25623ea211 Add tariff period selection for custom traffic in subscription flow
- Introduced a new function to generate a keyboard for selecting tariff periods with custom traffic.
- Enhanced the tariff price calculation logic to separate period and traffic pricing.
- Updated the custom tariff preview formatting to reflect changes in pricing structure.
- Implemented a new handler for processing the selection of tariff periods with custom traffic.
2026-01-14 06:44:36 +03:00
PEDZEO 3557c8c285 Merge pull request #2280 from BEDOLAGA-DEV/buttons
Implement custom days and traffic handling in subscription purchase flow
2026-01-14 06:41:05 +03:00
PEDZEO 84abf529de Implement custom days and traffic handling in subscription purchase flow
- Added new states for selecting custom days and traffic in the subscription process.
- Enhanced the tariff purchase handler to support custom days and traffic adjustments.
- Introduced new functions for formatting and displaying custom tariff previews.
- Updated the ban notification service to include a new notification type for WiFi bans.
- Modified API routes and schemas to accommodate the new notification type and its parameters.
2026-01-14 06:37:59 +03:00
Egor 48effb5087 Update subscription.py 2026-01-14 06:32:51 +03:00
Egor 237bd24fb3 Update subscription.py 2026-01-14 06:32:24 +03:00
Egor 6bea51b6dc Update config.py 2026-01-14 04:14:29 +03:00
Egor 834c542a57 Update yookassa.py 2026-01-14 04:13:02 +03:00
Egor 9c2b17af3b Update subscription.py 2026-01-14 04:12:18 +03:00
Egor c9d1a6bb55 Update stars_payments.py 2026-01-14 04:11:44 +03:00
Egor a174943bcc Update subscription.py 2026-01-14 03:35:48 +03:00
Egor b651867aac Add files via upload 2026-01-14 03:05:04 +03:00
Egor 10b5b37c48 Update wheel_service.py 2026-01-14 02:52:56 +03:00
Egor f19445bcf1 Update pricing.py 2026-01-14 02:52:22 +03:00
Egor 171b4e2ab4 Update main.py 2026-01-14 02:51:57 +03:00
Egor a1ab2f3727 Update tariff.py 2026-01-14 02:39:58 +03:00
Egor 58eae42eee Add files via upload 2026-01-13 16:51:19 +03:00
Egor 6937bcbc3d Update pricing.py 2026-01-13 16:30:52 +03:00
Egor 4730cf30e9 Update config.py 2026-01-13 16:30:27 +03:00
Egor ce7d7a206c Update config.py 2026-01-13 16:24:48 +03:00
Egor fe4e261115 Merge pull request #2275 from BEDOLAGA-DEV/buttons
Buttons
2026-01-13 03:33:39 +03:00
Egor 38613722b3 Merge pull request #2274 from SayonaraQ/fix/tickets-timezone
Fix ticket timestamps: render in local timezone
2026-01-13 03:29:00 +03:00
PEDZEO a686333603 Add support for custom days and traffic in tariffs
- Introduced fields for custom days and traffic in the tariff model, including enabling flags, pricing, and limits.
- Updated relevant routes and schemas to handle new tariff features.
- Implemented logic for purchasing and managing custom days and traffic in subscriptions.
- Added database migration scripts to accommodate new columns for tariffs and subscriptions.
2026-01-13 02:55:32 +03:00
Egor eac299d0bd Update backup_service.py 2026-01-13 01:37:46 +03:00
Egor 31241a6f01 Update backup_service.py 2026-01-13 01:31:02 +03:00
Egor e5faaa6c6f Update index.html 2026-01-13 01:27:07 +03:00
SayonaraQ 96384cfdcf Fix ticket timestamps: render in local timezone 2026-01-13 01:09:03 +03:00
Egor 91f75acb77 Update balance.py 2026-01-13 01:07:46 +03:00
Egor 11167d532b Add files via upload 2026-01-13 01:07:12 +03:00
Egor 70e747b866 Add files via upload 2026-01-13 01:06:13 +03:00
Egor 87f909d9bc Update admin.py 2026-01-13 01:05:36 +03:00
Egor 808bc2d40b Update miniapp.py 2026-01-13 01:04:50 +03:00
Egor 2f06285f69 Update messages.py 2026-01-13 00:53:08 +03:00
Egor 7fb7116101 Update messages.py 2026-01-13 00:52:02 +03:00
Egor 9d1b4bcb54 Update users.py 2026-01-13 00:22:32 +03:00
Egor 1583511854 Update tariff_purchase.py 2026-01-13 00:22:04 +03:00
Egor 45d7c2f659 Merge pull request #2273 from BEDOLAGA-DEV/dev5
Dev5
2026-01-13 00:08:04 +03:00
Egor e92b14391b Update config.py 2026-01-13 00:07:09 +03:00
Egor 61cc2a72cd Update display_name_restriction.py 2026-01-13 00:06:42 +03:00
Egor 9b3a39b13f Update balance.py 2026-01-12 23:53:04 +03:00
Egor 1b51886dd1 Update yookassa.py 2026-01-12 23:51:39 +03:00
Egor cd5116e62f Merge pull request #2272 from BEDOLAGA-DEV/dev5
Update messages.py
2026-01-12 23:31:50 +03:00
Egor a9499337a0 Update messages.py 2026-01-12 23:31:28 +03:00
Egor 2745b015d7 Update miniapp.py 2026-01-12 23:25:32 +03:00
PEDZEO 8e44db357a Enhance _message_to_response function to include media_file_id in the response 2026-01-12 22:26:47 +03:00
PEDZEO ac5850746e Merge pull request #2271 from BEDOLAGA-DEV/main
merge main
2026-01-12 22:25:49 +03:00
Egor c36d51be30 Update index.html 2026-01-12 20:06:37 +03:00
Egor e9a9b85791 Update index.html 2026-01-12 20:03:47 +03:00
Egor 43548fdb27 Update index.html 2026-01-12 20:00:28 +03:00
Egor 4e3b6a081c Update index.html 2026-01-12 19:48:17 +03:00
Egor a2380b937c Update index.html 2026-01-12 19:44:07 +03:00
Egor fe9feaf65d Update miniapp.py 2026-01-12 19:43:38 +03:00
Egor cec597cfd9 Update miniapp.py 2026-01-12 19:34:04 +03:00
Egor 40f1312d0b Update backup_service.py 2026-01-12 19:29:03 +03:00
Egor e4d54677fa Update miniapp.py 2026-01-12 19:25:25 +03:00
Egor f59614f14f Update index.html 2026-01-12 19:24:53 +03:00
Egor c42444429e Update index.html 2026-01-12 19:22:18 +03:00
Egor 7692e85ef0 Update index.html 2026-01-12 19:17:16 +03:00
Egor 5fdbb6fe7e Merge pull request #2270 from BEDOLAGA-DEV/dev5
Daily Tariffs / Bug fixs / promocode fixs / db dublicate session fix
2026-01-12 19:06:54 +03:00
Egor 26f3d4e052 Update purchase.py 2026-01-12 18:57:50 +03:00
Egor fab17702c5 Update miniapp.py 2026-01-12 18:57:13 +03:00
Egor 40fad0d537 Update subscription.py 2026-01-12 18:56:24 +03:00
Egor 67f60ba41a Update subscription.py 2026-01-12 18:44:34 +03:00
Egor eb40d8f48a Update miniapp.py 2026-01-12 18:43:54 +03:00
Egor e987b25e6b Add files via upload 2026-01-12 18:26:54 +03:00
Egor 25f2a9507d Update index.html 2026-01-12 18:26:00 +03:00
Egor db54b01f04 Update subscription.py 2026-01-12 18:16:59 +03:00
Egor df17f2be4a Update index.html 2026-01-12 18:13:02 +03:00
Egor 26e242cac7 Update subscription.py 2026-01-12 18:12:14 +03:00
Egor bcef64bafa Update miniapp.py 2026-01-12 18:11:21 +03:00
Egor 97711ac735 Update index.html 2026-01-12 18:01:34 +03:00
Egor 0ac2a7a62b Update miniapp.py 2026-01-12 17:58:50 +03:00
Egor c350195bfc Update miniapp.py 2026-01-12 17:58:25 +03:00
Egor 6e8c9bda30 Update channel_checker.py 2026-01-12 17:39:58 +03:00
Egor ae94ed1dcf Update subscription_checker.py 2026-01-12 17:36:31 +03:00
Egor d9514ac00e Add files via upload 2026-01-12 17:33:52 +03:00
Egor a9e56504a7 Update menu.py 2026-01-12 17:33:11 +03:00
Egor 0f8a367b24 Add files via upload 2026-01-12 17:26:16 +03:00
Egor 09bb52f8e5 Add files via upload 2026-01-12 17:25:47 +03:00
Egor 0903f42da9 Add files via upload 2026-01-12 17:20:55 +03:00
Egor 3839f6f709 Add files via upload 2026-01-12 17:20:13 +03:00
Egor 0759657185 Update purchase.py 2026-01-12 17:00:05 +03:00
Egor 71ff89ca55 Update monitoring_service.py 2026-01-12 16:56:00 +03:00
Egor 030acf07a4 Update tariff_purchase.py 2026-01-12 16:47:52 +03:00
Egor 891b388799 Add files via upload 2026-01-12 16:41:36 +03:00
Egor 92a6231b6f Update tariff_purchase.py 2026-01-12 16:40:42 +03:00
Egor 7aac2fadf7 Update tariff_purchase.py 2026-01-12 16:33:47 +03:00
Egor 640a80953f Update subscription.py 2026-01-12 16:31:00 +03:00
Egor 2090da3603 Update inline.py 2026-01-12 16:30:24 +03:00
Egor 62bec70db9 Update daily_subscription_service.py 2026-01-12 16:29:59 +03:00
Egor 8c5a385f14 Update tariff_purchase.py 2026-01-12 16:29:31 +03:00
Egor 78c44a1e00 Update tariff_purchase.py 2026-01-12 16:19:17 +03:00
Egor 1c23538b71 Update tariff_purchase.py 2026-01-12 16:14:14 +03:00
Egor 4967c7ff7d Update daily_subscription_service.py 2026-01-12 16:05:31 +03:00
Egor 2b1a20e373 Update purchase.py 2026-01-12 15:59:22 +03:00
Egor 6fc3dd0e35 Update purchase.py 2026-01-12 15:49:25 +03:00
Egor 7c4471a510 Update inline.py 2026-01-12 15:48:51 +03:00
Egor 24666dd155 Update tariffs.py 2026-01-12 15:48:20 +03:00
Egor 42978e2a37 Update inline.py 2026-01-12 15:27:37 +03:00
Egor 22884b5cdd Update purchase.py 2026-01-12 15:27:05 +03:00
Egor 785eabaa47 Update tariffs.py 2026-01-12 15:26:20 +03:00
Egor 538c002f8f Add files via upload 2026-01-12 15:25:47 +03:00
Egor 472ef37490 Add files via upload 2026-01-12 15:25:25 +03:00
Egor 9e4fa9defe Add files via upload 2026-01-12 15:24:59 +03:00
Egor 49bd69f987 Add files via upload 2026-01-12 15:24:17 +03:00
Egor e15aed6c19 Update main.py 2026-01-12 15:23:27 +03:00
Egor 2e4e62d324 Update index.html 2026-01-12 14:31:13 +03:00
Egor b7137c9498 Merge pull request #2261 from evansvl/main
Fix formatting and improve readability in README.md; update configuration tables and enhance descriptions for better clarity.
2026-01-12 13:58:06 +03:00
PEDZEO 15f6108674 feat(tariffs): добавлена поддержка докупки трафика и улучшения тарифов
- Реализована возможность докупки трафика для тарифов с новыми параметрами: traffic_topup_enabled, traffic_topup_packages и max_topup_traffic_gb.
- Обновлены схемы и маршруты для управления тарифами и трафиком.
- Добавлены новые эндпоинты для работы с докупкой трафика в мини-приложении.
- Обновлены настройки и логика для проверки доступности докупки трафика в зависимости от тарифа.
- Внедрены улучшения в обработку платежей через Freekassa.

Обновлён .env.example с новыми параметрами для режима тарифов.
2026-01-12 07:47:35 +03:00
PEDZEO 0e24a5505c feat(subscription): добавлены новые функции для управления тарифами и трафиком
- Обновлены схемы и маршруты для поддержки покупки тарифов и управления трафиком.
- Реализована синхронизация тарифов и серверов из RemnaWave при запуске.
- Добавлены новые параметры в тарифы: server_traffic_limits и allow_traffic_topup.
- Обновлены настройки и логика для проверки доступности докупки трафика в зависимости от тарифа.
- Внедрены новые эндпоинты для работы с колесом удачи и обработка платежей через Stars.

Обновлён .env.example с новыми параметрами для режима продаж подписок.
2026-01-12 07:41:10 +03:00
PEDZEO ffbb879752 Merge pull request #2264 from BEDOLAGA-DEV/main
main
2026-01-12 07:38:37 +03:00
evansvl 8f1b71408e Fix formatting and improve readability in README.md; update configuration tables and enhance descriptions for better clarity. 2026-01-11 06:11:11 +03:00
Egor 62a8e6cdc6 Merge pull request #2260 from evansvl/main
Enhance Freekassa payment handling and improve Docker Compose configuration
2026-01-11 05:59:11 +03:00
Egor a18cfd0147 Update tariff_purchase.py 2026-01-11 05:57:01 +03:00
Egor 2619db3f40 Update miniapp.py 2026-01-11 05:56:24 +03:00
Egor fea2283d88 Update miniapp.py 2026-01-11 05:53:44 +03:00
Egor 479a2b8344 Update tariff_purchase.py 2026-01-11 05:52:59 +03:00
Egor 453577b448 Update miniapp.py 2026-01-11 05:47:56 +03:00
evansvl b5234f3265 Enhance Freekassa payment handling and improve Docker Compose configuration 2026-01-11 05:45:09 +03:00
evansvl edb335ee52 Reduce Freekassa API logging verbosity 2026-01-11 05:44:43 +03:00
Egor 0333720fca Update index.html 2026-01-11 05:21:27 +03:00
Egor 76b521879e Update miniapp.py 2026-01-11 05:21:03 +03:00
Egor 4a24dbe267 Update miniapp.py 2026-01-11 05:20:41 +03:00
Egor fdd40ed923 Update miniapp.py 2026-01-11 05:14:25 +03:00
Egor b2b48b8bde Merge pull request #2259 from evansvl/main
Add Freekassa webhook handling and configuration options
2026-01-11 05:12:56 +03:00
Egor 10f9622df7 Update index.html 2026-01-11 05:08:43 +03:00
Egor 1586a3fe9a Update miniapp.py 2026-01-11 05:08:21 +03:00
Egor d4556038cc Update miniapp.py 2026-01-11 05:07:54 +03:00
evansvl a54a12825e Add Freekassa webhook handling and configuration options 2026-01-11 05:06:07 +03:00
Egor efae4b0c44 Update miniapp.py 2026-01-11 05:01:36 +03:00
Egor cda70afdef Update miniapp.py 2026-01-11 04:57:32 +03:00
Egor ead8266e0e Update index.html 2026-01-11 04:56:54 +03:00
Egor daf8214c40 Update index.html 2026-01-11 04:54:13 +03:00
Egor 9dacd3be24 Update miniapp.py 2026-01-11 04:51:06 +03:00
Egor a7fa36ca9b Update index.html 2026-01-11 04:50:15 +03:00
Egor fae72d7107 Update index.html 2026-01-11 04:42:33 +03:00
Egor f94c1437e4 Update index.html 2026-01-11 04:31:57 +03:00
Egor a8d167ac9a Update index.html 2026-01-11 04:27:18 +03:00
Egor 8b9ff3a32b Merge pull request #2258 from BEDOLAGA-DEV/dev5
Dev5
2026-01-11 04:12:46 +03:00
Egor 8482e4caad Update index.html 2026-01-11 04:09:47 +03:00
Egor b2577d5973 Update inline.py 2026-01-11 04:09:19 +03:00
Egor 752abfc6b5 Update tariff_purchase.py 2026-01-11 04:08:48 +03:00
Egor b213f7deb4 Update miniapp.py 2026-01-11 04:08:11 +03:00
Egor 7dfbd833bb Update miniapp.py 2026-01-11 04:07:42 +03:00
Egor 83812e1d4c Merge pull request #2257 from BEDOLAGA-DEV/main
w
2026-01-11 04:00:43 +03:00
Egor 21b2add7ac Update miniapp.py 2026-01-11 03:40:33 +03:00
Egor 1f52c39fed Update miniapp.py 2026-01-11 03:33:07 +03:00
Egor b9c9a82b6f Update miniapp.py 2026-01-11 03:28:46 +03:00
Egor 0a0f59ea24 Update miniapp.py 2026-01-11 03:22:07 +03:00
Egor aa6935464b Update universal_migration.py 2026-01-11 03:04:57 +03:00
Egor f50d6fc28a Merge pull request #2256 from BEDOLAGA-DEV/dev5
Dev5
2026-01-11 03:00:42 +03:00
Egor 3656f0181c Update tariffs.py 2026-01-11 02:59:48 +03:00
Egor 7c3b3188c1 Update states.py 2026-01-11 02:59:16 +03:00
Egor fc7d3d1823 Update index.html 2026-01-11 02:58:40 +03:00
Egor 3117d02c2f Update miniapp.py 2026-01-11 02:58:08 +03:00
Egor c2fe9f082f Update miniapp.py 2026-01-11 02:57:41 +03:00
Egor ce66c08e1a Update tariff.py 2026-01-11 02:56:46 +03:00
Egor c58faff2df Update models.py 2026-01-11 02:56:14 +03:00
Egor cb6aa4bad4 Merge pull request #2255 from BEDOLAGA-DEV/main
ц
2026-01-11 02:55:55 +03:00
Egor 11a35e1058 Update index.html 2026-01-11 02:28:20 +03:00
Egor d51cb4a02d Merge pull request #2254 from BEDOLAGA-DEV/dev5
Update miniapp.py
2026-01-11 02:21:25 +03:00
Egor deae83d2cd Update miniapp.py 2026-01-11 02:20:43 +03:00
Egor 0ad0462afb Merge pull request #2253 from BEDOLAGA-DEV/dev5
Dev5
2026-01-11 02:13:35 +03:00
Egor ca5f9af325 Update miniapp.py 2026-01-11 02:13:03 +03:00
Egor db98b20118 Update index.html 2026-01-11 02:12:22 +03:00
Egor 66cd99e2a8 Merge pull request #2252 from BEDOLAGA-DEV/dev5
Update index.html
2026-01-11 02:01:23 +03:00
Egor 217df39e10 Update index.html 2026-01-11 02:00:59 +03:00
Egor 752850e56b Update Dockerfile 2026-01-11 00:08:34 +03:00
Egor 6a1eb3a530 Update docker-registry.yml 2026-01-11 00:08:24 +03:00
Egor 2b89748384 Update docker-hub.yml 2026-01-11 00:08:14 +03:00
Egor 638c60b39b Merge pull request #2251 from BEDOLAGA-DEV/dev5
Update index.html
2026-01-10 22:13:37 +03:00
Egor f27b6c55fd Update index.html 2026-01-10 22:12:51 +03:00
Egor e5c1d4166a Merge pull request #2250 from BEDOLAGA-DEV/dev5
Update miniapp.py
2026-01-10 21:58:46 +03:00
Egor 34a5b0345f Update miniapp.py 2026-01-10 21:58:04 +03:00
Egor c9f67e6db3 Merge pull request #2249 from BEDOLAGA-DEV/dev5
Update change tariffs logic / Traffic buy / Freekassa updates / ux updates
2026-01-10 21:46:33 +03:00
Egor 8eaec9b032 Add files via upload 2026-01-10 21:44:17 +03:00
Egor 39c3ec9ed4 Add files via upload 2026-01-10 21:43:29 +03:00
Egor a598237465 Update index.html 2026-01-10 21:37:17 +03:00
Egor c5a35cd254 Update miniapp.py 2026-01-10 21:36:31 +03:00
Egor c7a51915c9 Update miniapp.py 2026-01-10 21:35:59 +03:00
Egor 29725d6028 Update index.html 2026-01-10 21:31:41 +03:00
Egor 60846ad06b Update miniapp.py 2026-01-10 21:29:55 +03:00
Egor 7a86cb6010 Update miniapp.py 2026-01-10 21:25:03 +03:00
Egor 16e70bff46 Update miniapp.py 2026-01-10 21:14:40 +03:00
Egor 4c947a3de9 Update miniapp.py 2026-01-10 21:14:06 +03:00
Egor 2c4b77ff8c Update inline.py 2026-01-10 21:05:58 +03:00
Egor e6676720f2 Update inline.py 2026-01-10 20:57:49 +03:00
Egor 83769df0ee Update tariff_purchase.py 2026-01-10 20:52:25 +03:00
Egor b2e00ecbbe Update user_service.py 2026-01-10 20:48:42 +03:00
Egor 7ea906bd1e Update tariff_purchase.py 2026-01-10 20:41:59 +03:00
Egor 744428e4f6 Update tariff.py 2026-01-10 20:37:40 +03:00
Egor 5d1d561882 Update subscription.py 2026-01-10 20:33:55 +03:00
Egor 69f4fd2b2a Update tariffs.py 2026-01-10 20:32:57 +03:00
Egor 8690d5fa8e Update traffic.py 2026-01-10 20:32:20 +03:00
Egor 209ce2c25a Update universal_migration.py 2026-01-10 20:30:47 +03:00
Egor 90d090a048 Update models.py 2026-01-10 20:30:30 +03:00
Egor e66944e2bf Update inline.py 2026-01-10 20:29:53 +03:00
Egor 57e4ccea28 Update states.py 2026-01-10 20:29:22 +03:00
Egor 011f4428e2 Update config.py 2026-01-10 19:41:18 +03:00
Egor 4d62fa946b Update freekassa_service.py 2026-01-10 19:40:56 +03:00
Egor fc9552a1bf Add files via upload 2026-01-10 18:38:58 +03:00
Egor af5401a393 Update freekassa_service.py 2026-01-10 18:38:20 +03:00
Egor 3685af771e Update miniapp.py 2026-01-10 18:28:48 +03:00
Egor bc7470b39d Update subscription.py 2026-01-10 18:20:35 +03:00
Egor cd0a25abec Update users.py 2026-01-10 18:17:10 +03:00
Mikhail 553e8e1ecd Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-10 00:47:40 +03:00
gy9vin 6e1d671df2 feat(traffic): добавлен новый мониторинг трафика v2 с проверкой дельты и snapshot
Новый функционал:
- Быстрая проверка (TRAFFIC_FAST_CHECK_*) — отслеживает дельту трафика за интервал через snapshot
- Суточная проверка (TRAFFIC_DAILY_CHECK_*) — анализирует трафик за 24 часа через bandwidth API
- Фильтрация по нодам (TRAFFIC_MONIT
2026-01-10 00:47:23 +03:00
Egor b482c76a5c Merge pull request #2248 from BEDOLAGA-DEV/dev5
Dev5
2026-01-09 19:31:47 +03:00
Egor 4e6d5b4716 Update subscription.py 2026-01-09 19:30:53 +03:00
Egor 879650c2e4 Update webhook_server.py 2026-01-09 13:11:40 +03:00
Egor d9ce09b3bf Update ru.json 2026-01-08 23:14:40 +03:00
Egor 678bd18509 Merge pull request #2247 from BEDOLAGA-DEV/main
ц
2026-01-08 23:14:18 +03:00
Egor 805e3847b9 Merge pull request #2234 from Gy9vin/main
Обновки
2026-01-08 14:43:22 +03:00
gy9vin eaeb6def51 feat(config): добавлено предупреждение после пополнения и обновлён .env.example
Новый функционал:
- SHOW_ACTIVATION_PROMPT_AFTER_TOPUP — показывает предупреждение с кнопками
  "Активировать", "Продлить", "Добавить устройства" после пополнения баланса,
  если подписка не активна (режим для новичков)

Обновлён .env.example (+138 строк):
- Redis: CART_TTL_SECONDS
- Remnawave: AUTO_SYNC_*, TRIAL_USER_TAG, PAID_SUBSCRIPTION_USER_TAG
- Трафик: BUY_TRAFFIC_BUTTON_VISIBLE, PRICE_TRAFFIC_UNLIMITED
- Автопродление: ENABLE_AUTOPAY
- Конкурсы: REFERRAL_CONTESTS_ENABLED
- YooKassa: TRUSTED_PROXY_NETWORKS
- Mulenpay: DISPLAY_NAME, IFRAME_EXPECTED_ORIGIN, DISPLAY_NAME_BANNED_KEYWORDS
- Platega: DISPLAY_NAME
- WATA: PUBLIC_KEY_CACHE_SECONDS, PUBLIC_KEY_URL
- CloudPayments: API_URL, WIDGET_URL, RETURN_URL
- Интерфейс: MENU_LAYOUT_ENABLED, MINIAPP_PURCHASE_URL, HAPP_DOWNLOAD_LINK_PC
- Web API: WORKERS, TITLE, VERSION, TOKEN_HASH_ALGORITHM, REQUEST_LOGGING,
  EXTERNAL_ADMIN_TOKEN, EXTERNAL_ADMIN_TOKEN_BOT_ID

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:16:52 +03:00
gy9vin 4c1ebd9f61 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2026-01-08 11:00:38 +03:00
gy9vin 51b88068bd fix(payment): добавлен параметр bot в auto_activate_subscription_after_topup
- Передача bot через getattr(self, "bot", None) во всех платёжных провайдерах
  - Добавлена отправка предупреждений пользователю при отключенной автоактивации
  - Добавлены предупреждения о необходимости активации подписки после пополнения
2026-01-08 11:00:34 +03:00
Mikhail f25ed360fc Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-08 08:12:45 +03:00
Egor 75b1fff791 Delete docs/web-api-analysis-and-suggestions.md 2026-01-08 04:55:31 +03:00
Egor 4ed23caa68 Update README.md 2026-01-08 04:41:31 +03:00
Egor 098e297e3a Update README.md 2026-01-08 04:40:39 +03:00
Egor 20315cf88f Update README.md 2026-01-08 04:37:18 +03:00
Egor 28c953c5ae Update Dockerfile 2026-01-08 03:45:21 +03:00
Egor 685305bff2 Update docker-registry.yml 2026-01-08 03:45:09 +03:00
Egor 927eb1d240 Update docker-hub.yml 2026-01-08 03:44:57 +03:00
Egor c767473184 Merge pull request #2246 from BEDOLAGA-DEV/dev5
Freekassa fix / campaign fix
2026-01-08 03:30:28 +03:00
Egor 744e5c1453 Update campaign_service.py 2026-01-08 03:28:49 +03:00
Egor 5eb581c68e Update .env.example 2026-01-08 03:19:32 +03:00
Egor cf53801408 Update freekassa.py 2026-01-08 03:18:56 +03:00
Egor 4e59d0a071 Update freekassa_service.py 2026-01-08 03:18:31 +03:00
Egor c31de445b9 Update config.py 2026-01-08 03:18:03 +03:00
Egor cc3f78c2f8 Update subscription.py 2026-01-08 03:07:31 +03:00
Egor 5eae547cb2 Update tariff_purchase.py 2026-01-08 02:57:50 +03:00
Egor 7e8e0c1617 Update tariff_purchase.py 2026-01-08 02:54:31 +03:00
Egor 250b8d95fb Update tariff_purchase.py 2026-01-08 02:45:26 +03:00
Egor 28e868fb09 Merge pull request #2245 from BEDOLAGA-DEV/main
ц
2026-01-08 02:44:47 +03:00
Egor 45e9a29b4e Update index.html 2026-01-08 02:36:25 +03:00
Egor 8a52b34760 Update index.html 2026-01-08 02:26:36 +03:00
Egor 464df9689e Update index.html 2026-01-08 01:33:36 +03:00
Egor 6191a7a395 Update index.html 2026-01-08 01:25:27 +03:00
Egor 233f6c3490 Update index.html 2026-01-08 01:02:18 +03:00
Egor 0e5b115d64 Update index.html 2026-01-08 00:52:25 +03:00
Egor 69bf99f3a6 Merge pull request #2244 from BEDOLAGA-DEV/dev5
Update index.html
2026-01-07 18:36:05 +03:00
Egor dae5c07318 Update index.html 2026-01-07 18:35:43 +03:00
Egor d53bf4ae29 Merge pull request #2243 from BEDOLAGA-DEV/dev5
Update index.html
2026-01-07 17:53:10 +03:00
Egor 50dd5a5fb3 Update index.html 2026-01-07 17:52:50 +03:00
Egor 9595026d3d Merge pull request #2242 from BEDOLAGA-DEV/dev5
Api update / Miniapp fix
2026-01-07 17:39:27 +03:00
Egor 571018982f Update index.html 2026-01-07 17:35:30 +03:00
Egor cfdfe1ccd1 Update miniapp.py 2026-01-07 17:34:39 +03:00
Egor d9f0d6496d Update miniapp.py 2026-01-07 17:33:56 +03:00
Egor 0eba4cfc8f Update remnawave_api.py 2026-01-07 17:25:06 +03:00
gy9vin 6392033579 fix(referral): обновлены методы отправки уведомлений админам
- Заменён метод send_notification на send_to_admins в AdminNotificationService
  - Исправлена настройка NOTIFICATIONS_CHAT_ID на ADMIN_NOTIFICATIONS_CHAT_ID для отправки в топик
2026-01-07 17:00:34 +03:00
gy9vin 2607ee4d0a fix(referral-withdrawal): исправления тестового режима вывода
1. Исправлена кнопка "Профиль" после тестового начисления
     - callback изменён с admin_user_{id} на admin_user_manage_{id}

  2. Исправлена логика расчёта доступного баланса
     - Добавлен метод get_first_referral_earning_date()
     - Добавлен метод get_user_spending_after_first_earning()
     - Теперь учитываются только траты ПОСЛЕ первого реф. начисления
     - Старые траты больше не уменьшают доступный реферальный баланс

  3. Добавлен bypass cooldown в тестовом режиме
     - При REFERRAL_WITHDRAWAL_TEST_MODE=true 30-дневный cooldown пропускается
2026-01-07 16:05:42 +03:00
gy9vin 3299d47b11 merge: resolve conflict in universal_migration.py 2026-01-07 15:05:14 +03:00
gy9vin 4afefcafa4 Добавлена система вывода реферального баланса
Новая функциональность вывода средств:
  - config.py: добавлены настройки вывода (минимальная сумма, кулдаун, анализ подозрительности, тестовый режим)
  - models.py: добавлена модель WithdrawalRequest с полями для заявок, анализа рисков и обработки админ
2026-01-07 14:54:50 +03:00
Mikhail c582e1b0c6 Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-07 14:24:08 +03:00
Egor 3eb84338a4 Merge pull request #2241 from BEDOLAGA-DEV/dev5
Tariffs
2026-01-07 05:14:30 +03:00
Egor 127a609d6b Update .env.example 2026-01-07 05:12:18 +03:00
Egor 528944f649 Update tariff_purchase.py 2026-01-07 05:06:25 +03:00
Egor 48fa739ca7 Update tariff_purchase.py 2026-01-07 04:48:27 +03:00
Egor 07e50f449f Update tariff_purchase.py 2026-01-07 04:40:09 +03:00
Egor 47433d905e Update admin.py 2026-01-07 04:30:54 +03:00
Egor b096003683 Update tariff_purchase.py 2026-01-07 04:26:31 +03:00
Egor 5814bbc920 Update tariff_purchase.py 2026-01-07 04:18:46 +03:00
Egor fdffe7ae35 Update traffic.py 2026-01-07 04:08:33 +03:00
Egor 917fa84838 Update service.py 2026-01-07 04:07:52 +03:00
Egor 9631f340c1 Update config.py 2026-01-07 03:58:44 +03:00
Egor 61fe7f0be4 Update inline.py 2026-01-07 03:49:31 +03:00
Egor a8c54c4d3b Update miniapp.py 2026-01-07 03:48:49 +03:00
Egor db7e6cf8f8 Add files via upload 2026-01-07 03:48:02 +03:00
Egor 9690436b9f Update tariffs.py 2026-01-07 03:47:28 +03:00
Egor fefc46e5c4 Update miniapp.py 2026-01-07 03:38:52 +03:00
Egor 14fc1f58af Update states.py 2026-01-07 03:38:13 +03:00
Egor f933992883 Update purchase.py 2026-01-07 03:31:56 +03:00
Egor fdc382f309 Update tariffs.py 2026-01-07 03:31:17 +03:00
Egor 0ef3e32c15 Update miniapp.py 2026-01-07 03:20:58 +03:00
Egor 9c870ffc70 Update purchase.py 2026-01-07 03:20:17 +03:00
Egor 6ce87698bc Update tariff.py 2026-01-07 03:19:32 +03:00
Egor 60a38d3ea6 Add files via upload 2026-01-07 03:18:46 +03:00
Egor 5355f41bef Update states.py 2026-01-07 03:06:11 +03:00
Egor 0fac84aa7d Update tariffs.py 2026-01-07 03:05:31 +03:00
Egor b4e472b873 Add files via upload 2026-01-07 03:04:44 +03:00
Egor 6c0c3f1b79 Update inline.py 2026-01-07 03:03:55 +03:00
Egor 203405438f Update tariff.py 2026-01-07 03:03:18 +03:00
Egor a38c12eaa6 Add files via upload 2026-01-07 03:02:47 +03:00
Egor e48ebca91e Update inline.py 2026-01-07 02:55:26 +03:00
Egor e32bf5f2c4 Add files via upload 2026-01-07 02:54:47 +03:00
Egor fbb7d6c4ab Update tariffs.py 2026-01-07 02:45:42 +03:00
Egor 29f967469f Update tariff_purchase.py 2026-01-07 02:42:07 +03:00
Egor 541d3c903d Update subscription_auto_purchase_service.py 2026-01-07 02:37:30 +03:00
Egor f19e2b2a34 Update ru.json 2026-01-07 02:36:43 +03:00
Egor 3d0050139c Refactor personal discount retrieval in tariff_purchase 2026-01-07 02:36:02 +03:00
Egor 0b677e4205 Update tariffs.py 2026-01-07 02:35:27 +03:00
Egor 71dac493d8 Update tariffs.py 2026-01-07 02:29:37 +03:00
Egor fc1528532e Update index.html 2026-01-07 02:25:48 +03:00
Egor 48cb19170b Update miniapp.py 2026-01-07 02:24:30 +03:00
Egor 1af1919a14 Update miniapp.py 2026-01-07 02:23:58 +03:00
Egor 5d864d0286 Introduce tariff creation and editing states
Added states for creating and editing tariffs.
2026-01-07 02:23:21 +03:00
Egor a448a2c450 Add files via upload 2026-01-07 02:22:36 +03:00
Egor e301d49657 Add tariffs button to admin keyboard 2026-01-07 02:21:42 +03:00
Egor 738216cf9f Add tariff change button for tariff mode 2026-01-07 02:21:15 +03:00
Egor 7b6f646d7e Update bot.py 2026-01-07 02:20:49 +03:00
Egor 3150349ffa Update config.py 2026-01-07 02:17:18 +03:00
Egor b50478eda0 Add files via upload 2026-01-07 02:16:33 +03:00
Egor cff00eb515 Add files via upload 2026-01-07 02:16:00 +03:00
Egor a981bf2ae0 Add files via upload 2026-01-07 02:15:16 +03:00
Egor 031c2b683b Add files via upload 2026-01-07 02:14:41 +03:00
Egor 72d2501be5 Merge pull request #2240 from BEDOLAGA-DEV/dev5
Update start.py
2026-01-06 22:59:30 +03:00
Egor 69ff87ab31 Update start.py 2026-01-06 22:57:21 +03:00
Egor e4fce0f430 Merge pull request #2239 from BEDOLAGA-DEV/dev5
Update start.py
2026-01-06 22:51:38 +03:00
Egor 5fb0699dcb Update start.py 2026-01-06 22:51:18 +03:00
Egor 965ae9d1d8 Merge pull request #2238 from BEDOLAGA-DEV/revert-2237-revert-2236-dev5
Revert "Revert "Frekassa""
2026-01-06 22:43:11 +03:00
Egor 23b64ffb2a Revert "Revert "Frekassa"" 2026-01-06 22:43:01 +03:00
Egor 9868c5de0f Merge pull request #2237 from BEDOLAGA-DEV/revert-2236-dev5
Revert "Frekassa"
2026-01-06 22:40:45 +03:00
Egor aeaaa54920 Revert "Frekassa" 2026-01-06 22:40:35 +03:00
Egor fb25032284 Merge pull request #2236 from BEDOLAGA-DEV/dev5
Frekassa
2026-01-06 21:43:35 +03:00
Egor 3e0661da39 Update index.html 2026-01-06 21:02:20 +03:00
Egor 7ab9fe9ad2 Update .env.example 2026-01-06 21:01:56 +03:00
Egor 7c846c2f83 Update miniapp.py 2026-01-06 21:01:20 +03:00
Egor 163f55ec14 Update config.py 2026-01-06 21:00:04 +03:00
Egor 2c8e67ac82 Implement Freekassa webhook handler
Added support for Freekassa webhook handling and logging.
2026-01-06 20:59:38 +03:00
Egor e9ce583eb2 Add files via upload 2026-01-06 20:59:02 +03:00
Egor 416908aea4 Add files via upload 2026-01-06 20:58:36 +03:00
Egor 6223a5d63a Update bot_configuration.py 2026-01-06 20:57:55 +03:00
Egor 53a10c0640 Add files via upload 2026-01-06 20:56:44 +03:00
Egor 7c2408209d Add files via upload 2026-01-06 20:54:46 +03:00
Egor 9fbc3a8312 Add FreekassaPayment model for payment processing 2026-01-06 20:54:18 +03:00
Egor dff08cbad2 Add create_freekassa_payments_table function 2026-01-06 20:53:55 +03:00
Egor a2379b9be1 Delete FreekassaPayment model and related code
Removed FreekassaPayment class and its related fields.
2026-01-06 20:53:32 +03:00
Egor 12dc9ccbaa Update models.py 2026-01-06 20:51:46 +03:00
gy9vin 8342e8fe35 Ручной запуск мониторинга трафика
Ручная проверка в админке (monitoring.py):
  - Новая кнопка "📊 Проверка трафика" в меню мониторинга
  - Проверяет всех юзеров с активной подпиской
  - Показывает результат: сколько проверено, сколько превышений
  - Отправляет уведомления админам при превышении
2026-01-04 21:21:05 +03:00
Mikhail 03d30dd5c0 Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-04 21:15:40 +03:00
gy9vin 27512825ae Улучшение системы мониторинга трафика
Изменения в traffic_monitoring_service.py:

  1. Добавлен импорт get_db — для получения сессии БД внутри цикла
  2. Добавлен set_bot() — для установки бота
  3. Изменён start_monitoring() — не требует db и bot как параметры
  4. Добавлен кэш уведомлений — защита от спама (1 уведомление в 24ч на юзера)
  5. Добавлена очистка кэша — удаляет записи старше 48ч

  Изменения в main.py:

  1. Импорт traffic_monitoring_scheduler
  2. Переменная traffic_monitoring_task
  3. set_bot() при старте
  4. Stage "Мониторинг трафика" с логированием интервала и порога
  5. Секция "Активные фоновые сервисы" — добавлен статус
  6. Перезапуск при ошибке в основном цикле
  7. Остановка в блоке finally

  ---
  Как включить

  В .env на сервере:

  TRAFFIC_MONITORING_ENABLED=true
  TRAFFIC_THRESHOLD_GB_PER_DAY=10.0
  TRAFFIC_MONITORING_INTERVAL_HOURS=1
  SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14

  После перезагрузки бота увидишь в логах:

  📊 Мониторинг трафика
     ├ Интервал проверки: 1 ч
     ├ Порог трафика: 10.0 ГБ/сутки
     └  Мониторинг трафика запущен
2026-01-04 21:15:29 +03:00
Egor 4b74ae12ad Merge pull request #2233 from BEDOLAGA-DEV/main
w
2026-01-04 17:24:01 +03:00
PEDZEO 258a4a5cb4 Merge pull request #2230 from BEDOLAGA-DEV/buttons
Buttons
2026-01-03 14:41:17 +03:00
Egor 2cd2147464 Merge pull request #2231 from Gy9vin/main
Фиксы
2026-01-02 19:26:52 +03:00
Mikhail c5efc7ce06 Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-02 19:25:34 +03:00
gy9vin 9cd5d8e0b9 Фикс промокодов 2026-01-02 19:23:52 +03:00
Egor 31d538fbcf Merge pull request #2229 from Gy9vin/main
Фиксы
2026-01-02 19:01:26 +03:00
gy9vin 2156f630dc Добавлена опция "только для первой покупки" в промокоды
- models.py: добавлено поле first_purchase_only в PromoCode
- universal_migration.py: миграция для добавления колонки first_purchase_only
- promocodes.py: добавлен хендлер toggle_promocode_first_purchase, отображение статуса в управлении промокодом
- promocode.py: обработка ошибки "not
2026-01-02 16:40:04 +03:00
gy9vin 917ca69b1d фикс 2026-01-02 16:20:42 +03:00
gy9vin d524088bb8 Обязательная подписка на канал Доработка 2026-01-02 16:14:40 +03:00
PEDZEO 9bd1944ba3 fix platega inv 2026-01-02 12:16:36 +03:00
PEDZEO c41979bda6 fix 2026-01-02 00:48:08 +03:00
PEDZEO 0813d585d2 docs: add cabinet settings to .env.example
Add configuration options for personal cabinet:
- CABINET_ENABLED, JWT settings, CORS origins
- Email verification settings
- SMTP configuration for email sending

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 00:23:05 +03:00
PEDZEO 3f74005068 feat: add cabinet columns migration
Add automatic migration for cabinet (personal account) columns:
- email, email_verified, email_verified_at
- password_hash, email_verification_token/expires
- password_reset_token/expires, cabinet_last_login

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 23:58:47 +03:00
PEDZEO 6b69ec750e feat: add cabinet (personal account) backend API
- Add JWT authentication for cabinet users
- Add Telegram WebApp authentication
- Add subscription management endpoints
- Add balance and transactions endpoints
- Add referral system endpoints
- Add tickets support for cabinet
- Add webhooks and websocket for real-time updates
- Add email verification service

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 23:20:20 +03:00
gy9vin 5a5a18d80d Фикс промокодов
Пагинация списка промокодов:
     - promocodes.py: добавлен хендлер show_promocodes_list_page
     - Зарегистрирован для admin_promo_list_page_*
2026-01-01 22:59:12 +03:00
gy9vin 4bebff5c4a fix(auto-activation): исправлен парсинг AVAILABLE_SUBSCRIPTION_PERIODS
Ошибка: код итерировал по строке "14,30,60,90,180,360" посимвольно,
  что приводило к ValueError: invalid literal for int() with base 10: ','

  Заменено на settings.get_available_subscription_periods() который
  корректно парсит строку в список [14, 30, 60, 90, 180, 360].
2026-01-01 22:55:01 +03:00
Mikhail e2ae7dd34d Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-01 22:48:27 +03:00
gy9vin e15728e369 Fix простой покупки 2026-01-01 22:47:40 +03:00
Egor ecaf270e04 Merge pull request #2228 from yazhog/main
Новые коды НДС для Юкассы
2025-12-31 14:07:48 +04:00
Egor 08e864e5ea Merge pull request #2227 from Gy9vin/main
Фиксы по старому функционалу)
2025-12-31 14:06:16 +04:00
yazhog 97655b8616 Merge pull request #47 from yazhog/codex/add-new-yookassa_vat_code-to-api
Add YooKassa VAT codes 7–12 to settings and document them in .env.example
2025-12-31 12:01:35 +03:00
yazhog 800d589afa Document YooKassa VAT codes in env example 2025-12-31 12:00:48 +03:00
gy9vin ff51a984ef fix Параметризация callback_data в get_back_keyboard + поддержка модема в уведомлениях
1. app/keyboards/inline.py
  - Добавлен параметр callback_data: str = "back_to_menu" в get_back_keyboard()
  - Позволяет использовать кнопку "Назад" с разными callback'ами

2. app/services/admin_notification_service.py
  - Добавлен тип "modem" в update_types с заголовком "📡 ИЗМЕНЕНИЕ МОД
2025-12-31 10:16:14 +03:00
gy9vin a4072237cc fix(nalogo): защита от дублирования чеков + очередь ручной проверки
ПРОБЛЕМА:
  При таймауте после успешной авторизации чек мог быть создан на сервере
  nalog.ru, но ответ не возвращался. Бот добавлял чек в очередь повторной
  отправки → создавался дубликат.

  РЕШЕНИЕ:
  1. Разделена обработка ошибок на два этапа:
     - Аутентификация не прошла → чек точно не создан → в очередь
     - Таймаут при создании → чек МОГ быть создан → НЕ в очередь

  2. Новая очередь `nalogo:pending_verification` для чеков требующих
     ручной проверки (когда таймаут после успешной авторизации)

  3. Кнопка в админке: Мониторинг → Статистика → "⚠️ Проверить (N)"
     - Показывает список чеков с суммой, датой, payment_id
     - " Создан" — чек найден в налоговой, убираем из очереди
     - "🔄 Отправить" — чек НЕ найден, отправляем повторно
     - "🗑 Очистить всё" — после полной сверки с lknpd.nalog.ru

  4. Таймаут увеличен с 10 до 30 секунд (NALOGO_TIMEOUT)

  5. Атомарная защита от race condition через cache.setnx()

  Изменённые файлы:
  - app/utils/cache.py — добавлен метод setnx()
  - app/services/nalogo_service.py — разделение ошибок, pending_verification
  - app/services/nalogo_queue_service.py — статус pending в get_status()
  - app/handlers/admin/monitoring.py — UI для ручной проверки
2025-12-31 01:25:47 +03:00
gy9vin ac83273a22 Фиксы 2025-12-31 00:07:33 +03:00
gy9vin 073d96fb27 fix Новый фильтр "Готовы к продлению"
1. Добавлен .unique() — предотвращает дубликаты при JOIN с подписками
  2. Лимит 20 → 10 — соответствует хендлеру и другим фильтрам
2025-12-30 23:16:42 +03:00
gy9vin 8e6082ce15 fix Черный список, мониторинг суточно графика по регламенту
Исправленные файлы:

  1. app/services/traffic_monitoring_service.py — удалены неиспользуемые импорты Decimal, aiohttp
  2. app/services/blacklist_service.py — удалён неиспользуемый импорт re
  3. app/database/crud/user.py:998 — создана отсутствующая функция get_users_with_active_subscriptions:
  async def get_users_with_active_subscriptions(db: AsyncSession) -> List[User]:
  3. Функция:
    - Возвращает пользователей с активными подписками
    - Фильтрует по remnawave_uuid IS NOT NULL (нужен для API Remnawave)
    - Проверяет end_date > now и status == ACTIVE
2025-12-30 23:11:54 +03:00
gy9vin 08692145d2 fix Массовая синхронизация пользователей с Remnawave
app/database/crud/subscription.py:

  Добавлен await db.flush() в create_subscription_no_commit для консистентности с create_user_no_commit:

  db.add(subscription)

  # Выполняем flush, чтобы получить присвоенный первичный ключ
  await db.flush()

  # Не коммитим сразу, оставляем для пакетной обработки
2025-12-30 23:03:34 +03:00
gy9vin 56cc8bacf2 fix Простая покупка подписки
1. app/database/crud/subscription.py

  Объединены функции create_pending_subscription и create_pending_trial_subscription:
  - Добавлен параметр is_trial: bool = False в create_pending_subscription
  - create_pending_trial_subscription теперь просто вызывает create_pending_subscription(is_trial=True)
  - Сокращено ~75 строк дублированного кода

  Удалён лишний импорт:
  # Было внутри activate_pending_subscription:
  from sqlalchemy import and_  # Удалено — уже импортирован на уровне модуля

  2. app/handlers/subscription/purchase.py

  Устранено дублирование функций:
  - Удалены определения _calculate_simple_subscription_price() и _get_simple_subscription_payment_keyboard() (~75 строк)
  - Добавлен импорт из app.handlers.simple_subscription

  from app.handlers.simple_subscription import (
      _calculate_simple_subscription_price,
      _get_simple_subscription_payment_keyboard,
  )

  Итого сокращено: ~150 строк дублированного кода
2025-12-30 22:56:24 +03:00
gy9vin 096b4d4fe3 fix Отправка сообщения пользователю из карточки
1. app/handlers/admin/users.py
  - Добавлен параметр parse_mode="HTML" в send_message для поддержки HTML-форматирования
  - Добавлен вызов await state.clear() при ошибке BadRequest для очистки состояния FSM
2025-12-30 22:48:15 +03:00
gy9vin 9dd3299744 fix Скрытие кнопок пополнения через env
1. app/config.py
  - Добавлен метод is_quick_amount_buttons_enabled() для централизации логики

  2. 9 файлов в app/handlers/balance/:
  - main.py — 1 замена
  - cryptobot.py — 2 замены
  - stars.py — 2 замены
  - yookassa.py — 4 замены
  - pal24.py — 1 замена
  - platega.py — 1 замена
  - mulenpay.py — 1 замена
  - wata.py — 1 замена
  - heleket.py — 1 замена

  Было (12 раз):
  if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:

  Стало:
  if settings.is_quick_amount_buttons_enabled():
2025-12-30 22:40:43 +03:00
gy9vin bc19ec32bb fix Реализация корзины пользователя! запоминает настройки подписки после пополнения баланса 2025-12-30 22:35:54 +03:00
gy9vin 720f0ecb60 fix Скрытие кнопки партнёрки через env 2025-12-30 22:29:06 +03:00
gy9vin a9fd4c2466 fix Модульная структура платежки 2025-12-30 22:22:56 +03:00
gy9vin 5aa9b6ddb3 fix Исправление уведомления пользователя о поступлении денег на счет 2025-12-30 22:18:44 +03:00
gy9vin 180cba4561 fix Расширение фильтров 2025-12-30 22:14:09 +03:00
gy9vin 25dc7ff624 fix Добавлена фильтрация пользователей по балансу 2025-12-30 22:07:26 +03:00
gy9vin 1233d38fe1 fix Добавлена функция покупки подписки администратором с преобразованием триала в безлимитную подписку 2025-12-30 21:54:02 +03:00
gy9vin d60ebaef41 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-30 21:50:42 +03:00
gy9vin dd86014667 Fix Добавление кнопок пополнения с суммой подписки 2025-12-30 21:50:36 +03:00
Mikhail bb8beff511 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-30 21:47:07 +03:00
Egor b276d764a3 Update Python version argument to v2.9.4 2025-12-30 19:28:19 +04:00
Egor ee78ab0932 Update docker-registry.yml 2025-12-30 19:28:03 +04:00
Egor e9daa76d7e Update docker-hub.yml 2025-12-30 19:27:52 +04:00
Egor 780c3956fa Merge pull request #2226 from BEDOLAGA-DEV/dev5
Новый режим покупки фикс+докупка со сбросом при продлении
2025-12-30 18:53:30 +04:00
Egor e582802e39 Update payment link generation to use async method 2025-12-30 18:51:55 +04:00
Egor d13b20d380 Update cloudpayments_service.py 2025-12-30 18:51:21 +04:00
Egor 5918f296ff Update inline.py 2025-12-30 18:33:18 +04:00
Egor 2f1ef8a60d Update pricing.py 2025-12-30 18:16:35 +04:00
Egor cefb6602f7 Refactor selectable logic in ensurePurchaseTrafficSelection 2025-12-30 18:15:50 +04:00
Egor b6503f9af9 Update miniapp.py 2025-12-30 18:15:02 +04:00
Egor 76f465e0f6 Update subscription.py 2025-12-30 18:13:18 +04:00
Egor 63ec894615 Update inline.py 2025-12-30 18:11:56 +04:00
Egor c9c25613af Add new traffic selection mode option 2025-12-30 18:10:08 +04:00
Egor 6ecaa406aa Update subscription_purchase_service.py 2025-12-30 18:09:11 +04:00
Egor 22c8f73eac Update traffic limit handling in subscription service
Refactor traffic limit assignment logic for subscriptions.
2025-12-30 18:08:32 +04:00
Egor d4bc7d0b51 Update subscription_renewal_service.py 2025-12-30 18:08:03 +04:00
Egor aa3c9231b0 Update subscription_service.py 2025-12-30 18:07:39 +04:00
Egor bce05d4bc4 Implement traffic limit reset on subscription renewal
Added logic to handle traffic limit reset during subscription renewal based on fixed traffic settings.
2025-12-30 18:05:26 +04:00
Egor f107109091 Update traffic.py 2025-12-30 18:04:54 +04:00
Egor 826a554d55 Update config.py 2025-12-30 17:56:40 +04:00
Egor fd5801839c Merge pull request #2225 from BEDOLAGA-DEV/main
w
2025-12-30 17:55:46 +04:00
gy9vin 8843d86d9b Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-30 11:34:46 +03:00
gy9vin 449f82d538 refactor(auto-purchase): перезагрузка пользователя после списания баланса для восстановления связей
- Добавлен get_user_by_id в импорты
- Перезагрузка user через get_user_by_id после subtract_user_balance
- Восстановление связи user_promo_groups, сбрасываемой после db.refresh() в payment-сервисах
- Добавлен мок get_user_by_id в тесте
2025-12-30 11:34:41 +03:00
Egor abce475ccc Merge pull request #2223 from Gy9vin/main
Отправка чеков со временем
2025-12-30 09:47:45 +04:00
gy9vin 22129ecbec минификс в отладке конкурсов 2025-12-30 02:15:52 +03:00
gy9vin 2a2a3daaae fix(contests): исправление статистики реферальных конкурсов
Основные исправления:
  - Фильтрация событий по дате регистрации реферала (occurred_at)
    в период конкурса (start_at - end_at)
  - Лидерборд теперь показывает правильные числа (было 21, стало 11)
  - Разделение DEPOSIT и SUBSCRIPTION_PAYMENT в статистике:
    - Основная метрика: покупки подписок (SUBSCRIPTION_PAYMENT)
    - Информационно: пополнения баланса (DEPOSIT)

  Новый функционал:
  - Кнопка "🔍 Отладка" для просмотра транзакций конкурса
  - Разбивка сумм по типам в детальной статистике
  - Кнопки "Назад" в синхронизации и отладке
  - Логирование дат фильтрации в синхронизации

  Также исправлено:
  - NaloGO: защита от дублирования чеков в очереди
    (проверка nalogo:created и nalogo:queued в Redis)
2025-12-30 02:08:23 +03:00
gy9vin ac94d5d708 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-30 00:39:50 +03:00
gy9vin d10ccc4844 refactor(nalogo): улучшение системы чеков NaloGO
Сохранение времени оплаты:
  - Добавлен параметр operation_time в create_receipt()
  - Чеки из очереди создаются с оригинальным временем платежа
  - Парсинг created_at из Redis очереди

  Защита от дублей (3 уровня):
  - Проверка transaction.receipt_uuid перед созданием
  - Redis ключ nalogo:created:{payment_id} с TTL 30 дней
  - Сохранение receipt_uuid в транзакцию после создания

  Бесконечные повторы:
  - Убрано удаление чеков после 10 попыток
  - Чеки остаются в очереди до успешной отправки

  Обработка ошибок:
  - Добавлена обработка 500 и "внутренняя ошибка" как временной недоступности

  Сверка чеков:
  - Заменена API сверка на сверку по логам (logs/current/payments.log)
  - Кнопка "Без чеков" → "Сверка чеков" с прямым показом сверки
  - Исправлена навигация кнопок "Назад"
2025-12-30 00:39:00 +03:00
Mikhail de733a5f08 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-29 22:15:48 +03:00
gy9vin 4cab3f5ed4 Отправка чеков со временем 2025-12-29 22:15:07 +03:00
Egor 26b85b16b5 Merge pull request #2220 from BEDOLAGA-DEV/main
w
2025-12-29 10:35:52 +04:00
Egor ea770c32e8 Merge pull request #2218 from Gy9vin/main
Апдейты
2025-12-28 17:23:04 +03:00
gy9vin 23f8bdfbec feat(restrictions): добавить систему ограничений пользователей
Добавлена возможность ограничивать пользователям:
  - Пополнение баланса (restriction_topup)
  - Покупку/продление подписки (restriction_subscription)

  Изменения:
  - models.py: добавлены поля restriction_topup, restriction_subscription,
    restriction_reason и property has_restrictions
  - universal_migration.py: миграция для новых полей
  - admin/users.py: меню управления ограничениями в карточке пользователя
  - keyboards/admin.py: клавиатура ограничений с toggle-кнопками
  - states.py: состояние editing_user_restriction_reason

  Проверки ограничений добавлены на двух уровнях:
  - start_*_payment: при выборе метода оплаты
  - process_*_payment_amount: при создании платежа

  Затронутые провайдеры: stars, yookassa, mulenpay, wata, pal24,
  cryptobot, heleket, platega, tribute, cloudpayments

  При ограничении пользователь видит причину и кнопку "Обжаловать",
  ведущую на контакт поддержки из настроек.
2025-12-28 13:32:04 +03:00
gy9vin a362ef9f25 refactor(nalogo): восстановить описание чеков из настроек и использовать локальную библиотеку
- Добавлено восстановление описания чека из настроек при обработке очереди
- Передача telegram_user_id и amount_kopeks через всю цепочку создания чеков
- Переход на локальную исправленную версию библ
2025-12-28 04:58:05 +03:00
gy9vin 1b736b381d refactor(nalogo): упростить настройку чеков и использовать локальное время
- Удалена избыточная настройка NALOGO_RECEIPTS_ENABLED
- Удален эндпоинт /settings/support/nalogo_receipts_enabled
- Удалены методы is_nalogo_receipts_enabled и set_nalogo_receipts_enabled из SupportSettingsService
- Упрощена логика создания чеков
2025-12-27 19:29:04 +03:00
gy9vin d343a317ee feat(logging): добавить систему ротации логов
- Ежедневная ротация в 00:00 с архивацией в tar.gz
  - Разделение по уровням: info.log, warning.log, error.log
  - Отдельный payments.log для платежных операций
  - Отправка архивов в Telegram-канал бекапов
  - Автоочистка архивов старше 7 дней (настраивается)
  - Переключатель LOG_ROTATION_ENABLED (по умолчанию выключен)
2025-12-27 19:02:28 +03:00
Egor 3fc9e63653 Merge pull request #2208 from Gy9vin/fix
Исправление уязвимостей и багов в конкурсах
2025-12-27 16:20:18 +03:00
Mikhail 9f12462871 Merge branch 'main' into fix 2025-12-27 15:54:07 +03:00
Egor e293470c14 Merge pull request #2217 from D4nilKO/promo-campaing-trial-fix
Fix campaign subscription logic: use trial instead of paid
2025-12-27 11:17:21 +03:00
Egor 6322e970ef Merge pull request #2216 from BEDOLAGA-DEV/main
w
2025-12-27 10:55:00 +03:00
PEDZEO 8a40d54c03 Merge pull request #2215 from BEDOLAGA-DEV/BACKUP
Add backup management endpoints
2025-12-27 05:18:15 +03:00
PEDZEO 0264c24743 Enhance backup upload validation
- Added checks for safe filename to prevent directory traversal attacks.
- Updated file type validation to use the sanitized filename.
- Implemented path resolution to ensure uploaded files are within the backup directory.
2025-12-27 05:16:08 +03:00
PEDZEO 1aade85fc9 Add backup management endpoints
- Implemented download, restore, upload and delete functionalities for backups.
- Added corresponding request and response schemas for backup operations.
- Enhanced security checks to prevent unauthorized access to backup files.
2025-12-27 04:33:13 +03:00
PEDZEO a5874a9873 Merge pull request #2214 from BEDOLAGA-DEV/BACKUP
Add support for password-protected backup archives
2025-12-27 04:13:22 +03:00
PEDZEO 6f9dc45e16 Update BackupService to check for temporary zip path instead of password for captioning 2025-12-27 04:11:08 +03:00
PEDZEO 34db5a0d28 Add support for password-protected backup archives
- Updated .env.example to include BACKUP_ARCHIVE_PASSWORD variable.
- Added pyzipper to requirements.txt for ZIP file encryption.
- Modified Settings class in config.py to handle BACKUP_ARCHIVE_PASSWORD.
- Enhanced BackupService to create and send password-protected ZIP archives if a password is provided.
2025-12-27 03:58:48 +03:00
Dxnil 34517427b0 test 2025-12-27 03:05:48 +03:00
gy9vin cad9abd8bd feat(payments): умная автоактивация подписки после пополнения + округление цен
Добавлена функция умной автоактивации подписки после пополнения баланса:

  - Новая настройка AUTO_ACTIVATE_AFTER_TOPUP_ENABLED в .env
  - Функция auto_activate_subscription_after_topup() в subscription_auto_purchase_service.py:
    - Автоматически продлевает истёкшую подписку с теми же параметрами
    - Создаёт новую подписку с дефолтными параметрами если подписки нет
    - Проверяет достаточность баланса перед активацией
    - Интеграция с RemnaWave API
    - Уведомления пользователю и админам

  - Интеграция во все 9 платёжных провайдеров:
    - Stars, CryptoBot, YooKassa, CloudPayments
    - WATA, Platega, Pal24, MulenPay, Tribute

  - Исправлен handle_activate_button в menu.py:
    - Полная переработка с интеграцией RemnaWave
    - Корректная работа с балансом и транзакциями
    - Использование SubscriptionRenewalService

  Добавлено округление цен при отображении:

  - Новая настройка PRICE_ROUNDING_ENABLED в .env
  - Логика: ≤50 коп → вниз, >50 коп → вверх
  - Применяется везде: пополнения, партнёрки, скидки, промогруппы
2025-12-26 23:38:46 +03:00
gy9vin bf9728352e feat(subscription): улучшение UX выбора серверов и устройств
- Кнопки устройств теперь в один столбец (вместо 2 колонок)
  - Автоматический предвыбор бесплатных серверов (price_kopeks == 0)
  - Вывод описания сквадов в тексте сообщения над кнопками

  Изменённые файлы:
  - keyboards/inline.py: get_devices_keyboard в 1 столбец
  - handlers/subscription/countries.py: хелперы _get_preselected_free_countries и _build_countries_selection_text
  - handlers/subscription/purchase.py, traffic.py, autopay.py: применение новой логики
2025-12-26 22:15:34 +03:00
gy9vin 58c924b70a fix(campaign,channel): исправлена логика рекламных кампаний и проверки подписки на канал
- Рекламные кампании теперь выдают триальную подписку (is_trial=True),
    а не платную — пользователь становится платным только после оплаты

  - Добавлена настройка CHANNEL_REQUIRED_FOR_ALL для проверки подписки
    на канал для ВСЕХ пользователей (платных и триальных)

  - Добавлен параметр is_trial в create_paid_subscription для гибкости
2025-12-26 21:40:28 +03:00
gy9vin 64ffffdf90 fix(traffic): исправлен баг с бесплатным переключением трафика
При наличии докупленного трафика (например 250 + 10 ГБ = 260 ГБ)
  система округляла текущий пакет до ближайшего (500 ГБ) и позволяла
  бесплатно переключиться на него.

  Исправления:
  - confirm_switch_traffic: используется базовый трафик для расчёта цены
  - get_traffic_switch_keyboard: добавлен параметр base_traffic_gb
  - handle_switch_traffic: показывает информацию о докупленном трафике
  - execute_switch_traffic: сбрасывает purchased_traffic_gb при переключении
2025-12-26 11:18:16 +03:00
gy9vin e71a3c1af7 feat(referral): топ рефереров по периодам в админ-панели
Добавлена возможность просмотра топа рефереров за неделю/месяц
   с сортировкой по количеству приглашённых или по заработку:

   - get_top_referrers_by_period() в crud/referral.py
   - Интерактивные кнопки выбора периода и критерия сортировки
   - Топ-20 рефереров с медалями для первых трёх мест
2025-12-26 09:11:08 +03:00
gy9vin 265d2b907b fix(devices): сброс устройств при уменьшении лимита
При уменьшении лимита устройств подключённые устройства не удалялись,
   позволяя пользователю продолжать использовать их бесплатно.

   Исправления:
   - execute_change_devices: сброс всех устройств через API если
     подключённых больше чем новый лимит
   - confirm_change_devices: предупреждение пользователя о сбросе
     устройств перед подтверждением
   - Уведомление о количестве сброшенных устройств в результате
2025-12-26 09:00:56 +03:00
gy9vin 54ffe3e126 feat(transactions): добавлен параметр payment_method для ручных пополнений баланса
Добавлена поддержка указания способа оплаты при пополнении баланса:

- add_user_balance(): новый параметр payment_method для передачи в транзакцию
- add_user_balance_by_id(): поддержка payment_method
- UserService: ручные пополнения админом пом
2025-12-26 08:53:08 +03:00
gy9vin 7e3d7d4771 refactor(referral): извлечена lambda в именованную функцию для пагинации списка рефералов
Заменена inline lambda на handle_referral_list_page() для улучшения читаемости:

- Извлечение номера страницы из callback.data
- Вызов show_detailed_referral_list() с параметром page
- Улучшена читаемость кода обработчика пагинации
2025-12-26 08:39:59 +03:00
gy9vin 05bfd89a02 ```
feat(tickets): добавлены уведомления админам об ответах пользователей на тикеты

Реализована функция notify_admins_about_ticket_reply() для оповещения администраторов:

- Уведомление отправляется после успешного добавления ответа пользователя
- Формат уведомления включает ID тикета, заголовок
2025-12-26 08:31:36 +03:00
gy9vin 3bf540427e feat(nalogo): расширена проверка временных ошибок для очереди чеков
Добавлены проверки сетевых ошибок и таймаутов в _is_service_unavailable():

- Проверка типа исключения (timeout, readtimeout, connecttimeout)
- Проверка сетевых ошибок (connectionerror, connecterror)
- Проверка текста ошибки на наличие "timeout"
- Обновлён docstring мет
2025-12-26 00:27:34 +03:00
gy9vin bf6dc3991a feat(trial): платный триал с выбором метода оплаты
Реализована система платного триала с гибким выбором способа оплаты:

- Автоопределение платности: если TRIAL_ACTIVATION_PRICE > 0, триал автоматически платный
- TRIAL_PAYMENT_ENABLED теперь опционален (для обратной совместимости)
- Добавлена функция create
2025-12-26 00:03:08 +03:00
Mikhail fb9405cccd Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-25 23:56:04 +03:00
gy9vin 0b34e90372 feat(migrations): добавлены миграции для модема, трафика и призов конкурсов
Добавлены миграции для новых функций:

- migrate_contest_templates_prize_columns(): миграция prize_days → prize_type + prize_value
- add_subscription_modem_enabled_column(): колонка modem_enabled в subscriptions
- add_subscription_purchased_traffic_column(): колонка purchased_traffic_gb в subscriptions
- Обновлён check
2025-12-25 23:29:02 +03:00
Egor 04e83814b4 Merge pull request #2213 from BEDOLAGA-DEV/dev5
CloudPayments
2025-12-25 23:09:44 +03:00
gy9vin 0df3018703 feat(nalogo): система очереди чеков с отложенной отправкой
Реализована отказоустойчивая система отправки чеков в налоговую:

  - Добавлен NalogoQueueService для фоновой обработки очереди чеков
  - При недоступности nalog.ru (503) чеки сохраняются в Redis
  - Автоматическая повторная отправка с настраиваемым интервалом
  - Защита от DDoS: задержка между чеками (NALOGO_QUEUE_RECEIPT_DELAY)
  - Уведомления админам в топик при проблемах и успешной разгрузке

  Изменения в файлах:
  - app/services/nalogo_queue_service.py: новый фоновый сервис
  - app/services/nalogo_service.py: методы очереди, определение 503
  - app/utils/cache.py: lpush/rpop/llen/lrange для Redis List
  - app/handlers/admin/monitoring.py: статистика чеков в админке
  - app/config.py: NALOGO_QUEUE_* и ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID
  - main.py: интеграция запуска/остановки сервиса

  Новые ENV переменные:
  - ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID
  - NALOGO_QUEUE_CHECK_INTERVAL (300с)
  - NALOGO_QUEUE_RECEIPT_DELAY (3с)
  - NALOGO_QUEUE_MAX_ATTEMPTS (10)
2025-12-25 23:01:49 +03:00
Egor 8eb2be15ff Update index.html 2025-12-25 21:11:13 +03:00
Egor 648fba6e2a Update miniapp.py 2025-12-25 21:10:15 +03:00
Egor 3df8d0cb24 Update bot_configuration.py 2025-12-25 21:09:18 +03:00
Egor a59a15af7f Update inline.py 2025-12-25 21:07:42 +03:00
Egor c919953219 Add files via upload 2025-12-25 21:07:00 +03:00
Egor 5e6baac07e Add files via upload 2025-12-25 21:05:49 +03:00
Egor 42b06ea8bc Add files via upload 2025-12-25 21:05:10 +03:00
Egor 07ded3710c Add files via upload 2025-12-25 21:04:10 +03:00
Egor 7d0977066b Add files via upload 2025-12-25 21:03:14 +03:00
Egor d8e57ab766 Add files via upload 2025-12-25 21:01:58 +03:00
Egor 83cb1ec823 Add files via upload 2025-12-25 21:01:11 +03:00
gy9vin da46e39c61 refactor(modem): рефакторинг модуля управления модемом
Рефакторинг архитектуры управления модемом:

- Создан сервис app/services/modem_service.py:
  - ModemService с бизнес-логикой подключения/отключения
  - ModemError enum для типизации ошибок
  - ModemPriceInfo, ModemOperationResult dataclass'ы
  - Константы MODEM_WARNING_DAYS_* для уровней предупреждений
2025-12-25 18:44:27 +03:00
gy9vin 86dd18fbe7 refactor(contests): доработка ежедневных конкурсов
Рефакторинг архитектуры ежедневных конкурсов:

- Создан модуль app/services/contests/ с новой архитектурой:
  - enums.py: GameType, RoundStatus, PrizeType enum классы
  - games.py: паттерн Стратегия для 7 типов игр
  - attempt_service.py: ContestAttemptService для атомарных операций

- Упрощён handlers/contests.py:
  - Удалены отдельные _render_* функции (заменены на стратегии)
  - Логика обработки попыток вынесена в ContestAttemptService
  - Уменьшено с 523 до 342 строк (-35%)

- Обновлён contest_rotation_service.py:
  - Заменена if-elif цепочка на get_game_strategy().build_payload()
  - Используются enum классы вместо магических строк

- Исправлен handlers/admin/daily_contests.py:
  - prize_days → prize_type/prize_value (соответствие модели БД)
  - Обновлены EDITABLE_FIELDS и отображение приза

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 18:09:11 +03:00
gy9vin 21f34a9b08 fix(contests): блокировка повторных ответов в текстовых играх
- Создание попытки сразу при показе вопроса (cipher/emoji/anagram)
- Проверка attempt.answer is not None для блокировки повторного ответа
- Обновление существующей попытки вместо создания новой
- Добавлена функция update_attempt() в CRUD
2025-12-25 15:00:30 +03:00
gy9vin 0538d0e337 feat(traffic): улучшение системы докупки и сброса трафика
- Добавлен ENV переключатель TRAFFIC_TOPUP_ENABLED для вкл/выкл докупки
- Добавлена отдельная конфигурация пакетов TRAFFIC_TOPUP_PACKAGES_CONFIG
- Добавлено поле purchased_traffic_gb для отслеживания докупленного трафика
- Добавлены режимы расчета цены сброса (period/traffic/traffic_with_purchased)
- Исправлен абьюз: цена сброса теперь учитывает докупленный трафик
- Сброс purchased_traffic_gb при продлении/покупке подписки
- UX: меню сброса теперь показывает цену и баланс вместо alert
- UX: кнопка пополнения если не хватает средств на сброс
- Добавлена миграция для нового поля purchased_traffic_gb
- Добавлена локализация TRAFFIC_TOPUP_DISABLED (ru/en/ua/zh)
2025-12-25 14:48:24 +03:00
Mikhail 1e64b65586 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-25 14:26:35 +03:00
gy9vin 90c3df6331 Добавлен функционал подключения модема к подписке
Изменения:
- Добавлены настройки модема в .env.example и config.py (MODEM_ENABLED, MODEM_PRICE_PER_MONTH, MODEM_PERIOD_DISCOUNTS)
- Добавлено поле modem_enabled в модель Subscription
- Реализован модуль handlers/subscription/modem.py с обработчиками подключения/отключения модема
- Добавлено управ
2025-12-25 14:26:15 +03:00
Egor b9ea5a80ac Merge pull request #2212 from BEDOLAGA-DEV/dev5
Update subscription_service.py
2025-12-25 11:00:00 +03:00
Egor 40d6514dee Update subscription_service.py 2025-12-25 10:59:35 +03:00
Egor 4a2b254d99 Merge pull request #2211 from BEDOLAGA-DEV/dev5
Dev5
2025-12-25 09:45:07 +03:00
Egor 6c740ad984 Update purchase.py 2025-12-25 09:35:49 +03:00
Egor 929a8d5fa9 Update purchase.py 2025-12-25 09:09:46 +03:00
Egor b0bf5131d4 Update subscription_service.py 2025-12-25 09:09:12 +03:00
Egor ef8d9fe1ff Update subscription_purchase_service.py 2025-12-24 23:15:37 +03:00
Egor d57e0743a1 Update tickets.py 2025-12-24 23:06:22 +03:00
Egor 664cbff1ce Update tickets.py 2025-12-24 23:05:52 +03:00
Egor 6ea52bf406 Update messages.py 2025-12-24 22:46:04 +03:00
Mikhail 0ddf24125b Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-24 16:17:29 +03:00
Egor f5a533e407 Merge pull request #2210 from BEDOLAGA-DEV/dev5
Dev5
2025-12-24 15:30:46 +03:00
Egor dd87b4d408 Update database.py 2025-12-24 14:44:02 +03:00
Egor 2c6c7056e8 Update subscriptions.py 2025-12-24 11:16:00 +03:00
Egor 8a10b96fef Update users.py 2025-12-24 11:15:40 +03:00
Egor c0a85bad84 Merge pull request #2209 from BEDOLAGA-DEV/dev5
Dev5
2025-12-23 22:09:46 +03:00
Egor fbfc00586c Update purchase.py 2025-12-23 21:03:13 +03:00
Egor 5723c3b379 Update purchase.py 2025-12-23 20:37:01 +03:00
Egor f1be66d1a5 Update inline.py 2025-12-23 20:35:42 +03:00
gy9vin b3cdd3c03a Расширение функционала конкурсов: разнообразие наград, напоминания, многоязычность
Изменения:
- ContestTemplate: prize_days заменен на prize_type и prize_value для поддержки разных типов наград (days, balance, custom)
- _award_prize: обновлена логика выдачи призов для всех типов наград
- DEFAULT_TEMPLATES: обновлены для использования prize_type/prize_value
- upsert_template: обновлена сигнатура для новых полей
- _announce_round_start: добавлена локализация и напоминания о конкурсах
- handle_text_answer: исправлена гонка условий с атомарным инкрементом победителей
- Локализация: добавлены ключи CONTEST_START_ANNOUNCEMENT, CONTEST_PRIZE, DAYS, CONTEST_WINNERS, CONTEST_ATTEMPTS, CONTEST_ELIGIBILITY, REMINDER, CONTEST_REMINDER_TEXT в ru.json и en.json
- API схемы: обновлены ContestTemplateResponse и ContestTemplateUpdateRequest

Требуется миграция БД для новых колонок prize_type и prize_value.
2025-12-23 19:15:40 +03:00
gy9vin 01afce002a Исправление уязвимостей и багов в конкурсах
- Добавлена защита от спама: rate limiting для попыток (1 попытка/3-5 сек)
- Усилена валидация входных данных: функция _validate_callback_data для безопасного парсинга callback.data
- Перепроверка авторизации: статус подписки проверяется на каждом шаге
- Атомарные операции победителей: использование select with_for_update для предотвращения гонок условий
- Улучшено логирование: добавлены логи попыток и побед для аудита
- Добавлена кнопка 'Назад' в игру 'Блиц' для предотвращения застревания пользователей
- Исправлены отступы и ошибки линтера в _render_blitz

Все изменения направлены на повышение безопасности, стабильности и UX конкурсов.
2025-12-23 18:59:44 +03:00
Egor 63a9c1afd1 Update inline.py 2025-12-23 16:09:58 +03:00
Egor 0c9f667cd8 Merge pull request #2207 from BEDOLAGA-DEV/dev5
Dev5
2025-12-23 11:57:02 +03:00
Egor dc428baa6a Update subscription.py 2025-12-23 11:53:52 +03:00
Egor f65aa6a82d Update remnawave_service.py 2025-12-23 11:53:11 +03:00
Egor c986b127a7 Merge pull request #2206 from BEDOLAGA-DEV/dev5
w
2025-12-23 11:29:16 +03:00
Egor 94a7a5fce8 Update remnawave_service.py 2025-12-23 11:28:37 +03:00
Egor f07bbe0fd0 Update remnawave_api.py 2025-12-23 11:27:57 +03:00
Egor 46f61dbc57 Update config.py 2025-12-23 11:27:33 +03:00
Egor 41502cadcd Merge pull request #2205 from BEDOLAGA-DEV/dev5
Update remnawave_api.py
2025-12-23 11:18:46 +03:00
Egor aa5e6841f8 Update remnawave_api.py 2025-12-23 11:18:22 +03:00
Egor 8a98a7ee84 Merge pull request #2204 from BEDOLAGA-DEV/dev5
Update config.py
2025-12-23 11:09:26 +03:00
Egor e1d6c73557 Update config.py 2025-12-23 11:08:50 +03:00
Egor b4d66bd34d Merge pull request #2203 from BEDOLAGA-DEV/dev5
Update photo_message.py
2025-12-23 02:19:18 +03:00
Egor 1496ec901d Update photo_message.py 2025-12-23 02:17:31 +03:00
Egor 78ce6bd428 Merge pull request #2202 from BEDOLAGA-DEV/dev5
Update remnawave_service.py
2025-12-23 00:54:44 +03:00
Egor 9ec52dcf95 Update remnawave_service.py 2025-12-23 00:54:01 +03:00
Egor 80a13e6b71 Merge pull request #2201 from BEDOLAGA-DEV/dev5
fix
2025-12-22 22:46:27 +03:00
Egor 9231f770a3 Update payment_utils.py 2025-12-22 22:45:21 +03:00
Egor f8ba587e5c Add files via upload 2025-12-22 22:43:51 +03:00
Egor 9280eecf4c Update Dockerfile 2025-12-22 19:40:19 +03:00
Egor a36b15e8ad Update docker-registry.yml 2025-12-22 19:40:09 +03:00
Egor 3e833f8a8c Update docker-hub.yml 2025-12-22 19:39:57 +03:00
Egor 48044b56c1 Merge pull request #2200 from BEDOLAGA-DEV/dev5
platega change name
2025-12-22 19:36:09 +03:00
Egor 3dab9df26c Update platega.py 2025-12-22 19:33:09 +03:00
Egor 65da3a57a7 Update inline.py 2025-12-22 19:32:36 +03:00
Egor 791482a8a1 Update system_settings_service.py 2025-12-22 19:32:06 +03:00
Egor 7b03f3e553 Update payment_verification_service.py 2025-12-22 19:31:32 +03:00
Egor 47698882fa Update payments.py 2025-12-22 19:30:52 +03:00
Egor 2c4a55f7c7 Update config.py 2025-12-22 19:30:13 +03:00
Egor f8053e14c3 Merge pull request #2199 from BEDOLAGA-DEV/main
w
2025-12-22 17:43:38 +03:00
Egor 2073d42fa8 Merge pull request #2198 from BEDOLAGA-DEV/dev5
fix: add trafic button
2025-12-22 17:08:20 +03:00
Egor 076eb89760 Update menu.py 2025-12-22 15:53:56 +03:00
Egor 34878a2ef3 Merge pull request #2197 from BEDOLAGA-DEV/dev5
Caddy Token auth
2025-12-22 15:33:36 +03:00
Egor 6ae573ed48 Update .env.example 2025-12-22 15:32:38 +03:00
Egor 482c42e9bf Update README.md 2025-12-22 15:30:57 +03:00
Egor 66092af999 Update maintenance_service.py 2025-12-22 15:29:46 +03:00
Egor c7eabdf90e Update remnawave_service.py 2025-12-22 15:29:21 +03:00
Egor ee6f4b35b0 Update subscription_service.py 2025-12-22 15:29:02 +03:00
Egor 7aea87a227 Update remnawave_api.py 2025-12-22 15:28:23 +03:00
Egor a2b039d092 Update config.py 2025-12-22 15:28:02 +03:00
Egor f2d2c0a8a4 Merge pull request #2196 from BEDOLAGA-DEV/dev5
Remnawave Api Update / Pinned Massages
2025-12-22 15:18:18 +03:00
Egor ea3033a088 Update validators.py 2025-12-22 15:11:23 +03:00
Egor 07f445b485 Update pinned_messages.py 2025-12-22 15:01:26 +03:00
Egor a94cb355aa Add files via upload 2025-12-22 14:56:16 +03:00
Egor 9f14ae67da Update __init__.py 2025-12-22 14:48:14 +03:00
Egor 44247dee03 Update app.py 2025-12-22 14:47:40 +03:00
Egor c66af415d5 Add files via upload 2025-12-22 14:46:51 +03:00
Egor 6b2d7618a7 Add files via upload 2025-12-22 14:45:27 +03:00
Egor 866fa56f5b Add files via upload 2025-12-22 14:43:58 +03:00
Egor ea48036010 Update messages.py 2025-12-22 14:43:22 +03:00
Egor 4077b2a032 Update admin.py 2025-12-22 14:42:52 +03:00
Egor a69500ce91 Update states.py 2025-12-22 14:42:23 +03:00
Egor 9a1e57a764 Update remnawave_service.py 2025-12-22 14:08:44 +03:00
Egor 88657dd3e2 Update remnawave_service.py 2025-12-22 14:03:33 +03:00
Egor 85d4c9c208 Add files via upload 2025-12-22 13:57:37 +03:00
Egor 2bda60cd18 Update messages.py 2025-12-22 13:56:52 +03:00
Egor 73e5e1b5a3 Update pinned_message_service.py 2025-12-22 13:56:15 +03:00
Egor 3f1b65b602 Update 7a3c0b8f5b84_add_send_before_menu_to_pinned_messages.py 2025-12-22 13:55:43 +03:00
Egor fa7dd7434b Update 1b2e3d4f5a6b_add_pinned_start_mode_and_user_last_pin.py 2025-12-22 13:55:18 +03:00
Egor 0243d3a2ba Merge pull request #2195 from BEDOLAGA-DEV/i1s5hb-bedolaga/add-pinned-message-feature-in-admin-menu
Add one-time pinned message delivery mode
2025-12-22 13:32:48 +03:00
Egor 0951c9f6dd Add one-time pinned message delivery mode 2025-12-22 13:32:34 +03:00
Egor 6891cc1f36 Merge pull request #2194 from BEDOLAGA-DEV/revert-2192-kbzjuf-bedolaga/add-pinned-message-feature-in-admin-menu
Revert "Add start delivery frequency toggle for pinned messages"
2025-12-22 13:32:16 +03:00
Egor dbe805662a Revert "Add start delivery frequency toggle for pinned messages" 2025-12-22 13:32:07 +03:00
Egor cd5c293b71 Merge pull request #2192 from BEDOLAGA-DEV/kbzjuf-bedolaga/add-pinned-message-feature-in-admin-menu
Add start delivery frequency toggle for pinned messages
2025-12-22 13:28:58 +03:00
Egor 58e789b094 Merge pull request #2193 from BEDOLAGA-DEV/revert-2191-wc3pdx-bedolaga/add-pinned-message-feature-in-admin-menu
Revert "Add admin option to remove pinned message"
2025-12-22 13:28:50 +03:00
Egor 3fd48807d1 Revert "Add admin option to remove pinned message" 2025-12-22 13:28:40 +03:00
Egor 76b32ea4fe Add one-time pinned message option for /start 2025-12-22 13:28:08 +03:00
Egor 60670e44a2 Merge pull request #2191 from BEDOLAGA-DEV/wc3pdx-bedolaga/add-pinned-message-feature-in-admin-menu
Add admin option to remove pinned message
2025-12-22 13:14:24 +03:00
Egor 69f1b91ce7 Add admin control to remove pinned message 2025-12-22 13:14:01 +03:00
Egor c78313b5db Merge pull request #2190 from BEDOLAGA-DEV/revert-2189-h29si7-bedolaga/add-pinned-message-feature-in-admin-menu
Revert "Prevent duplicate unknown command message after /start"
2025-12-22 13:13:27 +03:00
Egor 55a7ec6b11 Revert "Prevent duplicate unknown command message after /start" 2025-12-22 13:13:18 +03:00
Egor 41d94cd8e7 Merge pull request #2189 from BEDOLAGA-DEV/h29si7-bedolaga/add-pinned-message-feature-in-admin-menu
Prevent duplicate unknown command message after /start
2025-12-22 13:00:53 +03:00
Egor 2422f56137 Avoid unknown handler after /start commands 2025-12-22 13:00:17 +03:00
Egor bb7b95c971 Merge pull request #2188 from BEDOLAGA-DEV/revert-2187-6r14me-bedolaga/add-pinned-message-feature-in-admin-menu
Revert "Add pinned message placement control and reduce broadcast throttling"
2025-12-22 13:00:02 +03:00
Egor a17344858b Revert "Add pinned message placement control and reduce broadcast throttling" 2025-12-22 12:59:52 +03:00
Egor a45dd70531 Merge pull request #2187 from BEDOLAGA-DEV/6r14me-bedolaga/add-pinned-message-feature-in-admin-menu
Add pinned message placement control and reduce broadcast throttling
2025-12-22 12:49:05 +03:00
Egor 2bcda34f1c Add pinned message placement control and fix throttling broadcast 2025-12-22 12:48:21 +03:00
Egor ac698b3a74 Merge pull request #2186 from BEDOLAGA-DEV/revert-2185-1li4we-bedolaga/add-pinned-message-feature-in-admin-menu
Revert "Add position control for pinned messages"
2025-12-22 12:47:19 +03:00
Egor e52a47bfb3 Revert "Add position control for pinned messages" 2025-12-22 12:47:10 +03:00
Egor 47d7bdd632 Merge pull request #2185 from BEDOLAGA-DEV/1li4we-bedolaga/add-pinned-message-feature-in-admin-menu
Add position control for pinned messages
2025-12-22 12:42:12 +03:00
Egor f7be2911cd Add position control for pinned messages 2025-12-22 12:41:43 +03:00
Egor 104cf899ee Merge pull request #2184 from BEDOLAGA-DEV/revert-2183-b72kym-bedolaga/add-pinned-message-feature-in-admin-menu
Revert "Support media attachments in pinned messages"
2025-12-22 12:30:15 +03:00
Egor 6f0e3c0bfd Revert "Support media attachments in pinned messages" 2025-12-22 12:30:06 +03:00
Egor 09d8261cbf Merge pull request #2183 from BEDOLAGA-DEV/b72kym-bedolaga/add-pinned-message-feature-in-admin-menu
Support media attachments in pinned messages
2025-12-22 12:18:48 +03:00
Egor 3c9580dc30 Add pinned messages to universal migration 2025-12-22 12:18:32 +03:00
Egor 1d72419243 Merge pull request #2181 from BEDOLAGA-DEV/dev5
fix: promo info button in menu
2025-12-22 11:50:49 +03:00
Egor ae420a592a Update menu.py 2025-12-22 11:47:42 +03:00
Egor a2586d88e1 Merge pull request #2179 from D4nilKO/main
chore: sync subscription with Remnawave on creation/replacement
2025-12-21 20:25:13 +03:00
Dxnil 0b61878de8 chore: sync subscription with Remnawave on creation/replacement 2025-12-21 17:53:16 +03:00
Egor 5ad9fd8ae7 Update Dockerfile 2025-12-21 08:25:31 +03:00
Egor f9cfaa4bc1 Update docker-registry.yml 2025-12-21 08:25:21 +03:00
Egor e1cc7a2e23 Update docker-hub.yml 2025-12-21 08:25:10 +03:00
Egor 7a0faa4b0a Merge pull request #2177 from BEDOLAGA-DEV/dev5
Remnawave 2.4.0+ Api Update, Menu layout Api, Partners Stats Api, Yookassa 22%
2025-12-21 08:13:51 +03:00
Egor a87d067133 Update exported_at to use timezone-aware datetime 2025-12-21 08:08:07 +03:00
Egor 42759854c2 Use timezone-aware datetime for calculations 2025-12-21 08:07:31 +03:00
Egor b2027df7f3 Replace deprecated datetime.utcnow() with _utcnow()
Replaced deprecated datetime.utcnow() with a custom _utcnow() function to return current UTC time as naive datetime.
2025-12-21 08:06:52 +03:00
Egor ce865a9b2a Update start.py 2025-12-21 08:05:53 +03:00
Egor 7dcd11b964 Update contests.py 2025-12-21 07:53:20 +03:00
Egor 6482284cd8 Use configured timezone for contest scheduling
Refactor time handling to use configured timezone for scheduling.
2025-12-21 07:52:35 +03:00
Egor 7f8fbf069b Update referral_contest_service.py 2025-12-21 07:52:00 +03:00
Egor 3e6064756e Update contests.py 2025-12-21 07:51:21 +03:00
Egor 1d1a9e0d78 Add tables for menu layout history and button clicks 2025-12-21 07:18:52 +03:00
Egor bc6e57765e Use UTC for registration days calculation 2025-12-21 07:18:31 +03:00
Egor 16031023cb Change datetime from now() to utcnow() 2025-12-21 07:17:52 +03:00
Egor 151ce092b9 Enhance button stats middleware with builtin callbacks
Added a set of known builtin callback data for button statistics logging.
2025-12-21 07:16:55 +03:00
Egor 01d3659ad2 Update stats_service.py 2025-12-21 07:16:24 +03:00
Egor 7dd0f5f7b0 Improve button configuration and logging
Refactor button construction and configuration handling.
2025-12-21 07:16:06 +03:00
Egor cfd79f4b23 Update partner_stats_service.py 2025-12-21 07:15:39 +03:00
PEDZEO 0cd355a878 Merge pull request #2175 from BEDOLAGA-DEV/buttons
Buttons
2025-12-21 05:02:45 +03:00
Egor 344ff65710 Merge pull request #2173 from Gy9vin/main
Юкасса сдк обновление
2025-12-21 04:52:41 +03:00
PEDZEO 1b47f2aa99 Remove unused imports from partner_stats_service.py to streamline the code and improve readability. 2025-12-21 04:38:42 +03:00
PEDZEO 1d01a77457 Add global partner statistics endpoints and enhance partner-related schemas; implement detailed stats retrieval for referrers and daily statistics. 2025-12-21 04:35:58 +03:00
PEDZEO aa669fa3cd Refactor logging in ButtonStatsMiddleware and cleanup debug endpoints in menu_layout; streamline button click logging and enhance error handling. 2025-12-21 04:05:26 +03:00
Egor e487776510 Update remnawave_service.py 2025-12-21 03:40:00 +03:00
Egor 9d038f7443 Update remnawave_api.py 2025-12-21 03:38:41 +03:00
PEDZEO 02ebcd368e Improve logging in get_top_users to include detailed user data and streamline response item creation 2025-12-21 03:15:25 +03:00
PEDZEO 7e1a2998af Add debug_stats endpoint to expose raw button click log data and enhance logging for user_id checks in get_top_users 2025-12-21 03:10:41 +03:00
Egor 0a5383eb80 Update LICENSE 2025-12-21 02:54:58 +03:00
PEDZEO 763d8f2aaa Enhance logging in get_stats_by_button_type to include total record count in button_click_logs and update stats logging to info level for better visibility. 2025-12-21 02:54:40 +03:00
PEDZEO ce11ec7c0e Enhance ButtonStatsMiddleware with improved logging and error handling; log middleware activation status based on MENU_LAYOUT_ENABLED setting. 2025-12-21 02:17:40 +03:00
PEDZEO 1bb0a5c000 Refactor MenuLayoutStatsService to return hourly statistics as a complete list for all 24 hours, ensuring counts default to 0 when no data exists for a given hour. 2025-12-21 01:14:04 +03:00
gy9vin 2cb6dfb4aa Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-21 00:38:14 +03:00
gy9vin 837473b274 Апи для детальной статистики по конкурсу рефералов 2025-12-21 00:38:08 +03:00
gy9vin aab32299ac Детальная статистика по конкурсу рефералов. 2025-12-21 00:19:13 +03:00
PEDZEO 2a3f1bac76 Refactor MenuLayoutStatsService to create a dictionary for quick access to weekday statistics, ensuring all weekdays are returned with a count of 0 if no data exists. 2025-12-20 23:33:35 +03:00
PEDZEO f4ed274623 Merge branch 'buttons' of https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot into buttons 2025-12-20 21:56:36 +03:00
PEDZEO 32c28aedea Enhance MenuLayoutStatsService to include daily, weekly, and monthly click statistics for buttons, improving analytics capabilities in the menu layout. 2025-12-20 21:56:33 +03:00
PEDZEO 69927a4db2 Improve error handling and logging in MenuLayout statistics endpoints; change button click log ordering to descending. 2025-12-20 21:50:13 +03:00
gy9vin bdc1df737b Фикс автопродления и Фиксы конкурсов 2025-12-20 14:57:38 +03:00
gy9vin c8c79ea807 Улучшение реферального конкурса! 2025-12-20 14:32:26 +03:00
PEDZEO d75fc0c60f Add statistics endpoints in MenuLayoutService for button clicks, including by type, hour, weekday, top users, period comparison, and user click sequences 2025-12-20 03:32:34 +03:00
PEDZEO 931b282f5b Enhance button handling in MenuLayoutService to improve connect button identification and URL management 2025-12-20 03:27:31 +03:00
PEDZEO 10bc00d429 Merge pull request #2174 from BEDOLAGA-DEV/buttons
Buttons
2025-12-20 02:55:59 +03:00
PEDZEO dd24b7ffde Add ButtonStatsMiddleware for automatic button click logging in bot setup 2025-12-20 02:42:40 +03:00
PEDZEO 5fa627bd7f Refactor subscription days calculation and update autopay property naming in main menu keyboard 2025-12-20 02:31:52 +03:00
PEDZEO b8671ef07d Refactor subscription days variable usage in MenuLayoutService to improve clarity and consistency 2025-12-20 02:29:41 +03:00
PEDZEO 7a30aa23dc Update subscription parameter naming in main menu keyboard function 2025-12-20 02:23:55 +03:00
PEDZEO a87d52f2bc Update subscription placeholder naming in menu layout and adjust context variable accordingly 2025-12-20 02:15:51 +03:00
PEDZEO 5919cfff16 Refactor referral data retrieval and update subscription placeholder naming in menu layout 2025-12-20 01:55:26 +03:00
PEDZEO e743689b34 Enhance main menu keyboard functionality by adding user data support and subscription details retrieval 2025-12-20 01:40:23 +03:00
PEDZEO 37dd5ede9f fix 2025-12-19 23:27:00 +03:00
Mikhail 930aabc166 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-19 17:42:58 +03:00
gy9vin f70ad92efe Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-19 17:42:10 +03:00
gy9vin 01fd7a317d НДС 22 2025-12-19 17:42:03 +03:00
Egor 507df0af33 Merge pull request #2172 from BEDOLAGA-DEV/d2pxq4-bedolaga/fix-missing-localization-key-for-ru
Add missing Buy Traffic localization key
2025-12-19 07:28:16 +03:00
PEDZEO af91423dec Merge pull request #2171 from BEDOLAGA-DEV/buttons
Buttons
2025-12-19 07:27:44 +03:00
Egor 893ff0e8f1 Add missing Buy Traffic localization key 2025-12-19 07:27:27 +03:00
Egor cde838864f Merge pull request #2170 from BEDOLAGA-DEV/main
w
2025-12-19 07:23:40 +03:00
Egor 2eb2661848 Merge pull request #2167 from BEDOLAGA-DEV/dev5
Dev5
2025-12-19 07:23:00 +03:00
Egor d7a935906e Merge pull request #2168 from Gy9vin/main
Фиксы
2025-12-19 07:22:42 +03:00
PEDZEO b81400105f Add button open mode and webapp url 2025-12-19 04:02:58 +03:00
PEDZEO 8c3e71bdfd Fix menu layout update conversions 2025-12-19 03:22:44 +03:00
PEDZEO 025d50675f test 2025-12-19 02:14:57 +03:00
Mikhail 5f540d907e Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-18 17:51:05 +03:00
gy9vin c6175255a5 фикс кнопок конкурсов 2025-12-18 17:50:36 +03:00
gy9vin 71facd9624 Активация подписки после пополнения баланса! 2025-12-18 08:45:59 +03:00
Egor 89a5f5c6f7 Merge pull request #2166 from BEDOLAGA-DEV/j0zhkm-bedolaga/add-subscription-management-for-users-who-unsubscribed
Add toggle for trial deactivation on channel unsubscribe
2025-12-18 03:23:14 +03:00
Egor 7b60be1ec7 Add toggle for trial deactivation on channel unsubscribe 2025-12-18 03:04:13 +03:00
Egor ed314ca19b Merge pull request #2165 from Gy9vin/main
Фиксы, правки и хотелки
2025-12-18 00:16:25 +03:00
Mikhail 0b714893e7 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-18 00:14:20 +03:00
gy9vin 5ca0f5fc27 Еще правки 2025-12-18 00:13:57 +03:00
Egor 31fa7b7072 Merge pull request #2164 from BEDOLAGA-DEV/main
ц
2025-12-17 20:23:29 +03:00
Egor 86d7003c33 Merge pull request #2161 from Gy9vin/main
Кнопка конкурсов + прототип налоговой. не трогайте налоговую еще!!!
2025-12-17 19:13:32 +03:00
Egor 753d77b4c2 Merge pull request #2162 from Vysokostnyi/main
Добавлено передача telegram_user_id в описание платежа
2025-12-17 19:13:05 +03:00
Vysokostnyi 4add298aab Добавлено передача telegram_user_id в описание платежа 2025-12-16 02:41:58 +03:00
gy9vin e76f2f3d50 Кнопка конкурсов + прототип налоговой. не трогайте налоговую еще!!! 2025-12-16 00:59:23 +03:00
Egor dcb0002f4a Merge pull request #2160 from Gy9vin/main
Конкурсные
2025-12-15 22:58:21 +03:00
gy9vin 332c20fc45 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-15 21:36:23 +03:00
gy9vin 305a3c4490 Фиксы по конкурсам. 2025-12-15 21:34:56 +03:00
Mikhail a9ecd5f620 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-15 20:05:03 +03:00
gy9vin 2f0594e361 Конкурсная система. 2025-12-15 20:04:39 +03:00
Egor 65b36c3a5f Merge pull request #2159 from BEDOLAGA-DEV/revert-2158-c8yqa8-bedolaga/add-subscription-settings-for-happ_cryptolink
Revert "Add limited Happ cryptolink support"
2025-12-15 11:12:22 +03:00
Egor f8ef2b9f5a Revert "Add limited Happ cryptolink support" 2025-12-15 11:12:14 +03:00
Egor 70a283d1f1 Merge pull request #2158 from BEDOLAGA-DEV/c8yqa8-bedolaga/add-subscription-settings-for-happ_cryptolink
Add limited Happ cryptolink support
2025-12-15 11:00:54 +03:00
Egor f2a5032f15 Refresh Happ link before displaying subscription 2025-12-15 11:00:39 +03:00
Egor 0f5fcdd067 Merge pull request #2157 from BEDOLAGA-DEV/revert-2156-2gz1mq-bedolaga/add-subscription-settings-for-happ_cryptolink
Revert "Add limited Happ cryptolink support"
2025-12-15 11:00:26 +03:00
Egor 80388be54f Revert "Add limited Happ cryptolink support" 2025-12-15 11:00:17 +03:00
Egor 47f0b77138 Merge pull request #2156 from BEDOLAGA-DEV/2gz1mq-bedolaga/add-subscription-settings-for-happ_cryptolink
Add limited Happ cryptolink support
2025-12-15 10:41:18 +03:00
Egor 162e7da350 Add cooldown for Happ device reset links 2025-12-15 10:40:59 +03:00
Egor 07e1e08b1d Merge pull request #2154 from BEDOLAGA-DEV/revert-2153-49fvja-bedolaga/add-web-admin-button-for-admins
Revert "Add admin web panel miniapp toggle"
2025-12-15 07:24:51 +03:00
Egor fc955bd3b2 Revert "Add admin web panel miniapp toggle" 2025-12-15 07:24:41 +03:00
Egor 1b504063d2 Merge pull request #2153 from BEDOLAGA-DEV/49fvja-bedolaga/add-web-admin-button-for-admins
Add admin web panel miniapp toggle
2025-12-15 07:19:01 +03:00
Egor 174f9b8517 Add admin web panel miniapp toggle 2025-12-15 07:18:45 +03:00
Egor 3706d5ceb4 Merge pull request #2152 from BEDOLAGA-DEV/main
w
2025-12-14 19:35:39 +03:00
Egor 3577054d79 Merge pull request #2151 from Gy9vin/main
Коркурсная система + АПИ
2025-12-14 19:32:41 +03:00
gy9vin afd4fe8d1d Конкурсы +АПИ 2025-12-14 14:37:29 +03:00
gy9vin 1409a0ab8d Конкурсы 2025-12-14 01:38:22 +03:00
Egor 9df8ca52d6 Merge pull request #2150 from BEDOLAGA-DEV/dev5
Dev5
2025-12-12 08:54:47 +03:00
Egor 0902efc007 Merge pull request #2149 from BEDOLAGA-DEV/9ks456-bedolaga/fix-url-validation-error-in-bot
Restrict blacklist URL input to waiting state
2025-12-12 08:52:51 +03:00
Egor 0b4fea02a1 Restrict blacklist URL input to waiting state 2025-12-12 08:52:36 +03:00
Egor 01580aae74 Merge pull request #2148 from BEDOLAGA-DEV/dev5
Dev5
2025-12-12 06:58:03 +03:00
Egor 7e022a1eca Merge pull request #2147 from BEDOLAGA-DEV/om94g5-bedolaga/update-subscription-link-display
Wrap Happ CryptoLink subscription link in blockquote
2025-12-12 06:56:36 +03:00
Egor f31c64602c Wrap Happ CryptoLink subscription link in blockquote 2025-12-12 06:55:58 +03:00
Egor f193b0bfcf Update Python version argument to v2.9.1 2025-12-12 06:29:18 +03:00
Egor b961529001 Update version to v2.9.1 in docker-registry.yml 2025-12-12 06:29:09 +03:00
Egor afa172f3cc Update docker-hub.yml 2025-12-12 06:28:52 +03:00
Egor 8be1d7e423 Delete app/handlers/.DS_Store 2025-12-12 06:10:15 +03:00
Egor 8c4db0a0e6 Delete app/.DS_Store 2025-12-12 06:09:46 +03:00
Egor 1e56e4eaa6 Merge pull request #2146 from BEDOLAGA-DEV/main
w
2025-12-12 06:08:29 +03:00
Egor a8da98c35e Update docker-compose.yml 2025-12-12 06:07:54 +03:00
Egor 7ca6942443 Rename vpn_logo (1).png to vpn_logo.png 2025-12-12 06:07:06 +03:00
Egor 3f97d22a76 Add files via upload 2025-12-12 06:06:42 +03:00
Egor e3dccf2c1b Merge pull request #2144 from Gy9vin/main
Обновки
2025-12-12 06:04:37 +03:00
Egor d0e25fff3d Merge pull request #2145 from BEDOLAGA-DEV/dev5
Dev5
2025-12-12 06:03:41 +03:00
Egor 3699d248b1 Add enrich_happ_links parameter to get_all_users 2025-12-12 05:59:58 +03:00
Egor bffbf3ea39 Update remnawave_api.py 2025-12-12 05:59:01 +03:00
gy9vin e869c9b167 Обновление env 2025-12-11 23:21:12 +03:00
gy9vin 0b2b109e86 Минификсы 2025-12-11 23:11:23 +03:00
gy9vin 5dd586e0b2 Новый фильтр Готовы к продлению 2025-12-11 22:42:37 +03:00
gy9vin c9de084efa Фикс корзины 2025-12-11 22:25:42 +03:00
gy9vin 81b3c7ed3f черный список + фиксы 2025-12-11 10:56:14 +03:00
gy9vin 80785f22b0 Черный список, мониторинг суточно графика по регламенту 2025-12-10 19:13:52 +03:00
Egor b1cb368bf6 Merge pull request #2139 from BEDOLAGA-DEV/main
w
2025-12-10 02:21:37 +03:00
Egor 9fc977ace3 Merge pull request #2132 from remnawave-contrib/ivan-nginx-platega-min
Reduce minimum amount for Platega transactions from 10000 to 100 kope…
2025-12-10 02:16:49 +03:00
Egor 47acd37449 Merge pull request #2133 from remnawave-contrib/ivan-nginx-health-check
fix: healthcheck command to include API key in requests
2025-12-10 02:16:08 +03:00
Egor d7fa956f58 Merge pull request #2138 from BEDOLAGA-DEV/dev5
Dev5
2025-12-10 02:14:54 +03:00
Egor 98164c2083 Merge pull request #2137 from BEDOLAGA-DEV/g0wcwm-bedolaga/fix-api-issue-with-faq-status-editing
Fix FAQ status route matching
2025-12-10 02:10:00 +03:00
Egor 81f5ce429e Fix FAQ status route matching 2025-12-10 02:09:46 +03:00
Egor 5a3bded4a8 Merge pull request #2136 from BEDOLAGA-DEV/revert-2134-dfg5t2-bedolaga/fix-api-issue-with-faq-status-editing
Revert "Handle FAQ status update without request body"
2025-12-10 02:09:00 +03:00
Egor 2743a845bd Revert "Handle FAQ status update without request body" 2025-12-10 02:08:51 +03:00
Egor 7c222c0aed Merge pull request #2135 from BEDOLAGA-DEV/dev5
Dev5
2025-12-09 23:49:34 +03:00
Egor 3222c82b46 Merge pull request #2134 from BEDOLAGA-DEV/dfg5t2-bedolaga/fix-api-issue-with-faq-status-editing
Handle FAQ status update without request body
2025-12-09 23:49:16 +03:00
Egor 9f0f3a367c Handle FAQ status update without request body 2025-12-09 23:48:54 +03:00
Ivan.Nginx f702356f48 fix: healthcheck command to include API key in requests 2025-12-09 03:14:24 +03:00
Ivan.Nginx 48d799e312 Reduce minimum amount for Platega transactions from 10000 to 100 kopeks (from 100 to 1 rub) 2025-12-09 00:59:29 +03:00
Egor 7c05b36b9d Update Python version to 2.9.0 in Dockerfile 2025-12-08 04:35:25 +03:00
Egor b398eecbeb Update versioning scheme to v2.9.0 2025-12-08 04:35:13 +03:00
Egor 5ab80b09a1 Update docker-hub.yml 2025-12-08 04:34:58 +03:00
Egor 535b92d43b Merge pull request #2129 from BEDOLAGA-DEV/dev5
Обновление Api Remnawave под версию 2.3.0 + Возможность установить таги для триалов и платных подписок + Правки Web Api
2025-12-08 04:28:00 +03:00
Egor cbaddd65e0 Merge pull request #2128 from BEDOLAGA-DEV/revert-2127-xs3s5d-bedolaga/add-ability-to-assign-internal-squads
Revert "Support user-specific internal squads"
2025-12-08 04:24:04 +03:00
Egor 136cae68f1 Revert "Support user-specific internal squads" 2025-12-08 04:23:57 +03:00
Egor 38abf49af9 Merge pull request #2127 from BEDOLAGA-DEV/xs3s5d-bedolaga/add-ability-to-assign-internal-squads
Support user-specific internal squads
2025-12-08 04:20:37 +03:00
Egor 799243a988 Support user-specific internal squads 2025-12-08 04:20:21 +03:00
Egor 6d024716c7 Merge pull request #2126 from BEDOLAGA-DEV/revert-2125-8z51jt-bedolaga/add-support-for-assigning-internal-squads
Revert "Add internal squad management for users and trials"
2025-12-08 04:17:14 +03:00
Egor 0892f494d9 Revert "Add internal squad management for users and trials" 2025-12-08 04:17:05 +03:00
Egor 42b67bdc47 Merge pull request #2125 from BEDOLAGA-DEV/8z51jt-bedolaga/add-support-for-assigning-internal-squads
Add internal squad management for users and trials
2025-12-08 04:15:35 +03:00
Egor 6c41263511 Add internal squad management for users and trials 2025-12-08 04:15:18 +03:00
Egor 1aab9550ce Merge pull request #2124 from BEDOLAGA-DEV/42kgo8-bedolaga/fix-squads-functionality-in-bot-api
Fix RemnaWave squad creation success flag
2025-12-08 04:00:58 +03:00
Egor d596b19d96 Fix RemnaWave squad creation success flag 2025-12-08 04:00:42 +03:00
Egor 0863e3023d Merge pull request #2123 from BEDOLAGA-DEV/revert-2122-mbdf7a-bedolaga/fix-squads-functionality-in-bot-api
Revert "Fix RemnaWave squad inbound serialization"
2025-12-08 04:00:31 +03:00
Egor 1ef2e1264f Revert "Fix RemnaWave squad inbound serialization" 2025-12-08 04:00:22 +03:00
Egor c40806a5a3 Merge pull request #2122 from BEDOLAGA-DEV/mbdf7a-bedolaga/fix-squads-functionality-in-bot-api
Fix RemnaWave squad inbound serialization
2025-12-08 03:52:53 +03:00
Egor 841c288313 Fix serialization of squad inbounds 2025-12-08 03:52:38 +03:00
Egor cd1a4a4a6e Merge pull request #2121 from BEDOLAGA-DEV/0w5so9-bedolaga/fix-critical-error-in-show_node_details
Bind refreshed callbacks to bot
2025-12-08 03:34:10 +03:00
Egor e9e61f4892 Bind refreshed callbacks to bot 2025-12-08 03:33:56 +03:00
Egor 167cddcd2f Merge pull request #2120 from BEDOLAGA-DEV/revert-2119-hcism8-bedolaga/fix-critical-error-in-show_node_details
Revert "Fix node details refresh callback usage"
2025-12-08 03:33:07 +03:00
Egor 5b4c597e9b Revert "Fix node details refresh callback usage" 2025-12-08 03:32:58 +03:00
Egor 0cb1c9b580 Merge pull request #2119 from BEDOLAGA-DEV/hcism8-bedolaga/fix-critical-error-in-show_node_details
Fix node details refresh callback usage
2025-12-08 03:26:46 +03:00
Egor ff4471a22a Fix node details refresh callback usage 2025-12-08 03:26:32 +03:00
Egor 93d7bd74a3 Merge pull request #2118 from BEDOLAGA-DEV/revert-2117-sifkp4-bedolaga/fix-critical-error-in-show_node_details
Revert "Fix callback reuse after node actions"
2025-12-08 03:26:22 +03:00
Egor c770948c1f Revert "Fix callback reuse after node actions" 2025-12-08 03:26:14 +03:00
Egor 2e1a6faec1 Merge pull request #2117 from BEDOLAGA-DEV/sifkp4-bedolaga/fix-critical-error-in-show_node_details
Fix callback reuse after node actions
2025-12-08 03:24:26 +03:00
Egor 0031c9e2e0 Fix callback reuse after node actions 2025-12-08 03:24:13 +03:00
Egor 637d2a07e0 Merge pull request #2116 from BEDOLAGA-DEV/udkyiq-bedolaga/expand-remnawave-node-statistics
Expand Remnawave node statistics
2025-12-08 03:19:36 +03:00
Egor 988ffbebdb Expand Remnawave node statistics 2025-12-08 03:19:23 +03:00
Egor 5a795a2ae2 Update remnawave_api.py 2025-12-08 03:13:16 +03:00
Egor 9b6cd74dbf Update remnawave_api.py 2025-12-08 03:01:21 +03:00
Egor daa2f13ca3 Merge pull request #2114 from BEDOLAGA-DEV/qhbf7v-bedolaga/add-custom-tag-feature-for-trial-users
Add configurable RemnaWave user tags for trial and paid subscriptions
2025-12-08 02:46:32 +03:00
Egor 386b9ae998 Add configurable user tags for trial and paid subscriptions 2025-12-08 02:44:53 +03:00
Egor e64854dc48 Update remnawave_api.py 2025-12-08 01:42:03 +03:00
Egor 23eed94009 Add functions to extract traffic bytes from user data 2025-12-08 01:32:23 +03:00
Egor f04ffa58e4 Update remnawave_api.py 2025-12-08 01:31:42 +03:00
Egor fc45db6f3c Merge pull request #2112 from BEDOLAGA-DEV/dev5
Dev5
2025-12-06 17:31:03 +03:00
Egor b8874772be Merge pull request #2111 from BEDOLAGA-DEV/8ioop5-bedolaga/add-api-endpoint-for-statistics
Add full statistics API endpoint
2025-12-06 16:14:52 +03:00
Egor d8c7793a26 Merge pull request #2110 from BEDOLAGA-DEV/dev5
Dev5
2025-12-06 15:34:35 +03:00
Egor 10d08d5b40 Add full statistics API endpoint 2025-12-06 15:32:01 +03:00
Egor b07a889826 Merge pull request #2109 from BEDOLAGA-DEV/ffowe2-bedolaga/-api
Add promo offer broadcast endpoint
2025-12-06 15:28:12 +03:00
Egor a24b4c72e9 Add promo offer broadcast endpoint 2025-12-06 15:21:54 +03:00
Egor 5e1d00ad0d Merge pull request #2108 from belousotroll/fix-calculating-tg-starts-amount
Fix calculating tg starts amount
2025-12-06 06:24:52 +03:00
belousotroll d33ebb1453 Merge branch 'BEDOLAGA-DEV:main' into fix-calculating-tg-starts-amount 2025-12-05 21:54:46 +07:00
Egor 7a2bcf3d2f Merge pull request #2107 from BEDOLAGA-DEV/dev5
Dev5
2025-12-05 10:22:44 +03:00
Egor dbb68582e4 Merge pull request #2106 from BEDOLAGA-DEV/gpower-bedolaga/add-telegram-id-support-to-promo-offers-api
Add Telegram ID support to promo offers API
2025-12-05 10:18:34 +03:00
Egor 479b9bc384 Add telegram id support for promo offers 2025-12-05 09:56:03 +03:00
Egor 73f3987481 Merge pull request #2105 from BEDOLAGA-DEV/dev5
Dev5
2025-12-03 07:30:06 +03:00
Egor 8b38f1e37d Merge pull request #2104 from BEDOLAGA-DEV/axy4i1-bedolaga/fix-telegram-api-error-in-show_balance_menu
Handle balance menu for YooKassa SBP QR and add Heleket localization keys
2025-12-03 07:29:43 +03:00
Egor 6976129972 Handle balance menu edits for SBP invoices and add Heleket locales 2025-12-03 07:29:01 +03:00
belousotroll 207e4673c0 Merge branch 'BEDOLAGA-DEV:main' into fix-calculating-tg-starts-amount 2025-11-30 20:30:14 +07:00
belousotroll 160ff7ff9f fix: calculating telegram starts amount 2025-11-30 19:29:29 +07:00
Egor 6c7c57138d Merge pull request #2099 from BEDOLAGA-DEV/dev5
Dev5
2025-11-29 09:52:18 +03:00
Egor 12b5a39194 Update subscription_purchase_service.py 2025-11-29 09:50:35 +03:00
Egor 7678150e6a Update subscription_auto_purchase_service.py 2025-11-29 09:50:05 +03:00
Egor 99e35cae4c Merge pull request #2098 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 23:02:44 +03:00
Egor af313a12ed Add files via upload 2025-11-28 23:00:08 +03:00
Egor 71d18287fe Add files via upload 2025-11-28 22:59:27 +03:00
Egor 6b24b69b53 Merge pull request #2097 from BEDOLAGA-DEV/dev5
Update menu.py
2025-11-28 21:28:54 +03:00
Egor 9528457b89 Update menu.py 2025-11-28 21:27:11 +03:00
Egor 69322640c5 Merge pull request #2096 from BEDOLAGA-DEV/dev5
Update purchase.py
2025-11-28 21:17:51 +03:00
Egor 3c0703b599 Update purchase.py 2025-11-28 21:13:49 +03:00
Egor d14aa5bd8a Update README.md 2025-11-28 06:42:43 +03:00
Egor baa5da243e Merge pull request #2095 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 06:24:37 +03:00
Egor fff01d1ce3 Update validators.py 2025-11-28 06:23:55 +03:00
Egor 52c8442423 Merge pull request #2094 from BEDOLAGA-DEV/revert-2093-acgzuy-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Remove blockquote markup to prevent Telegram parse errors"
2025-11-28 06:02:16 +03:00
Egor 150f9e741a Revert "Remove blockquote markup to prevent Telegram parse errors" 2025-11-28 06:02:08 +03:00
Egor 4ae2234b7f Merge pull request #2093 from BEDOLAGA-DEV/acgzuy-bedolaga/fix-unsupported-html-tag-parsing-error
Remove blockquote markup to prevent Telegram parse errors
2025-11-28 06:00:48 +03:00
Egor 71366a8133 Remove blockquote markup to prevent Telegram parse errors 2025-11-28 06:00:32 +03:00
Egor fb6cda2c63 Merge pull request #2092 from BEDOLAGA-DEV/revert-2091-ndvrn0-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Harden message send fallback for invalid HTML"
2025-11-28 06:00:25 +03:00
Egor 2d4f9da9a7 Revert "Harden message send fallback for invalid HTML" 2025-11-28 06:00:16 +03:00
Egor 7a39abe05c Merge pull request #2091 from BEDOLAGA-DEV/ndvrn0-bedolaga/fix-unsupported-html-tag-parsing-error
Harden message send fallback for invalid HTML
2025-11-28 05:51:20 +03:00
Egor 79659ec5fe Harden message send fallback for invalid HTML 2025-11-28 05:51:05 +03:00
Egor a3457d5853 Merge pull request #2090 from BEDOLAGA-DEV/revert-2089-pmhwx5-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Improve fallback on Telegram parse errors"
2025-11-28 05:50:59 +03:00
Egor 0392aa5b45 Revert "Improve fallback on Telegram parse errors" 2025-11-28 05:50:50 +03:00
Egor 2c68d9ded9 Merge pull request #2089 from BEDOLAGA-DEV/pmhwx5-bedolaga/fix-unsupported-html-tag-parsing-error
Improve fallback on Telegram parse errors
2025-11-28 05:49:24 +03:00
Egor a437ebd65b Improve fallback on Telegram parse errors 2025-11-28 05:49:04 +03:00
Egor e2323755de Merge pull request #2088 from BEDOLAGA-DEV/revert-2087-lyaqkf-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Handle Telegram parse errors gracefully"
2025-11-28 05:48:57 +03:00
Egor 5e23920723 Revert "Handle Telegram parse errors gracefully" 2025-11-28 05:48:48 +03:00
Egor 5e45fc1285 Merge pull request #2087 from BEDOLAGA-DEV/lyaqkf-bedolaga/fix-unsupported-html-tag-parsing-error
Handle Telegram parse errors gracefully
2025-11-28 05:43:41 +03:00
Egor ff1556ffcd Handle parse errors with plain text fallback 2025-11-28 05:43:23 +03:00
Egor 549e8fa332 Merge pull request #2086 from BEDOLAGA-DEV/revert-2085-aoflna-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Ensure photo replies default to HTML parse mode"
2025-11-28 05:43:15 +03:00
Egor 8a7fb598fa Revert "Ensure photo replies default to HTML parse mode" 2025-11-28 05:43:06 +03:00
Egor 521c364fbb Merge pull request #2085 from BEDOLAGA-DEV/aoflna-bedolaga/fix-unsupported-html-tag-parsing-error
Ensure photo replies default to HTML parse mode
2025-11-28 05:41:40 +03:00
Egor db60f5c5ba Ensure photo replies default to HTML parse mode 2025-11-28 05:41:24 +03:00
Egor affc07985e Merge pull request #2084 from BEDOLAGA-DEV/4drat9-bedolaga/fix-empty-page-on-/redoc
Ensure ReDoc loads reliably
2025-11-28 04:00:35 +03:00
Egor 2761255e65 Ensure ReDoc loads reliably 2025-11-28 03:58:59 +03:00
Egor 53988c319b Update Dockerfile 2025-11-28 03:21:48 +03:00
Egor 777ea3b99b Update docker-registry.yml 2025-11-28 03:21:34 +03:00
Egor 672ce18f38 Update docker-hub.yml 2025-11-28 03:21:20 +03:00
Egor aafcec2716 Merge pull request #2083 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 03:03:03 +03:00
Egor 4e5b607288 Merge pull request #2082 from BEDOLAGA-DEV/ycxgll-bedolaga/restore-reverse-sync-button-in-remnawave
Recover missing RemnaWave users during panel sync
2025-11-28 03:00:24 +03:00
Egor aae85683a0 Recover missing RemnaWave users during panel sync 2025-11-28 03:00:09 +03:00
Egor 9971c43218 Merge pull request #2081 from BEDOLAGA-DEV/revert-2080-jq66gt-bedolaga/restore-reverse-sync-button-in-remnawave
Revert "Handle RemnaWave status validation changes"
2025-11-28 02:59:49 +03:00
Egor b4370e9a34 Revert "Handle RemnaWave status validation changes" 2025-11-28 02:59:41 +03:00
Egor 101e27acd6 Merge pull request #2080 from BEDOLAGA-DEV/jq66gt-bedolaga/restore-reverse-sync-button-in-remnawave
Handle RemnaWave status validation changes
2025-11-28 02:53:30 +03:00
Egor 5b9002896e Handle RemnaWave status validation changes 2025-11-28 02:53:13 +03:00
Egor e8ac14b0b7 Merge pull request #2079 from BEDOLAGA-DEV/revert-2078-lw4oi2-bedolaga/restore-reverse-sync-button-in-remnawave
Revert "Align RemnaWave sync with new status and expiration rules"
2025-11-28 02:52:57 +03:00
Egor 7cd6c3acb7 Revert "Align RemnaWave sync with new status and expiration rules" 2025-11-28 02:52:47 +03:00
Egor 7e7795fc4e Merge pull request #2078 from BEDOLAGA-DEV/lw4oi2-bedolaga/restore-reverse-sync-button-in-remnawave
Align RemnaWave sync with new status and expiration rules
2025-11-28 02:49:23 +03:00
Egor 1f75413abd Handle RemnaWave API status and expire constraints 2025-11-28 02:48:49 +03:00
Egor 3c6d674ce5 Merge pull request #2077 from BEDOLAGA-DEV/revert-2076-7uxban-bedolaga/restore-reverse-sync-button-in-remnawave
Revert "Add reverse remnawave sync with batched upload"
2025-11-28 02:48:37 +03:00
Egor 92efc52f7e Revert "Add reverse remnawave sync with batched upload" 2025-11-28 02:48:28 +03:00
Egor c558f69d62 Merge pull request #2076 from BEDOLAGA-DEV/7uxban-bedolaga/restore-reverse-sync-button-in-remnawave
Add reverse remnawave sync with batched upload
2025-11-28 02:39:55 +03:00
Egor 168cb5ea38 Add reverse remnawave sync with batched upload 2025-11-28 02:34:50 +03:00
Egor 6a48cc687c Merge pull request #2075 from BEDOLAGA-DEV/r3w3lo-bedolaga/fix-bot-crash-due-to-html-tags
Avoid unescaping attribute entities in sanitizer
2025-11-28 02:30:54 +03:00
Egor 9a5b0553c6 Avoid unescaping attribute entities in sanitizer 2025-11-28 02:22:39 +03:00
Egor 7515964599 Merge pull request #2074 from BEDOLAGA-DEV/revert-2072-ywwhae-bedolaga/fix-bot-crash-due-to-html-tags
Revert "Improve HTML sanitization for menu messages"
2025-11-28 02:12:41 +03:00
Egor bf5e0ecd4f Revert "Improve HTML sanitization for menu messages" 2025-11-28 02:12:32 +03:00
Egor 6d058beade Merge pull request #2072 from BEDOLAGA-DEV/ywwhae-bedolaga/fix-bot-crash-due-to-html-tags
Improve HTML sanitization for menu messages
2025-11-28 01:35:35 +03:00
Egor 91f557e357 Improve HTML sanitization for menu messages 2025-11-28 01:31:53 +03:00
Egor 8e6c594a7b Merge pull request #2071 from BEDOLAGA-DEV/revert-2069-k72cch-bedolaga/fix-bot-crash-due-to-html-tags
Revert "Validate HTML in user messages"
2025-11-28 01:31:47 +03:00
Egor 94cc04703b Revert "Validate HTML in user messages" 2025-11-28 01:31:39 +03:00
Egor 9ac621cebd Merge pull request #2069 from BEDOLAGA-DEV/k72cch-bedolaga/fix-bot-crash-due-to-html-tags
Validate HTML in user messages
2025-11-28 01:06:50 +03:00
Egor 5d9f6064d6 Validate user message HTML 2025-11-28 01:00:15 +03:00
Egor 010be14379 Merge pull request #2068 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 00:18:54 +03:00
Egor d864c80457 Merge pull request #2067 from BEDOLAGA-DEV/80cp2x-bedolaga/fix-cryptobot-notification-error
Handle missing greenlet when building topup keyboard
2025-11-28 00:11:37 +03:00
Egor ac04284d85 Handle lazy subscription access in checkout resume 2025-11-28 00:11:15 +03:00
Egor e1377ad7a0 Merge pull request #2066 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 00:03:34 +03:00
Egor 6c7bbba7ed Merge pull request #2065 from BEDOLAGA-DEV/78k0ik-bedolaga/filter-out-pricing-logs-from-bot
Reduce noisy price calculation logging
2025-11-27 23:57:00 +03:00
Egor 7a70a90d63 Reduce noisy price calculation logging 2025-11-27 23:55:54 +03:00
Egor 58187cecdb Merge pull request #2064 from BEDOLAGA-DEV/dev5
Dev5
2025-11-27 23:46:46 +03:00
Egor 649a358560 Merge pull request #2063 from BEDOLAGA-DEV/a812lu-bedolaga/fix-manual-status-check-error-in-payment-service
Refresh promo groups before platega finalize
2025-11-27 23:42:38 +03:00
Egor e0f1f221df Refresh promo groups before platega finalize 2025-11-27 23:38:16 +03:00
Egor 1342236b23 Merge pull request #2062 from BEDOLAGA-DEV/rq2kdr-bedolaga/fix-attributeerror-in-cryptobotpayment
Fix CryptoBot saved cart notifications
2025-11-27 23:33:02 +03:00
Egor 61681b393e Fix CryptoBot saved cart notifications 2025-11-27 23:28:53 +03:00
Egor 20a36fe869 Update README.md 2025-11-27 21:30:29 +03:00
Egor a4fe68bf2a Merge pull request #2061 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 10:09:29 +03:00
Egor 827cdd7805 Merge pull request #2060 from BEDOLAGA-DEV/9h699c-bedolaga/fix-telegram-flood-control-issue
Add retry when persisting broadcast results after DB disconnect
2025-11-25 10:02:44 +03:00
Egor 2479167073 Merge pull request #2059 from BEDOLAGA-DEV/y8nxh1-bedolaga/fix-payment-verification-service-error
Avoid lazy loading during Platega payment finalization
2025-11-25 10:00:13 +03:00
Egor 5cc8f7869f Add retry when persisting broadcast results after DB disconnect 2025-11-25 09:59:07 +03:00
Egor 330ace039b Avoid lazy loading during Platega payment finalization 2025-11-25 09:57:11 +03:00
Egor 36ad95dfef Merge pull request #2058 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 06:41:03 +03:00
Egor a7d564b7d1 Update README.md 2025-11-25 06:40:42 +03:00
Egor 4582daa1d1 Update docker-compose.local.yml 2025-11-25 06:38:14 +03:00
Egor c440274c32 Merge pull request #2057 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 04:59:59 +03:00
Egor a02b2f163b Merge pull request #2056 from BEDOLAGA-DEV/bedolaga-5s9q2y
Serve miniapp app-config and fix maintenance schema
2025-11-25 04:32:34 +03:00
Egor f5a6dbdeec Serve miniapp app-config and fix maintenance schema 2025-11-25 04:26:54 +03:00
Egor 282bf3aa11 Merge pull request #2055 from BEDOLAGA-DEV/revert-2053-bedolaga-1xcsyw
Revert "Block miniapp during maintenance mode"
2025-11-25 04:26:22 +03:00
Egor d302e02b0a Revert "Block miniapp during maintenance mode" 2025-11-25 04:26:12 +03:00
Egor da554736ba Merge pull request #2054 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 04:10:40 +03:00
Egor aefbc21033 Merge pull request #2053 from BEDOLAGA-DEV/bedolaga-1xcsyw
Block miniapp during maintenance mode
2025-11-25 04:03:15 +03:00
Egor add0fb4fbe Block miniapp during maintenance mode 2025-11-25 03:58:10 +03:00
Egor f1bec83a6f Merge pull request #2052 from BEDOLAGA-DEV/revert-2051-bedolaga-612jbk
Revert "Improve miniapp app-config loading fallback"
2025-11-25 03:58:02 +03:00
Egor a9e15a7cc1 Revert "Improve miniapp app-config loading fallback" 2025-11-25 03:57:52 +03:00
Egor 53c0f17200 Merge pull request #2051 from BEDOLAGA-DEV/bedolaga-612jbk
Improve miniapp app-config loading fallback
2025-11-25 03:46:46 +03:00
Egor 0c5a28d1c3 Improve miniapp app-config loading fallback 2025-11-25 03:46:23 +03:00
Egor cedb6b4cc4 Merge pull request #2050 from BEDOLAGA-DEV/revert-2048-bedolaga-myk79e
Revert "Show maintenance notice in miniapp"
2025-11-25 03:45:55 +03:00
Egor 5c7ec8c4a2 Revert "Show maintenance notice in miniapp" 2025-11-25 03:45:46 +03:00
Egor 3bb2a055e7 Merge pull request #2049 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 03:29:19 +03:00
Egor b117df126a Merge pull request #2048 from BEDOLAGA-DEV/bedolaga-myk79e
Show maintenance notice in miniapp
2025-11-25 03:13:40 +03:00
Egor 610a39e322 Show maintenance notice in miniapp 2025-11-25 03:12:30 +03:00
Egor 71fca0ccf8 Merge pull request #2046 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 02:49:53 +03:00
Egor 2568cf086a Merge pull request #2045 from BEDOLAGA-DEV/0dm6a3-bedolaga/fix-broadcast-message-failure-on-large-lists
Handle large broadcast status updates and cancellations
2025-11-25 02:42:34 +03:00
Egor ae453279bf Handle late cancellation finalization 2025-11-25 02:38:17 +03:00
Egor bdb04ab91b Merge pull request #2042 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 02:16:49 +03:00
Egor 3636cd27b6 Merge pull request #2041 from BEDOLAGA-DEV/q8dwo9-bedolaga/add-user-balance-to-promocode-notifications
Ensure balance fields exposed in subscription events API
2025-11-25 02:13:47 +03:00
Egor 04279542c5 Ensure balance fields exposed in subscription events API 2025-11-25 02:10:33 +03:00
Egor c7666274b8 Merge pull request #2040 from BEDOLAGA-DEV/revert-2039-uqw8rl-bedolaga/add-user-balance-to-promocode-notifications
Revert "Add balance details to promocode notifications"
2025-11-25 02:06:33 +03:00
Egor 5b27b8834d Revert "Add balance details to promocode notifications" 2025-11-25 02:06:23 +03:00
Egor 4b506cde0c Merge pull request #2039 from BEDOLAGA-DEV/uqw8rl-bedolaga/add-user-balance-to-promocode-notifications
Add balance details to promocode notifications
2025-11-25 02:05:23 +03:00
Egor 121d254fc0 Add balance details to promocode notifications 2025-11-25 02:03:16 +03:00
Egor 515c9dc8b1 Merge pull request #2038 from BEDOLAGA-DEV/dev5
Апи для сообщений в меню и приветственного текста и доп уведомления
2025-11-25 02:00:46 +03:00
Egor 4e03fa1b71 Merge pull request #2037 from BEDOLAGA-DEV/7z2yaf-bedolaga/fix-text-field-key-mismatch-in-updater
Fix welcome text patch text field mapping
2025-11-25 01:53:20 +03:00
Egor 80752c3004 Fix welcome text update payload mapping 2025-11-25 01:53:01 +03:00
Egor a11d37ef96 Merge pull request #2036 from BEDOLAGA-DEV/1docjs-bedolaga/add-notifications-to-api
Add notification events for balances and promotions
2025-11-25 01:51:45 +03:00
Egor 14c89c7aab Expand notification events 2025-11-25 01:43:07 +03:00
Egor b09b47fa49 Merge pull request #2034 from BEDOLAGA-DEV/2westv-bedolaga/expand-bot-api-for-welcome-text
Handle missing creator on welcome text creation
2025-11-25 01:25:57 +03:00
Egor 49b8a96a84 Handle missing creator on welcome text creation 2025-11-25 01:25:41 +03:00
Egor 6f800ac60b Merge pull request #2033 from BEDOLAGA-DEV/revert-2031-vb0a8m-bedolaga/expand-bot-api-for-welcome-text
Revert "Handle missing creator on user message creation"
2025-11-25 01:24:02 +03:00
Egor 46dfb59f3d Revert "Handle missing creator on user message creation" 2025-11-25 01:23:54 +03:00
Egor d5bbb9f14a Merge pull request #2031 from BEDOLAGA-DEV/vb0a8m-bedolaga/expand-bot-api-for-welcome-text
Handle missing creator on user message creation
2025-11-25 01:19:15 +03:00
Egor 7c52811750 Merge pull request #2032 from BEDOLAGA-DEV/revert-2030-j1ummj-bedolaga/expand-bot-api-for-welcome-text
Revert "Expose admin APIs for welcome texts and menu messages"
2025-11-25 01:19:01 +03:00
Egor 2d4b46afbd Revert "Expose admin APIs for welcome texts and menu messages" 2025-11-25 01:18:45 +03:00
Egor 83093236fc Handle missing creator on user message creation 2025-11-25 01:18:03 +03:00
Egor 3881e7d1d5 Merge pull request #2030 from BEDOLAGA-DEV/j1ummj-bedolaga/expand-bot-api-for-welcome-text
Expose admin APIs for welcome texts and menu messages
2025-11-25 01:08:09 +03:00
Egor 1c3dbd57c5 Add API endpoints for welcome texts and menu messages 2025-11-25 01:06:05 +03:00
Egor 6f1275f291 Merge pull request #2029 from BEDOLAGA-DEV/dev5
Апи для партнерки
2025-11-25 00:58:14 +03:00
Egor cfc48ea061 Merge pull request #2028 from BEDOLAGA-DEV/vinuia-bedolaga/expand-bot-api-for-partner-section
Fix referrer listing filter for partners API
2025-11-25 00:54:20 +03:00
Egor 2679172ae4 Fix referrer query predicate 2025-11-25 00:54:05 +03:00
Egor 6a0c276454 Merge pull request #2027 from BEDOLAGA-DEV/revert-2026-fvbx51-bedolaga/expand-bot-api-for-partner-section
Revert "Fix partner routes referral imports"
2025-11-25 00:52:21 +03:00
Egor 5872e3bd3c Revert "Fix partner routes referral imports" 2025-11-25 00:52:09 +03:00
Egor f6041ab055 Merge pull request #2026 from BEDOLAGA-DEV/fvbx51-bedolaga/expand-bot-api-for-partner-section
Fix partner routes referral imports
2025-11-25 00:45:37 +03:00
Egor b5c5da552d Fix partner routes referral imports 2025-11-25 00:45:23 +03:00
Egor c093ef95ed Merge pull request #2025 from BEDOLAGA-DEV/revert-2024-f0fpo3-bedolaga/expand-bot-api-for-partner-section
Revert "Add API endpoint to update user referral commission"
2025-11-25 00:45:07 +03:00
Egor d376a25e64 Revert "Add API endpoint to update user referral commission" 2025-11-25 00:44:56 +03:00
Egor 4ec926d072 Merge pull request #2024 from BEDOLAGA-DEV/f0fpo3-bedolaga/expand-bot-api-for-partner-section
Add API endpoint to update user referral commission
2025-11-25 00:42:20 +03:00
Egor 65a7df4549 Allow updating referral commission per user via API 2025-11-25 00:42:05 +03:00
Egor 57c252dcbf Merge pull request #2023 from BEDOLAGA-DEV/revert-2022-76qz7l-bedolaga/expand-bot-api-for-partner-section
Revert "Add API to update referrer commission percent"
2025-11-25 00:41:28 +03:00
Egor 9b74b40794 Revert "Add API to update referrer commission percent" 2025-11-25 00:41:19 +03:00
Egor 0b028e1f13 Merge pull request #2022 from BEDOLAGA-DEV/76qz7l-bedolaga/expand-bot-api-for-partner-section
Add API to update referrer commission percent
2025-11-25 00:40:08 +03:00
Egor 337b948fda Add API to update referrer commission percent 2025-11-25 00:39:50 +03:00
Egor 5de187c6cd Merge pull request #2021 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 00:27:50 +03:00
Egor 7150204c71 Merge pull request #2020 from BEDOLAGA-DEV/0i4s7o-bedolaga/update-bot-api-for-notifications
Add user details to subscription event API responses
2025-11-25 00:24:58 +03:00
Egor 3d44403318 Add user info to subscription event API 2025-11-25 00:24:32 +03:00
Egor 812f3e983c Merge pull request #2018 from BEDOLAGA-DEV/dev5
Dev5
2025-11-24 08:27:34 +03:00
Egor 7549e571fe Merge pull request #2017 from BEDOLAGA-DEV/7pp9bw-bedolaga/add-setting-to-disable-support-balance-top-up
Add setting to disable support balance top-ups
2025-11-24 08:25:55 +03:00
Egor 9d3f096be0 Add toggle for support top-ups 2025-11-24 08:17:23 +03:00
Egor fd40cd3ba6 Merge pull request #2016 from BEDOLAGA-DEV/dev5
Dev5
2025-11-24 08:12:05 +03:00
Egor 1ba566f30f Merge pull request #2015 from BEDOLAGA-DEV/mz6u7a-bedolaga/fix-subscription-notification-api
Rollback DB session on subscription event logging errors
2025-11-24 07:59:39 +03:00
Egor 9b9d17b8e7 Rollback session after subscription event logging failure 2025-11-24 07:55:52 +03:00
Egor 3fee600db0 Merge pull request #2012 from BEDOLAGA-DEV/revert-2011-rrpx8s-bedolaga/add-referral-income-withdrawal-feature
Revert "Debit balance when closing referral withdrawals"
2025-11-24 07:30:05 +03:00
Egor fbb1091f8b Revert "Debit balance when closing referral withdrawals" 2025-11-24 07:29:57 +03:00
Egor 8513d67a78 Merge pull request #2011 from BEDOLAGA-DEV/rrpx8s-bedolaga/add-referral-income-withdrawal-feature
Debit balance when closing referral withdrawals
2025-11-24 07:22:18 +03:00
Egor 45ce615fbe Debit balance when closing referral withdrawal requests 2025-11-24 07:21:39 +03:00
Egor 2ab4ca5b09 Merge pull request #2010 from BEDOLAGA-DEV/revert-2009-so9oqv-bedolaga/add-referral-income-withdrawal-feature
Revert "Harden referral withdrawal template formatting"
2025-11-24 07:21:04 +03:00
Egor b229dc22ba Revert "Harden referral withdrawal template formatting" 2025-11-24 07:20:56 +03:00
Egor bf49a5c1d5 Merge pull request #2009 from BEDOLAGA-DEV/so9oqv-bedolaga/add-referral-income-withdrawal-feature
Harden referral withdrawal template formatting
2025-11-24 07:11:04 +03:00
Egor 27b6e974ab Initialize texts in admin referral stats handler 2025-11-24 07:06:14 +03:00
Egor 9f00b56be0 Merge pull request #2002 from BEDOLAGA-DEV/j33on7-bedolaga/add-individual-referral-percentage-in-user-edit
Add universal migration for referral commission column
2025-11-24 05:35:46 +03:00
Egor 05177bff92 Chain referral commission migration to latest head 2025-11-24 05:35:37 +03:00
Egor f40f233562 Add universal migration for referral commission column 2025-11-24 05:27:04 +03:00
Egor d3a351aeba Merge pull request #1992 from BEDOLAGA-DEV/dd57ir-bedolaga/fix-validation-error-in-system-stats-response
Handle fractional uptime values in system stats
2025-11-24 05:23:15 +03:00
Egor fb76d30457 Merge pull request #2001 from BEDOLAGA-DEV/revert-2000-main
Revert "Обратная синхронизация remnawave"
2025-11-24 01:20:25 +03:00
Egor afba2137d1 Revert "Обратная синхронизация remnawave" 2025-11-24 01:20:17 +03:00
Egor 9ef3ca1831 Merge pull request #2000 from Gy9vin/main
Обратная синхронизация remnawave
2025-11-24 01:14:20 +03:00
gy9vin 65ae2f5c48 Обратная синхронизация 2025-11-24 00:09:58 +03:00
Egor 2686dcd30a Merge pull request #1998 from BEDOLAGA-DEV/dev5
Dev5
2025-11-23 06:40:08 +03:00
Egor 6bc125a2f4 Merge pull request #1997 from BEDOLAGA-DEV/w16jb9-bedolaga/add-admin-panel-for-pricing-limits
Fix admin pricing callbacks for base discount settings
2025-11-23 06:37:00 +03:00
Egor 29c6e296df Escape promo discount format hint for HTML 2025-11-23 06:36:44 +03:00
Egor 3d1d064af7 Merge pull request #1996 from BEDOLAGA-DEV/revert-1995-efy4mi-bedolaga/add-admin-panel-for-pricing-limits
Revert "Fix admin pricing callbacks for base discount settings"
2025-11-23 06:36:02 +03:00
Egor ccf1f8a71d Revert "Fix admin pricing callbacks for base discount settings" 2025-11-23 06:35:53 +03:00
Egor b83a8c9df0 Merge pull request #1995 from BEDOLAGA-DEV/efy4mi-bedolaga/add-admin-panel-for-pricing-limits
Fix admin pricing callbacks for base discount settings
2025-11-23 06:29:56 +03:00
Egor a7bd94f394 Shorten admin pricing callback payloads 2025-11-23 06:29:33 +03:00
Egor 96787c27d3 Merge pull request #1994 from BEDOLAGA-DEV/revert-1993-arxkfd-bedolaga/add-admin-panel-for-pricing-limits
Revert "Add base promo group discount controls to admin pricing"
2025-11-23 06:29:11 +03:00
Egor e99c731e27 Revert "Add base promo group discount controls to admin pricing" 2025-11-23 06:29:03 +03:00
Egor 4a4b5bd8ef Merge pull request #1993 from BEDOLAGA-DEV/arxkfd-bedolaga/add-admin-panel-for-pricing-limits
Add base promo group discount controls to admin pricing
2025-11-23 06:18:05 +03:00
Egor 9d27f1aa6b Add base promo group discount controls to admin pricing 2025-11-23 06:17:45 +03:00
Egor fb010038f1 Ensure uptime seconds parsed as integer 2025-11-23 06:08:33 +03:00
Egor 700f86fe38 Merge pull request #1991 from BEDOLAGA-DEV/dev5
Dev5
2025-11-23 06:00:33 +03:00
Egor 582a8b9615 Merge pull request #1990 from BEDOLAGA-DEV/bedolaga
Apply base promo discounts to all order components
2025-11-23 05:55:48 +03:00
Egor 7c9dfc352c Apply base promo discounts to all order components 2025-11-23 05:51:30 +03:00
Egor b86d549695 Merge pull request #1989 from BEDOLAGA-DEV/dev5
Dev5
2025-11-23 04:46:13 +03:00
Egor 577f6dfd8e Merge pull request #1988 from BEDOLAGA-DEV/bedolaga-dtalt2
Add subscription events table to universal migration
2025-11-23 04:37:53 +03:00
Egor eab4cce251 Add subscription events universal migration 2025-11-23 04:33:44 +03:00
Egor 7a099ab255 Merge pull request #1987 from BEDOLAGA-DEV/dev5
Dev5
2025-11-23 04:19:34 +03:00
Egor 9fa67f61f3 Merge pull request #1986 from BEDOLAGA-DEV/bedolaga/add-/upload-endpoint-for-file-uploads-lowg6q
Proxy media downloads without exposing bot token
2025-11-23 04:19:03 +03:00
Egor 86ebff4948 Serve proxy media with detected content type 2025-11-23 04:09:04 +03:00
Egor 821ac4668f Merge pull request #1983 from BEDOLAGA-DEV/main
w
2025-11-23 03:23:33 +03:00
Egor d0280f0ab9 Merge pull request #1980 from belousotroll/add-policy-page
Add policy page at registration stage
2025-11-23 03:22:49 +03:00
belousotroll ceb45b4c0b add policy page at registration stage 2025-11-22 21:30:11 +07:00
Egor 85ea3d97ef Update README.md 2025-11-22 02:36:04 +03:00
Egor 5640041e45 Update README.md 2025-11-22 02:35:17 +03:00
Egor 1357ddc1fe Update README.md 2025-11-22 02:34:29 +03:00
Egor 0765dae178 Update README.md 2025-11-22 02:31:48 +03:00
Egor 81f2e20c08 Update README.md 2025-11-22 02:29:35 +03:00
Egor b27d34ec92 Update README.md 2025-11-22 02:24:59 +03:00
Egor e7fbbd579c Merge pull request #1977 from remnawave-contrib/ivan-nginx-manual-update
docs: update manual update instructions
2025-11-22 00:11:22 +03:00
Ivan.Nginx 7a2a82fa87 docs: update manual update instructions 2025-11-21 10:25:02 +03:00
Egor c3c105619b Update Dockerfile 2025-11-21 07:02:11 +03:00
Egor 01e575676f Update docker-registry.yml 2025-11-21 07:01:57 +03:00
Egor b53a43fcc6 Update docker-hub.yml 2025-11-21 07:01:41 +03:00
Egor 6a8ff1c1b4 Merge pull request #1976 from BEDOLAGA-DEV/dev5
Dev5
2025-11-21 06:36:35 +03:00
Egor 451ab514cc Merge pull request #1975 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-d1pv7w
Clean up CryptoBot prompt messages
2025-11-21 06:33:34 +03:00
Egor c8b6830704 Clean up CryptoBot amount prompts 2025-11-21 06:32:34 +03:00
Egor 74ee7149c5 Merge pull request #1974 from BEDOLAGA-DEV/revert-1972-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-675m3k
Revert "Fix Tribute payment prompt formatting"
2025-11-21 06:32:23 +03:00
Egor 506b96dec4 Revert "Fix Tribute payment prompt formatting" 2025-11-21 06:32:13 +03:00
Egor 927d4230a2 Merge pull request #1973 from BEDOLAGA-DEV/dev5
Dev5
2025-11-21 06:08:36 +03:00
Egor 69ff737b72 Merge pull request #1972 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-675m3k
Fix Tribute payment prompt formatting
2025-11-21 06:05:50 +03:00
Egor d0114a1f29 Fix Tribute payment prompt editing 2025-11-21 06:03:35 +03:00
Egor f92a0fd88b Merge pull request #1971 from BEDOLAGA-DEV/revert-1969-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-xitbxf
Revert "Reject YooKassa webhooks lacking payment id"
2025-11-21 06:03:14 +03:00
Egor e7478cc1e1 Revert "Reject YooKassa webhooks lacking payment id" 2025-11-21 06:03:04 +03:00
Egor 5814f9757d Merge pull request #1970 from BEDOLAGA-DEV/dev5
Dev5
2025-11-21 05:40:12 +03:00
Egor cb7b9a9e22 Merge pull request #1969 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-xitbxf
Reject YooKassa webhooks lacking payment id
2025-11-21 05:34:23 +03:00
Egor 6dc525dd72 Handle missing YooKassa payment ids gracefully 2025-11-21 05:26:42 +03:00
Egor 409c8c161b Merge pull request #1968 from BEDOLAGA-DEV/revert-1967-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-skti1r
Revert "Handle YooKassa webhook payloads without payment ids"
2025-11-21 05:26:31 +03:00
Egor 78db3a1e77 Revert "Handle YooKassa webhook payloads without payment ids" 2025-11-21 05:26:21 +03:00
Egor d0c5ff19e1 Merge pull request #1967 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-skti1r
Handle YooKassa webhook payloads without payment ids
2025-11-21 05:17:43 +03:00
Egor 6a29765ddf Handle YooKassa webhooks without payment ids 2025-11-21 05:17:09 +03:00
Egor 95147fbe03 Merge pull request #1964 from BEDOLAGA-DEV/revert-1963-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-fbz95w
Revert "Clean up Platega and YooKassa invoices after payment"
2025-11-21 04:49:30 +03:00
Egor 0137321d24 Revert "Clean up Platega and YooKassa invoices after payment" 2025-11-21 04:49:17 +03:00
Egor 0521306699 Merge pull request #1963 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-fbz95w
Clean up Platega and YooKassa invoices after payment
2025-11-21 04:18:23 +03:00
Egor 2a10e7d2c7 Clean up Platega and YooKassa invoices after payment 2025-11-21 04:18:03 +03:00
Egor e78e9e1a4d Merge pull request #1962 from BEDOLAGA-DEV/revert-1960-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-v2ldtf
Revert "Clean up Platega and YooKassa prompts"
2025-11-21 04:17:22 +03:00
Egor 29d0b54b04 Revert "Clean up Platega and YooKassa prompts" 2025-11-21 04:17:13 +03:00
Egor 64fe3fdbb7 Merge pull request #1960 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-v2ldtf
Clean up Platega and YooKassa prompts
2025-11-21 04:02:54 +03:00
Egor 04edca175e Merge pull request #1961 from BEDOLAGA-DEV/revert-1959-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-s863sv
Revert "Clean up Telegram Stars payment flow"
2025-11-21 04:02:38 +03:00
Egor 840091cb05 Revert "Clean up Telegram Stars payment flow" 2025-11-21 04:02:28 +03:00
Egor 473c3704cf Clean up Platega and YooKassa prompts 2025-11-21 04:02:22 +03:00
Egor af630b22c9 Merge pull request #1959 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-s863sv
Clean up Telegram Stars payment flow
2025-11-21 03:27:06 +03:00
Egor 29fe2f2016 Clean up Telegram Stars payment flow 2025-11-21 03:26:34 +03:00
Egor cd5691640e Merge pull request #1958 from BEDOLAGA-DEV/revert-1957-bedolaga/remove-unnecessary-chat-messages-after-balance-refill
Revert "Clean up Telegram Stars top-up messages"
2025-11-21 03:26:03 +03:00
Egor 77c217f9ad Revert "Clean up Telegram Stars top-up messages" 2025-11-21 03:25:55 +03:00
Egor 9e963569bc Merge pull request #1957 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill
Clean up Telegram Stars top-up messages
2025-11-21 03:24:32 +03:00
Egor c7114ec359 Clean up Telegram Stars top-up messages 2025-11-21 03:24:17 +03:00
Egor b9586ad72a Merge pull request #1956 from BEDOLAGA-DEV/revert-1955-bedolaga/remove-unnecessary-messages-after-balance-update-vvw9nt
Revert "Clean up Telegram Stars top-up messages"
2025-11-21 03:18:19 +03:00
Egor b6e333127c Revert "Clean up Telegram Stars top-up messages" 2025-11-21 03:18:10 +03:00
Egor 6ea2c2cceb Merge pull request #1955 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-messages-after-balance-update-vvw9nt
Clean up Telegram Stars top-up messages
2025-11-21 03:16:40 +03:00
Egor af42377c3b Clean up Telegram Stars top-up messages 2025-11-21 03:16:24 +03:00
Egor 82881a4c45 Merge pull request #1954 from BEDOLAGA-DEV/revert-1953-bedolaga/remove-unnecessary-messages-after-balance-update
Revert "Clean up Telegram Stars payment messages"
2025-11-21 03:16:12 +03:00
Egor 70d50e9c47 Revert "Clean up Telegram Stars payment messages" 2025-11-21 03:16:04 +03:00
Egor ddb5c01026 Merge pull request #1953 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-messages-after-balance-update
Clean up Telegram Stars payment messages
2025-11-21 03:14:41 +03:00
Egor e287adcb84 Clean up Telegram Stars payment messages 2025-11-21 03:14:25 +03:00
Egor 83d041c13b Merge pull request #1952 from BEDOLAGA-DEV/bedolaga/fix-auto-renewal-calculation-error
Fix autopay renewal pricing for promo groups
2025-11-20 23:13:12 +03:00
Egor 545c5fd749 Eager load promo groups for autopay renewals 2025-11-20 23:12:49 +03:00
Egor 7f0e2de7c4 Merge pull request #1951 from BEDOLAGA-DEV/bedolaga/fix-subscription-check-service-errors
Handle missing channel link in subscription check
2025-11-20 23:06:53 +03:00
Egor 1fdf1e49a3 Handle missing channel link in subscription check 2025-11-20 23:06:34 +03:00
Egor 08f82ee511 Merge pull request #1950 from BEDOLAGA-DEV/bedolaga-q76r4i
Fix trial reset cleanup
2025-11-20 22:58:04 +03:00
Egor fba217b87f Fix trial reset by clearing server links 2025-11-20 22:49:57 +03:00
Egor 6668a18ad3 Merge pull request #1949 from BEDOLAGA-DEV/revert-1948-bedolaga-uhav33
Revert "Add admin trial reset controls"
2025-11-20 22:46:40 +03:00
Egor 11c239f46c Revert "Add admin trial reset controls" 2025-11-20 22:46:30 +03:00
Egor ac62dee279 Merge pull request #1948 from BEDOLAGA-DEV/bedolaga-uhav33
Add admin trial reset controls
2025-11-20 22:34:27 +03:00
Egor 29a17a7876 Add admin trial reset controls 2025-11-20 22:33:43 +03:00
Egor 694c934612 Merge pull request #1947 from BEDOLAGA-DEV/dev5
Dev5
2025-11-20 22:11:23 +03:00
Egor c01d2bfcd7 Update README.md 2025-11-20 22:09:07 +03:00
Egor b3708f99c0 Merge pull request #1943 from BEDOLAGA-DEV/bedolaga/fix-subscription-check-error-on-double-click
Handle repeat device selection without redundant updates
2025-11-20 18:39:46 +03:00
Egor f651f921aa Handle repeat device selection without redundant updates 2025-11-20 18:39:25 +03:00
Egor ada6845036 Merge pull request #1938 from BEDOLAGA-DEV/dev5
Dev5
2025-11-20 17:58:46 +03:00
Egor d33b259537 Merge pull request #1936 from BEDOLAGA-DEV/bedolaga/fix-email-dispatch-limit-to-100000-users-yvuqli
Throttle broadcast sending for large campaigns
2025-11-20 17:31:23 +03:00
Egor 3c2ed147ca Merge pull request #1937 from BEDOLAGA-DEV/revert-1934-bedolaga/fix-email-dispatch-limit-to-100000-users
Revert "Remove broadcast recipient limit"
2025-11-20 17:31:07 +03:00
Egor d46e88c2b5 Revert "Remove broadcast recipient limit" 2025-11-20 17:30:56 +03:00
Egor 1477491e6e Throttle broadcast speed for large campaigns 2025-11-20 17:30:30 +03:00
Egor 647dc0bd7c Merge pull request #1935 from BEDOLAGA-DEV/dev5
Dev5
2025-11-20 15:47:45 +03:00
Egor f6a45993e9 Merge pull request #1934 from BEDOLAGA-DEV/bedolaga/fix-email-dispatch-limit-to-100000-users
Remove broadcast recipient limit
2025-11-20 15:47:24 +03:00
Egor 0766320411 Fetch all broadcast users in batches 2025-11-20 15:36:38 +03:00
Egor 5ebf113fd6 Merge pull request #1915 from BEDOLAGA-DEV/dev4
Dev4
2025-11-20 01:30:14 +03:00
Egor 2005d103f8 Merge pull request #1914 from BEDOLAGA-DEV/bedolaga/fix-balance-top-up-through-method-10
Handle Platega description byte limit
2025-11-20 01:20:05 +03:00
Egor 3935813336 Trim Platega descriptions by byte length 2025-11-20 01:16:25 +03:00
Egor ec71fa78c0 Merge pull request #1913 from BEDOLAGA-DEV/bedolaga/fix-subscription-channel-error
Handle duplicate channel subscription prompts
2025-11-20 01:12:44 +03:00
Egor cd2b0a2d67 Handle duplicate channel subscription messages 2025-11-20 01:10:28 +03:00
Egor 68d502804f Merge pull request #1911 from BEDOLAGA-DEV/dev4
Dev4
2025-11-18 01:17:55 +03:00
Egor afbd343442 Merge pull request #1910 from BEDOLAGA-DEV/bedolaga/fix-subscription-creation-api-error-uklgqk
Preserve autopay and device limits during subscription replacement
2025-11-18 01:17:37 +03:00
Egor 991e5a3112 Preserve zero device limit when replacing trials 2025-11-18 01:14:43 +03:00
Egor 7d9de6ca5d Merge pull request #1906 from BEDOLAGA-DEV/dev4
Dev4
2025-11-18 00:28:35 +03:00
Egor 562bb69082 Merge pull request #1905 from BEDOLAGA-DEV/bedolaga/expand-bot-api-functionality
Expand admin API for polls, logs, and tickets
2025-11-18 00:27:49 +03:00
Egor a02416c78b Add poll sending and ticket API enhancements 2025-11-18 00:22:46 +03:00
Egor 81f56008d4 Delete install_bot.sh 2025-11-16 12:04:21 +03:00
Egor 43a7039479 Merge pull request #1904 from Fr1ngg/dev4
Dev4
2025-11-15 01:46:45 +03:00
Egor a9f3904524 Merge pull request #1903 from Fr1ngg/bedolaga/fix-ru-cards-payment-processing-error
Limit Platega description length
2025-11-15 00:59:45 +03:00
Egor 9136c7cfe3 Limit Platega description length 2025-11-15 00:56:11 +03:00
Egor 5595846929 Merge pull request #1902 from Fr1ngg/dev4
Dev4
2025-11-14 01:32:49 +03:00
Egor 9ebe2971bf Merge pull request #1901 from Fr1ngg/bedolaga/fix-402-error-in-subscription-renewal-endpoint
Fix renewal mixed payment logic
2025-11-14 01:31:06 +03:00
Egor fadea75314 Fix renewal mixed payment logic 2025-11-14 01:30:31 +03:00
Egor a2009cae39 Merge pull request #1899 from Fr1ngg/dev4
Allow trial users to renew from miniapp
2025-11-14 01:17:04 +03:00
Egor 0187adb6f9 Merge pull request #1898 from Fr1ngg/bedolaga/fix-trial-subscription-error-in-miniapp
Enable trial subscriptions to renew via miniapp
2025-11-14 01:16:00 +03:00
Egor 302a028873 Allow trial users to renew from miniapp 2025-11-14 00:15:43 +03:00
Egor 77c9c19806 Merge pull request #1897 from Fr1ngg/dev4
Dev4
2025-11-13 23:37:04 +03:00
Egor 724da2545e Merge pull request #1896 from Fr1ngg/main
w
2025-11-13 23:36:46 +03:00
Egor 079af66d9f Merge pull request #1895 from Fr1ngg/bedolaga/fix-subscription-purchase-failure-with-platega
Fix Platega quick amount selection flow
2025-11-13 23:36:23 +03:00
Egor e701d234f0 Fix Platega quick amount selection flow 2025-11-13 23:33:07 +03:00
Egor 1b111975bb Update config.py 2025-11-12 16:41:37 +03:00
Egor c874425e0e Merge pull request #1890 from DOFER998/c0mrade/refactor-subtract-balance-logging
refactor: improve logging level for balance subtraction debug info
2025-11-12 16:28:09 +03:00
c0mrade c6c112fd17 refactor: improve logging level for balance subtraction debug info 2025-11-12 10:21:28 +03:00
Egor 57d924e3ba Merge pull request #1889 from Fr1ngg/dev4
Доп языки + фиксы
2025-11-12 05:38:34 +03:00
Egor 9d5b9ebf82 Add files via upload 2025-11-12 05:28:39 +03:00
Egor 354358e860 Create fail.html 2025-11-12 05:28:25 +03:00
Egor 327c9d52d2 Create succes.html 2025-11-12 05:27:59 +03:00
Egor c03051614d Merge pull request #1888 from Fr1ngg/revert-1887-bedolaga/activate-trial-automatically-after-payment-le7ksb
Revert "Enable automatic trial activation after balance top-up"
2025-11-12 05:24:24 +03:00
Egor ac9e5cd908 Revert "Enable automatic trial activation after balance top-up" 2025-11-12 05:24:14 +03:00
Egor 585bb56a99 Merge pull request #1887 from Fr1ngg/bedolaga/activate-trial-automatically-after-payment-le7ksb
Enable automatic trial activation after balance top-up
2025-11-12 04:45:20 +03:00
Egor 8f33eb0cc6 Enable automatic trial activation after balance top-up 2025-11-12 04:45:03 +03:00
Egor 71515a87de Merge pull request #1886 from Fr1ngg/revert-1885-bedolaga/activate-trial-automatically-after-payment
Revert "feat: auto activate paid trials after balance top-up"
2025-11-12 04:44:54 +03:00
Egor 0a4ede8f79 Revert "feat: auto activate paid trials after balance top-up" 2025-11-12 04:44:46 +03:00
Egor 040b33b541 Merge pull request #1885 from Fr1ngg/bedolaga/activate-trial-automatically-after-payment
feat: auto activate paid trials after balance top-up
2025-11-12 04:34:06 +03:00
Egor 5c3484ee28 feat: auto activate paid trials after balance top-up 2025-11-12 04:27:33 +03:00
Egor ef309c3101 Merge pull request #1884 from Fr1ngg/bedolaga/fix-trial-subscription-display-issue
Fix trial subscription status display in menu
2025-11-12 03:09:46 +03:00
Egor 51dad7936b Fix trial subscription status display in menu 2025-11-12 03:03:34 +03:00
Egor 097734b31d Merge pull request #1883 from Fr1ngg/bedolaga/fix-user-deletion-foreign-key-violation
Handle Platega payments when deleting users
2025-11-12 02:47:54 +03:00
Egor 7185fde9bc Handle Platega payments when deleting users 2025-11-12 02:47:37 +03:00
Egor 75f2cdb04b Update remnawave.py 2025-11-12 02:41:44 +03:00
Egor d3c445ed2d Merge pull request #1882 from Fr1ngg/bedolaga/fix-webhook-issue-with-heleket
Fix Heleket webhook signature verification
2025-11-12 02:40:34 +03:00
Egor 804f088435 Fix Heleket webhook signature verification 2025-11-11 16:41:58 +03:00
Egor 6c1b70e796 Merge pull request #1881 from Fr1ngg/bedolaga/fix-403-error-on-subscription-renewal-endpoint-n1nsij
Handle CryptoBot renewal payload fallbacks
2025-11-11 13:13:14 +03:00
Egor a3532e5878 Handle CryptoBot renewal payload fallbacks 2025-11-11 13:06:10 +03:00
Egor a8c1b32912 Merge pull request #1880 from Fr1ngg/revert-1877-bedolaga/fix-403-error-on-subscription-renewal-endpoint
Revert "Allow expired miniapp subscriptions to load renewal options"
2025-11-11 12:22:29 +03:00
Egor 20b0c8a0e2 Revert "Allow expired miniapp subscriptions to load renewal options" 2025-11-11 12:22:15 +03:00
Egor bc18692d75 Merge pull request #1877 from Fr1ngg/bedolaga/fix-403-error-on-subscription-renewal-endpoint
Allow expired miniapp subscriptions to load renewal options
2025-11-11 10:46:42 +03:00
Egor c05ecf1ecd Allow expired subscriptions to access renewal options 2025-11-11 10:42:33 +03:00
Egor 8381fc52f1 Merge pull request #1876 from Fr1ngg/bedolaga/add-default-localization-support-for-flags-cen8qi
Fix dynamic traffic price recalculation in localization
2025-11-11 10:33:33 +03:00
Egor a697ffa8f1 Fix dynamic traffic localization prices 2025-11-11 10:28:09 +03:00
Egor 4eb36fab6d Merge pull request #1875 from Fr1ngg/revert-1873-bedolaga/add-default-localization-support-for-flags-ocds8i
Revert "Add dynamic localization support for UA and ZH languages"
2025-11-11 10:27:19 +03:00
Egor 4b0780b549 Revert "Add dynamic localization support for UA and ZH languages" 2025-11-11 10:27:10 +03:00
Egor 1befc2982a Merge pull request #1873 from Fr1ngg/bedolaga/add-default-localization-support-for-flags-ocds8i
Add dynamic localization support for UA and ZH languages
2025-11-11 09:10:15 +03:00
Egor df1f225405 Populate dynamic prices and support text for new locales 2025-11-11 09:07:02 +03:00
Egor df6feca53c Add files via upload 2025-11-11 08:37:03 +03:00
Egor bd41efb974 Merge pull request #1869 from Fr1ngg/main
w
2025-11-11 08:34:18 +03:00
Egor a1216bface Update README.md 2025-11-11 08:32:07 +03:00
Egor e0969b315a Merge pull request #1868 from remnawave-contrib/ivan-nginx-readme-make
Added «make» installation command to Readme
2025-11-11 08:30:06 +03:00
Ivan.Nginx 395bccf4e5 Merge branch 'Fr1ngg:main' into main 2025-11-11 08:23:02 +03:00
Ivan.Nginx f6e5cfce54 Merge pull request #2 from remnawave-contrib/ivan-nginx-readme-add-make-installation
Update installation instructions for 'make' command
2025-11-11 08:22:16 +03:00
Ivan.Nginx a26e89147b Update installation instructions for 'make' command 2025-11-11 08:21:34 +03:00
Egor 4396020177 Merge pull request #1867 from Fr1ngg/dev4
[FEAT] Add integration metadata for miniapp payment methods
2025-11-11 08:16:20 +03:00
Egor e2c89ec19e Merge pull request #1866 from Fr1ngg/bedolaga/extend-api-for-payment-method-integration-types
[FEAT] Add integration metadata for miniapp payment methods
2025-11-11 08:15:06 +03:00
Egor 68c125ba14 feat: expose integration metadata for miniapp payments 2025-11-11 08:11:42 +03:00
Egor 8c794fc91c Update README.md 2025-11-11 07:42:15 +03:00
Egor efbe8475df Update README.md 2025-11-11 07:41:43 +03:00
Egor 7835e02d5c Merge pull request #1860 from Gy9vin/main
Апи поиск в боте по тгID
2025-11-11 02:08:32 +03:00
Mikhail 21a5499af8 Merge branch 'Fr1ngg:main' into main 2025-11-10 13:54:03 +03:00
gy9vin dd85db071a Апи поиск в боте по тгID 2025-11-10 13:53:21 +03:00
Egor 813193102c Update Dockerfile 2025-11-10 06:28:50 +03:00
Egor 42112d6c8a Update docker-registry.yml 2025-11-10 06:28:40 +03:00
Egor dfe5e36fe0 Update docker-hub.yml 2025-11-10 06:28:31 +03:00
Egor b5d4a54e97 Update README.md 2025-11-10 06:22:26 +03:00
Egor 1eaf7204ce Merge pull request #1859 from Fr1ngg/dev4
fix #1849 Round CryptoBot top-up amounts up to whole rubles
2025-11-10 06:13:51 +03:00
Egor 8cc37dc439 Merge pull request #1858 from Fr1ngg/bedolaga/fix-webhook-handler-error-in-cryptobot-i1csym
Round CryptoBot top-up amounts up to whole rubles
2025-11-10 06:11:42 +03:00
Egor ccafb18122 Round CryptoBot top-up amounts up to whole rubles 2025-11-10 06:11:18 +03:00
Egor 654b132ae6 Merge pull request #1857 from Fr1ngg/revert-1855-bedolaga/fix-webhook-handler-error-in-cryptobot
Revert "Refactor CryptoBot notifications to avoid transaction rollback"
2025-11-10 06:11:05 +03:00
Egor 815fa0560c Revert "Refactor CryptoBot notifications to avoid transaction rollback" 2025-11-10 06:10:54 +03:00
Egor c4d19810a4 Merge pull request #1856 from Fr1ngg/dev4
fix
2025-11-10 06:05:55 +03:00
Egor 9fbb32eb5b Merge pull request #1855 from Fr1ngg/bedolaga/fix-webhook-handler-error-in-cryptobot
Refactor CryptoBot notifications to avoid transaction rollback
2025-11-10 05:59:09 +03:00
Egor 3ce3cde859 Refactor CryptoBot notifications to avoid transaction rollback 2025-11-10 05:53:26 +03:00
Egor 4e71dcd2b1 Merge pull request #1853 from Fr1ngg/dev4
Update Pal24 callback handling and SBP link fallback
2025-11-10 05:33:11 +03:00
Egor acf21fa6d2 Merge pull request #1852 from Fr1ngg/bedolaga/update-requirements.txt-and-fix-pal24-webhook
Update Pal24 callback handling and SBP link fallback
2025-11-10 05:29:04 +03:00
Egor f2b724a78d Adjust Pal24 callback handling and SBP link fallback 2025-11-10 05:26:02 +03:00
Egor 03473360c1 Merge pull request #1851 from Fr1ngg/dev4
Dev4
2025-11-10 04:30:16 +03:00
Egor 27dc68be41 Merge pull request #1850 from Fr1ngg/bedolaga/fix-webhook-bot-error-handling
Prevent maintenance monitoring from crashing without RemnaWave URL
2025-11-10 04:26:35 +03:00
Egor b15d78f8af Handle missing RemnaWave config in maintenance monitoring 2025-11-10 04:25:52 +03:00
Egor d232d14937 Merge pull request #1848 from Fr1ngg/dev4
fix migration on new bases
2025-11-10 04:13:19 +03:00
Egor 927d469db2 Merge pull request #1847 from Fr1ngg/bedolaga/fix-database-migration-crash-on-first-bot-launch
Handle missing tables when creating indexes during init_db
2025-11-10 04:06:46 +03:00
Egor e20e9e7cec Fix init_db index creation on fresh databases 2025-11-10 04:06:28 +03:00
Egor 0f097c3108 Merge pull request #1830 from Gy9vin/main
Фикс простой покупки!
2025-11-10 03:37:31 +03:00
gy9vin eaa3c80d59 Фикс простой покупки! 2025-11-09 21:55:58 +03:00
Egor 0c49eddfde Merge pull request #1828 from Gy9vin/main
Фикс проверки зачисления платежа юкассы(защита от дублирования)
2025-11-09 20:09:15 +03:00
gy9vin 33882f1e93 Фикс проверки зачисления платежа юкассы(защита от дублирования) 2025-11-09 18:31:39 +03:00
Egor f94666b68d Merge pull request #1826 from Fr1ngg/dev4
Dev4
2025-11-09 10:29:27 +03:00
Egor d32a49f801 Merge pull request #1825 from Fr1ngg/bedolaga/fix-bulk_update-to-include-where-clause
Fix bulk_update to target records by primary key
2025-11-09 10:29:11 +03:00
Egor 9596f82ea6 Fix bulk_update to target records by primary key 2025-11-09 10:28:47 +03:00
Egor 0e3fdf7d61 Merge pull request #1824 from Fr1ngg/dev4
Razgonchik
2025-11-09 10:24:57 +03:00
Egor 54cfec6029 Merge pull request #1823 from Fr1ngg/bedolaga/fix-repeated-yookassa-deposits-y7sxo6
Ensure YooKassa retries finish pending credits
2025-11-09 10:13:20 +03:00
Egor 9e88eae416 Ensure YooKassa retries finish pending credits 2025-11-09 10:13:01 +03:00
Egor 8d32efac73 Merge pull request #1822 from Fr1ngg/revert-1821-bedolaga/fix-repeated-yookassa-deposits
Revert "Prevent double processing of YooKassa payments"
2025-11-09 10:04:02 +03:00
Egor 9f07b6007e Revert "Prevent double processing of YooKassa payments" 2025-11-09 10:03:54 +03:00
Egor 2fb3b6c5dc Merge pull request #1821 from Fr1ngg/bedolaga/fix-repeated-yookassa-deposits
Prevent double processing of YooKassa payments
2025-11-09 10:03:38 +03:00
Egor 0637576425 Prevent double processing of YooKassa payments 2025-11-09 10:00:17 +03:00
Egor ee7a445610 Update database.py 2025-11-09 09:46:40 +03:00
Egor ba4c7ee7ac Update database.py 2025-11-09 09:43:26 +03:00
Egor 4da169eb0f Merge pull request #1819 from Fr1ngg/bedolaga/fix-health-endpoints-for-sqlite-nullpool
Handle missing pool metrics for NullPool
2025-11-09 09:40:53 +03:00
Egor 28937abab4 Handle missing pool metrics for NullPool 2025-11-09 09:40:08 +03:00
Egor b1dcf2779e Update database.py 2025-11-09 09:25:59 +03:00
Egor 3219062ce2 Update database.py 2025-11-09 09:22:36 +03:00
Egor 2ac72951f8 Merge pull request #1818 from Fr1ngg/bedolaga/add-health-and-metrics-endpoints-to-fastapi
Add database health and pool metrics endpoints
2025-11-09 09:17:35 +03:00
Egor cefb8cba78 Add database health and pool metrics endpoints 2025-11-09 09:17:18 +03:00
Egor e869655028 Update database.py 2025-11-09 09:11:47 +03:00
Egor ae03ede209 Merge pull request #1817 from Fr1ngg/dev4
Dev
2025-11-09 08:34:49 +03:00
Egor a72bb25ea2 Update database.py 2025-11-09 08:30:38 +03:00
Egor 0b22bc7056 Merge pull request #1815 from Fr1ngg/bedolaga/-nullpool-queuepool-wh494i
Handle optional Pal24 metadata fields
2025-11-09 08:24:26 +03:00
Egor cde84ff20b Handle optional Pal24 metadata fields 2025-11-09 08:23:01 +03:00
Egor f76114d7dc Update database.py 2025-11-09 08:17:43 +03:00
Egor 9708a56fd7 Update database.py 2025-11-09 08:14:41 +03:00
Egor a360337660 Merge pull request #1813 from Fr1ngg/dev4
фикс бекапов/восстановления
2025-11-09 07:39:18 +03:00
Egor 9f78335651 Merge pull request #1812 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism-1wazcg
Avoid duplicate association errors during restore
2025-11-09 07:31:17 +03:00
Egor ef8142a99b Skip duplicate squad promo associations during restore 2025-11-09 07:30:54 +03:00
Egor 06d51f9fcf Merge pull request #1811 from Fr1ngg/revert-1810-bedolaga/improve-backup-and-restore-mechanism-gn1h90
Revert "Handle PostgreSQL backups without pg_dump"
2025-11-09 07:30:41 +03:00
Egor 4d94347179 Revert "Handle PostgreSQL backups without pg_dump" 2025-11-09 07:30:31 +03:00
Egor 2f6aa0b1e6 Merge pull request #1810 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism-gn1h90
Handle PostgreSQL backups without pg_dump
2025-11-09 07:23:11 +03:00
Egor 536a58d4d3 Handle PostgreSQL backups without pg_dump 2025-11-09 07:22:50 +03:00
Egor 62f7c07059 Merge pull request #1809 from Fr1ngg/revert-1808-bedolaga/improve-backup-and-restore-mechanism
Revert "Refactor backup pipeline to use archive dumps"
2025-11-09 07:22:10 +03:00
Egor 0648edb3c4 Revert "Refactor backup pipeline to use archive dumps" 2025-11-09 07:21:56 +03:00
Egor 9b6c235424 Merge pull request #1808 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism
Refactor backup pipeline to use archive dumps
2025-11-09 07:12:18 +03:00
Egor 2fc35950cf Refactor backup system with archive dumps 2025-11-09 07:12:01 +03:00
Egor d2a5d74239 Merge pull request #1807 from Fr1ngg/dev4
Dev4
2025-11-09 07:06:24 +03:00
Egor 6b04c6748e Merge pull request #1806 from Fr1ngg/bedolaga/add-trial-activation-funds-check
Prefill top-up amount for paid trial activation
2025-11-09 06:58:03 +03:00
Egor 10b44cbdb7 Prefill top-up amount for paid trial activation 2025-11-09 06:51:50 +03:00
Egor dde04c716c Merge pull request #1805 from Fr1ngg/bedolaga/fix-variable-loading-for-minimum-sum
Adjust Platega top-up prompt to show configured limits
2025-11-09 06:44:55 +03:00
Egor 819f19a7ea Update Platega prompt to use configured limits 2025-11-09 06:44:23 +03:00
Egor e7201597ab Merge pull request #1804 from Fr1ngg/bedolaga/fix-pall24-payment-method-issue
Fix Pal24 SBP link handling after status checks
2025-11-09 06:35:54 +03:00
Egor ea61aa7053 Fix Pal24 SBP link handling after status checks 2025-11-09 06:31:02 +03:00
Egor 14546a8485 Merge pull request #1803 from Fr1ngg/bedolaga/add-ip-validation-for-yookassa-webhooks
Handle Cloudflare proxied YooKassa webhooks
2025-11-09 06:22:17 +03:00
Egor f55455761f Trust Cloudflare headers for YooKassa webhooks 2025-11-09 06:21:40 +03:00
Egor b1a7767827 Merge pull request #1802 from Fr1ngg/revert-1801-bedolaga/add-server-category-feature-to-admin-panel-ltnwjl
Revert "Fix server category button routing in admin panel"
2025-11-09 05:55:16 +03:00
Egor 43c04b7e64 Revert "Fix server category button routing in admin panel" 2025-11-09 05:55:07 +03:00
Egor add679cf6f Merge pull request #1801 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel-ltnwjl
Fix server category button routing in admin panel
2025-11-09 05:49:00 +03:00
Egor ee173190a0 Fix category edit menu callback 2025-11-09 05:48:45 +03:00
Egor a6e21b47f6 Merge pull request #1800 from Fr1ngg/revert-1799-bedolaga/add-server-category-feature-to-admin-panel-fwofd2
Revert "Fix category pricing to ignore full squads"
2025-11-09 05:40:56 +03:00
Egor cd627e5840 Revert "Fix category pricing to ignore full squads" 2025-11-09 05:40:47 +03:00
Egor fc096e6867 Merge pull request #1799 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel-fwofd2
Fix category pricing to ignore full squads
2025-11-09 05:33:29 +03:00
Egor b591844c4e Fix category pricing to ignore full squads 2025-11-09 05:33:13 +03:00
Egor 9557acb546 Merge pull request #1798 from Fr1ngg/revert-1797-bedolaga/add-server-category-feature-to-admin-panel
Revert "Ensure server category selection skips full squads"
2025-11-09 05:31:20 +03:00
Egor 1fa6e75bd3 Revert "Ensure server category selection skips full squads" 2025-11-09 05:31:09 +03:00
Egor 8965e43a55 Merge pull request #1797 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel
Ensure server category selection skips full squads
2025-11-09 05:24:50 +03:00
Egor 8e7a6beece Ensure category auto-selection respects capacity 2025-11-09 05:23:58 +03:00
Egor e9f4fd8007 Merge pull request #1796 from Fr1ngg/revert-1793-bedolaga/implement-server-grouping-system-for-vpn-bot
Revert "Add server group backend selection logic"
2025-11-09 04:54:55 +03:00
Egor a991c59d58 Revert "Add server group backend selection logic" 2025-11-09 04:54:47 +03:00
Egor dd0624adf8 Merge pull request #1795 from Fr1ngg/revert-1794-bedolaga/check-functionality-for-server-grouping
Revert "Fix autopurchase server selection and RemnaWave stats handling"
2025-11-09 04:54:31 +03:00
Egor ab51cbe96a Revert "Fix autopurchase server selection and RemnaWave stats handling" 2025-11-09 04:54:24 +03:00
Egor 2f7a08deb0 Merge pull request #1794 from Fr1ngg/bedolaga/check-functionality-for-server-grouping
Fix autopurchase server selection and RemnaWave stats handling
2025-11-09 04:50:50 +03:00
Egor a0fa7f986b Fix autopurchase server selection and RemnaWave stats handling 2025-11-09 04:50:32 +03:00
Egor b2d3eebe39 Merge pull request #1793 from Fr1ngg/bedolaga/implement-server-grouping-system-for-vpn-bot
Add server group backend selection logic
2025-11-09 04:39:54 +03:00
Egor a043fc0e46 feat: add backend support for server groups 2025-11-09 04:36:33 +03:00
Egor d6eec8787e Merge pull request #1792 from Fr1ngg/bedolaga/fix-payment-methods-display-for-platega.io
Add Platega payment option to balance top-up list
2025-11-09 03:31:21 +03:00
Egor 95eee42047 Добавить Platega в список пополнения 2025-11-09 03:30:49 +03:00
Egor c673d6db4f Merge pull request #1790 from Fr1ngg/dev4
Dev4
2025-11-08 18:58:33 +03:00
Egor bb32a63d48 Merge pull request #1789 from Fr1ngg/bedolaga/fix-null-value-constraint-in-promo_groups
Fix default promo group creation with priority column
2025-11-08 18:58:13 +03:00
Egor af5acb272c Fix default promo group creation with priority column 2025-11-08 18:57:53 +03:00
Egor 5cd5ac8710 Merge pull request #1788 from Fr1ngg/revert-1787-dev4
Revert "Dev4"
2025-11-08 12:05:27 +03:00
Egor 8bb58b44b3 Revert "Dev4" 2025-11-08 12:05:12 +03:00
Egor 511ecd506f Merge pull request #1787 from Fr1ngg/dev4
Dev4
2025-11-08 11:38:41 +03:00
Egor 62fc014bc3 Restore json serialization helper for YooKassa webhook tests 2025-11-08 11:38:18 +03:00
Egor 961f564274 Merge pull request #1786 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for-lcydgf
Remove YooKassa IP filtering from webhooks
2025-11-08 11:27:53 +03:00
Egor 984870c78c Remove YooKassa IP filtering from webhooks 2025-11-08 11:27:37 +03:00
Egor 6196c135d2 Merge pull request #1785 from Fr1ngg/revert-1782-bedolaga/fix-webhook-ip-verification-for-w66vvd
Revert "Verify YooKassa webhooks against API"
2025-11-08 11:27:23 +03:00
Egor bf1b8315a8 Revert "Verify YooKassa webhooks against API" 2025-11-08 11:27:13 +03:00
Egor b7eabcf7e7 Merge pull request #1784 from Fr1ngg/revert-1783-dev4
Revert "Dev4"
2025-11-08 11:08:55 +03:00
Egor e83ebc4f02 Revert "Dev4" 2025-11-08 11:08:47 +03:00
Egor 1a4758f4cc Merge pull request #1783 from Fr1ngg/dev4
Dev4
2025-11-08 11:04:08 +03:00
Egor b80e8b3804 Merge pull request #1782 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for-w66vvd
Verify YooKassa webhooks against API
2025-11-08 11:00:49 +03:00
Egor e036173670 Verify YooKassa webhooks against API 2025-11-08 11:00:28 +03:00
Egor bd728c6466 Merge pull request #1781 from Fr1ngg/revert-1777-bedolaga/fix-webhook-ip-verification-for
Revert "Handle Cloudflare forwarded IPs for YooKassa webhooks"
2025-11-08 10:58:48 +03:00
Egor e3aec310a3 Revert "Handle Cloudflare forwarded IPs for YooKassa webhooks" 2025-11-08 10:58:34 +03:00
Egor 1c59ce601e Merge pull request #1780 from Fr1ngg/revert-1779-dev4
Revert "da eb tvoyu mat' kakoy pidoras delal etu ukassu "
2025-11-08 10:58:21 +03:00
Egor 0563f7524f Revert "da eb tvoyu mat' kakoy pidoras delal etu ukassu " 2025-11-08 10:58:12 +03:00
Egor cedbe07759 Merge pull request #1779 from Fr1ngg/dev4
da eb tvoyu mat' kakoy pidoras delal etu ukassu
2025-11-08 10:53:02 +03:00
Egor 0746b2a5a7 Merge pull request #1778 from Fr1ngg/main
w
2025-11-08 10:52:15 +03:00
Egor e8a72860dc Merge pull request #1777 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for
Handle Cloudflare forwarded IPs for YooKassa webhooks
2025-11-08 10:51:50 +03:00
Egor abbfe4a7d3 Handle Cloudflare forwarded IPs for YooKassa webhooks 2025-11-08 10:50:56 +03:00
Egor 1ad5d9123b Update Dockerfile 2025-11-08 09:05:30 +03:00
Egor 57031e4afe Update docker-registry.yml 2025-11-08 09:05:14 +03:00
Egor b54d2434c0 Update docker-hub.yml 2025-11-08 09:05:02 +03:00
Egor 7c2d7b6b4f Merge pull request #1776 from Fr1ngg/dev4
платный триал + фиксы вебхука ебучей юкассы(без негатива))(
2025-11-08 08:35:25 +03:00
Egor 9e731a5f18 Merge pull request #1775 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-fo5kxf
Bedolaga/add paid trial activation feature fo5kxf
2025-11-08 08:25:32 +03:00
Egor cd3832fef0 Handle trial provisioning failures by refunding charges 2025-11-08 08:14:49 +03:00
Egor cb6d51ed3d Remove trial squad override setting 2025-11-08 07:55:24 +03:00
Egor 8b75768296 Merge pull request #1773 from Fr1ngg/revert-1772-bedolaga/add-paid-trial-activation-feature-pws6p8
Revert "Rollback trial activation when charge errors occur"
2025-11-08 07:55:14 +03:00
Egor 3f0a26b1c7 Revert "Rollback trial activation when charge errors occur" 2025-11-08 07:55:04 +03:00
Egor de4f608d09 Merge pull request #1772 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-pws6p8
Rollback trial activation when charge errors occur
2025-11-08 07:42:33 +03:00
Egor 618327b9f1 Rollback trial trials when post-charge errors occur 2025-11-08 07:37:21 +03:00
Egor 54fdad8f0c Merge pull request #1771 from Fr1ngg/revert-1770-bedolaga/add-paid-trial-activation-feature-obw312
Revert "Delay trial activation charges until after subscription creation"
2025-11-08 07:27:03 +03:00
Egor e9bbd68aa1 Revert "Delay trial activation charges until after subscription creation" 2025-11-08 07:26:54 +03:00
Egor 5df4837087 Merge pull request #1770 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-obw312
Delay trial activation charges until after subscription creation
2025-11-08 07:18:14 +03:00
Egor 025e74c462 Defer trial activation charges until subscription creation succeeds 2025-11-08 07:17:57 +03:00
Egor 3208303c23 Merge pull request #1767 from Fr1ngg/bedolaga/remove-webhook-secret-references-for-yukassa-n5vl0v
Handle YooKassa cancellations in FastAPI webhook
2025-11-08 05:59:35 +03:00
Egor 4cf0c5a971 Handle YooKassa cancellations in FastAPI webhook 2025-11-08 05:44:53 +03:00
Egor 7519352e79 Merge pull request #1765 from Fr1ngg/main
w
2025-11-08 04:47:01 +03:00
Egor e7f94f9661 Merge pull request #1763 from aleksandryants/patch-1
Add wata payment processing method
2025-11-08 04:46:16 +03:00
Egor cb68a612e9 Merge pull request #1764 from Gy9vin/main
Обновка!
2025-11-08 04:44:25 +03:00
Egor 44d4deb5a6 Update .gitignore 2025-11-08 04:43:58 +03:00
gy9vin 23c73d1f21 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-11-07 23:56:51 +03:00
gy9vin 607beca837 fix 2025-11-07 23:56:49 +03:00
Egor 31a5f8766f Delete QWEN.md 2025-11-07 23:21:06 +03:00
gy9vin cfdc829f48 update 2025-11-07 22:24:43 +03:00
aleksandryants c44cefb983 Add wata payment processing method
Добавить обработку платежа wata
2025-11-07 19:49:27 +03:00
Egor 79c2307c92 Merge pull request #1762 from Fr1ngg/dev4
ебучая юкасса
2025-11-07 10:55:04 +03:00
Egor 25f126a5cb Merge pull request #1761 from Fr1ngg/bedolaga/disable-webhook-secret-validation-and-check-ip-tr0szb
fix: allow trusted public proxies for YooKassa webhooks
2025-11-07 10:49:17 +03:00
Egor 45bd54e7b0 fix: allow trusted public proxies for YooKassa webhooks 2025-11-07 10:43:19 +03:00
Egor 4ce43f80b8 Merge pull request #1755 from Fr1ngg/dev4
Platega + small fixs
2025-11-07 08:40:52 +03:00
Egor f7a4cedbbf Fix missing logging import in Platega handler 2025-11-07 08:29:29 +03:00
Egor 6f83122e42 Merge pull request #1753 from Fr1ngg/bedolaga/update-readme-with-platega.io-info
docs: document Platega payment integration
2025-11-07 08:18:18 +03:00
Egor dbcf3541a8 docs: document Platega payment integration 2025-11-07 08:16:45 +03:00
Egor b7adf3e971 Merge pull request #1752 from Fr1ngg/main
w
2025-11-07 08:08:01 +03:00
Egor 037b1f876b Merge pull request #1751 from Fr1ngg/bedolaga/fix-display-issue-for-unlimited-traffic
Fix unlimited traffic display in payment methods
2025-11-07 08:01:35 +03:00
Egor 4495658fad Fix unlimited traffic display in payment methods 2025-11-07 07:58:54 +03:00
Egor 017b758e6c Merge pull request #1750 from Fr1ngg/bedolaga/move-platega-settings-to-payments-section
Add Platega payment tests
2025-11-07 07:52:37 +03:00
Egor edff6d5102 Add tests for Platega payment flows and method parsing 2025-11-07 07:49:38 +03:00
Egor 979fbe305a Merge pull request #1749 from Fr1ngg/bedolaga/fix-trial-access-issue-for-users
Handle missing devices placeholder in trial offer message
2025-11-07 07:24:34 +03:00
Egor d1edb8f892 Fix trial offer formatting for device limit 2025-11-07 07:17:53 +03:00
Egor 0eeda6b9ac Merge pull request #1748 from Fr1ngg/bedolaga/integrate-new-payment-method-for-bot
Integrate Platega balance top-ups
2025-11-07 07:05:45 +03:00
Egor 7c31c3616e Guard Platega finalization against duplicate callbacks 2025-11-07 07:05:22 +03:00
Egor fba80b1a0d Integrate Platega balance top-ups 2025-11-07 06:52:57 +03:00
Egor 86dbb317f3 Delete redirect directory 2025-11-07 05:10:03 +03:00
Egor 7432d43b55 Update README.md 2025-11-07 03:32:54 +03:00
Egor a61fb10784 Update Dockerfile 2025-11-07 03:04:36 +03:00
Egor 8d83881cf0 Update docker-registry.yml 2025-11-07 03:04:25 +03:00
Egor d624df5f65 Update docker-hub.yml 2025-11-07 03:04:06 +03:00
Egor a96a7de784 Update README.md 2025-11-07 02:55:42 +03:00
Egor fa188e04c5 Add files via upload 2025-11-07 02:32:46 +03:00
Egor e8c95ece8d Update README.md 2025-11-07 02:24:13 +03:00
Egor bc0f4adb7a Update README.md 2025-11-07 02:23:10 +03:00
Egor 3eb5a71e8d Merge pull request #1747 from Fr1ngg/dev4
Бот на Вебхуке Без смс и регистрации
2025-11-07 01:47:53 +03:00
Egor 37c97de5d6 Update README.md 2025-11-07 01:37:42 +03:00
Egor 3624832ed8 Update README.md 2025-11-07 01:37:06 +03:00
Egor 9ee39d5f71 Update README.md 2025-11-07 01:36:17 +03:00
Egor 3a0e558b6f Update README.md 2025-11-07 01:34:05 +03:00
Egor ca083c216e Merge pull request #1746 from Fr1ngg/bedolaga/fix-notification-sending-error
Fix trial expiration notification loading for monitoring service
2025-11-07 01:12:17 +03:00
Egor 8b9c1d1cc6 Fix trial notification promo group loading 2025-11-07 01:01:33 +03:00
Egor 78830a7884 Merge pull request #1745 from Fr1ngg/bedolaga/add-telegram-webhook-and-both-mode-agf2n2
Expose unified health status alongside admin API
2025-11-06 18:09:19 +03:00
Egor 9fe7d0592f Expose unified health status alongside admin API 2025-11-06 18:02:08 +03:00
Egor 6a3e3c6fc4 Merge pull request #1741 from Fr1ngg/revert-1740-bedolaga/add-telegram-webhook-and-both-mode
Revert "Restore FastAPI docs endpoints for webhook mode"
2025-11-06 17:30:04 +03:00
Egor 54bfff0ed6 Revert "Restore FastAPI docs endpoints for webhook mode" 2025-11-06 17:28:06 +03:00
Egor 6ee305cbba Merge pull request #1740 from Fr1ngg/bedolaga/add-telegram-webhook-and-both-mode
Restore FastAPI docs endpoints for webhook mode
2025-11-06 17:27:56 +03:00
Egor 340041ddae Restore FastAPI docs endpoints for webhook mode 2025-11-06 17:27:39 +03:00
Egor 8a3628a6c5 Merge pull request #1738 from Fr1ngg/dev4
Dev4
2025-11-06 13:54:27 +03:00
Egor 8ac3350ac6 Merge pull request #1735 from reshifter1/main
Проверка промокода при вводе реферального кода + фикс ебучего lazy loading
2025-11-06 13:46:52 +03:00
Pavel Stryuk a507ef9966 Промокоды на место реферального кода 2025-11-06 11:03:50 +01:00
Pavel Stryuk 113405ed71 Промокоды на место реферального кода 2025-11-06 09:26:24 +01:00
Egor 1bdf1a0f4c Update README.md 2025-11-06 09:46:33 +03:00
Egor ff7883e33c Update Dockerfile 2025-11-06 09:09:50 +03:00
Egor 9bd909c1b6 Update docker-registry.yml 2025-11-06 09:09:33 +03:00
Egor 408b4e8a4d Update docker-hub.yml 2025-11-06 09:09:21 +03:00
Egor 0ee9708b4d Merge pull request #1734 from Fr1ngg/dev4
Dev4
2025-11-06 08:45:07 +03:00
Egor 44fadf6709 Merge pull request #1733 from Fr1ngg/bedolaga/fix-high-priority-bugs-in-remnawave_service.py
Reset queued UUID mutations after rollbacks
2025-11-06 08:36:45 +03:00
Egor 7384763466 Reset pending UUID mutations on rollback 2025-11-06 08:32:21 +03:00
Egor 9b6f3b10c6 Fix UUID mutation cleanup ordering 2025-11-06 07:48:00 +03:00
Egor 0e4007bde6 Merge pull request #1728 from Fr1ngg/bedolaga/fix-user-visibility-issue-in-remnawave-bot-4srv7j
Restore RemnaWave UUID map after sync rollbacks
2025-11-06 07:31:11 +03:00
Egor 9fcffc7df1 Merge pull request #1729 from Fr1ngg/revert-1726-bedolaga/fix-user-visibility-issue-in-remnawave-bot
Revert "Fix RemnaWave user identifier refresh during sync"
2025-11-06 07:21:21 +03:00
Egor 8e1a88c5cd Revert "Fix RemnaWave user identifier refresh during sync" 2025-11-06 07:21:12 +03:00
Egor 7bb21c9f77 Protect RemnaWave UUID map updates during rollbacks 2025-11-06 07:20:34 +03:00
Egor 7333379fcf Merge pull request #1726 from Fr1ngg/bedolaga/fix-user-visibility-issue-in-remnawave-bot
Fix RemnaWave user identifier refresh during sync
2025-11-06 04:14:49 +03:00
Egor fc040007b4 Fix remnawave user mapping after sync 2025-11-06 04:05:04 +03:00
Egor b30a3c7961 Merge pull request #1725 from Fr1ngg/dev4
fix
2025-11-06 03:45:46 +03:00
Egor 62e18156f5 Merge pull request #1724 from Fr1ngg/main
w
2025-11-06 03:40:19 +03:00
Egor 853666af6f Merge pull request #1723 from Fr1ngg/bedolaga/fix-typeerror-for-none-connected_squads
Guard payment methods handler against missing connected squads
2025-11-06 03:39:29 +03:00
Egor 3416f7b56c Guard payment methods handler against missing connected squads 2025-11-06 03:39:12 +03:00
Egor d78d82c6ce Update main.py 2025-11-06 03:22:16 +03:00
Egor 47bd1be714 Update system_settings_service.py 2025-11-06 03:21:48 +03:00
Egor 1eec919b88 Update main.py 2025-11-06 03:17:08 +03:00
Egor 777ad52bad Update system_settings_service.py 2025-11-06 03:02:31 +03:00
Egor 5fcadb5544 Update system_settings_service.py 2025-11-06 02:48:36 +03:00
Egor 0a8af20b58 Update system_settings_service.py 2025-11-06 02:23:32 +03:00
Egor 95163a0e35 Update monitoring_service.py 2025-11-06 01:59:53 +03:00
Egor ac58466c1a Update promo_offers.py 2025-11-06 01:45:26 +03:00
Egor 5ce14dcd9b Update promo_offers.py 2025-11-06 01:39:08 +03:00
Egor 21d700e16d Add files via upload 2025-11-06 01:35:18 +03:00
Egor 5f2d4a5e04 Update poll_service.py 2025-11-06 01:30:17 +03:00
Egor 7b90d73007 Update poll_service.py 2025-11-06 01:26:18 +03:00
Egor 68c90aec44 Update poll_service.py 2025-11-06 01:22:07 +03:00
Egor 9280c9a39a Update poll_service.py 2025-11-06 01:17:49 +03:00
Egor 245e31e262 Update purchase.py 2025-11-06 01:12:27 +03:00
Egor c00d7ef4d3 Update poll_service.py 2025-11-05 22:03:38 +03:00
Egor 2a652c562b Refactor promo offer sending with asyncio semaphore
Refactor promo offer sending to use asyncio semaphore for concurrent processing and batch sending.
2025-11-05 21:52:04 +03:00
Egor 1cc31729eb Implement semaphore and batch processing for broadcasts
Refactor broadcast message sending to use asyncio semaphore for concurrency control and batch processing.
2025-11-05 21:42:03 +03:00
Egor 34e5c20c1a Update broadcast_service.py 2025-11-05 21:33:48 +03:00
Egor c8545fe725 Refactor import path for SubscriptionService 2025-11-05 21:06:18 +03:00
Egor a8339d376d Update main.py 2025-11-05 20:59:42 +03:00
Egor 47150ccbd0 Update main.py 2025-11-05 20:52:28 +03:00
Egor 0a0890d8bb Update monitoring.py 2025-11-05 20:41:59 +03:00
Egor 6bcf24e3d8 Update welcome_text.py 2025-11-05 20:34:48 +03:00
Egor 898008a1bd Update remnawave_api.py 2025-11-05 20:21:52 +03:00
Egor 09af2e1d41 Update main.py 2025-11-05 20:21:21 +03:00
Egor 045d44f451 Update app.py 2025-11-05 20:09:34 +03:00
Egor 04b2325cb9 Update pricing.py 2025-11-05 19:57:39 +03:00
Egor 504581b0f8 Merge pull request #1721 from DOFER998/c0mrade/makefile-refactor
feat(makefile): refactor structure and add auto-generated help command
2025-11-05 19:45:55 +03:00
c0mrade 0572f414d4 feat(makefile): refactor structure and add auto-generated help command 2025-11-05 19:42:00 +03:00
Egor 50736dc005 Update price_display.py 2025-11-05 19:36:38 +03:00
Egor fe4bdb6a04 Merge pull request #1720 from Fr1ngg/dev4
Update user.py
2025-11-05 18:56:24 +03:00
Egor 7d7c5f3972 Update user.py 2025-11-05 18:51:35 +03:00
Egor 131a2d18cc Merge pull request #1719 from Fr1ngg/dev4
Dev4
2025-11-05 18:26:53 +03:00
Egor 47296c5c08 Merge pull request #1718 from Fr1ngg/bedolaga/fix-user-creation-integrity-issue
Ensure create_user_no_commit flushes new users
2025-11-05 18:22:10 +03:00
Egor c15b7a63fb Flush user before returning from create_user_no_commit 2025-11-05 18:19:22 +03:00
Egor e476afe208 Merge pull request #1716 from Fr1ngg/bedolaga/fix-remnawave-sync-error
Ensure RemnaWave service exposes create_user_no_commit
2025-11-05 18:13:45 +03:00
Egor b59d422fb0 Ensure RemnaWave service exposes create_user_no_commit 2025-11-05 18:13:31 +03:00
Egor 8b105c2396 Merge pull request #1715 from Fr1ngg/dev4
Раъебочка от Паши @reshifter1
2025-11-05 18:04:21 +03:00
Egor 9b6e0b26c3 Ensure autopurchase sets paid flag for converted trials 2025-11-05 18:02:07 +03:00
Egor 4ca038e186 Merge pull request #1714 from Fr1ngg/bedolaga/keep-user_promo_groups-in-sync-on-delete
Fix promo group deletion to maintain user_promo_groups
2025-11-05 17:55:28 +03:00
Egor 3e185e7ffe Reassign promo group memberships when deleting groups 2025-11-05 17:49:47 +03:00
Egor 5e27dcdcfb Merge pull request #1712 from Fr1ngg/bedolaga/fix-sync-for-legacy-promo_group_id-qofcyl
Recalculate primary promo group when reusing auto assignment
2025-11-05 17:39:09 +03:00
Egor 8730a68b40 Recalculate primary promo group instead of forcing target 2025-11-05 17:35:09 +03:00
Egor d88f13784a Merge pull request #1708 from reshifter1/main
Пачка правок
2025-11-05 17:19:32 +03:00
Pavel Stryuk 6e471c9094 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-05 13:45:54 +01:00
reshifter1 3201b60ff2 Merge branch 'dev4' into main 2025-11-04 22:57:38 +03:00
Pavel Stryuk d30d1e2a29 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-04 20:52:17 +01:00
Pavel Stryuk bd73ad069f 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-04 20:36:01 +01:00
Egor 2186780c32 Merge pull request #1706 from Fr1ngg/dev4
Dev4
2025-11-04 16:40:27 +03:00
Egor fbb2e561c5 Merge pull request #1705 from Fr1ngg/bedolaga/fix-mulenpay-sbp-callback-error
Fix MulenPay callback lazy loading error
2025-11-04 16:39:57 +03:00
Egor c8aba42d3d Reload user after MulenPay callback commit 2025-11-04 16:33:34 +03:00
Pavel Stryuk 05d495e874 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-04 14:13:21 +01:00
Pavel Stryuk 427011fe41 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) К промокодам можно будет добавить промогруппу. Все активировавшие промокод получат её
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
2025-11-04 13:05:02 +01:00
Egor 2feacccb13 Merge pull request #1703 from Fr1ngg/dev4
fix
2025-11-04 11:40:21 +03:00
Egor dc196fb478 Optimize user promo_group and subscription access
Refactor user attribute access to avoid lazy loading.
2025-11-04 09:30:21 +03:00
Egor 71c219e6ea Update user.py 2025-11-04 09:29:46 +03:00
Egor e250812463 Update simple_subscription.py 2025-11-04 08:02:03 +03:00
Egor 477b0b26c4 Update docker-compose.yml 2025-11-04 07:53:53 +03:00
Egor 06323f7168 Enhance search functionality for telegram_id
Added error handling for converting search input to int for telegram_id.
2025-11-04 07:39:12 +03:00
Egor 6b471caa22 Merge pull request #1700 from Fr1ngg/main
w
2025-11-04 06:21:51 +03:00
Egor 6fb28dd363 Merge pull request #1697 from remnawave-contrib/ivan-nginx-tg-stars-disabled-by-default
fix: update Telegram Stars rate & disable by default in `.env`
2025-11-04 06:21:08 +03:00
Ivan.Nginx 22dcff47d1 fix: update Telegram Stars rate & disable by default in .env 2025-11-03 23:42:21 +03:00
Egor 1d9b5ea3c6 Merge pull request #1689 from Fr1ngg/7iu0vl-bedolaga/expand-bot-api-for-server-management
Prevent partial server updates when promo groups are invalid
2025-11-03 07:19:25 +03:00
Egor 08e0b3a657 Validate promo groups before updating server 2025-11-03 07:19:03 +03:00
Egor 10b80981b2 Merge pull request #1688 from Fr1ngg/revert-1687-txzghv-bedolaga/expand-bot-api-for-server-management
Revert "Fix RemnaWave panel user creation defaults"
2025-11-03 07:18:33 +03:00
Egor 348f02f34c Revert "Fix RemnaWave panel user creation defaults" 2025-11-03 07:18:24 +03:00
Egor 9e9e984f36 Merge pull request #1687 from Fr1ngg/txzghv-bedolaga/expand-bot-api-for-server-management
Fix RemnaWave panel user creation defaults
2025-11-03 07:01:38 +03:00
Egor d47ca550ae Fix RemnaWave user sync tests by restoring create_user usage 2025-11-03 06:59:29 +03:00
Egor 16f4285948 Update README.md 2025-11-03 06:09:14 +03:00
Egor 23cb6dd8d0 Merge pull request #1685 from Fr1ngg/main
w
2025-11-02 22:25:17 +03:00
Egor dfbb6b0058 Merge pull request #1686 from Fr1ngg/revert-1684-qt986v-bedolaga/fix-user-search-logic-for-large-integers
Revert "Fix user search when numeric query exceeds INT32 range"
2025-11-02 22:25:10 +03:00
Egor 62f8fdd9bb Revert "Fix user search when numeric query exceeds INT32 range" 2025-11-02 22:24:56 +03:00
Egor fc114d15a0 Merge pull request #1684 from Fr1ngg/qt986v-bedolaga/fix-user-search-logic-for-large-integers
Fix user search when numeric query exceeds INT32 range
2025-11-02 22:22:11 +03:00
Egor a9fb8a069d Fix user search when query exceeds int32 2025-11-02 22:21:50 +03:00
Egor 9c6f738382 Merge pull request #1678 from Gy9vin/main
массовая синхронизация
2025-11-02 21:09:02 +03:00
gy9vin 1a7b2eb908 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-11-02 16:08:09 +03:00
gy9vin b108cc6b60 Фикс синхронизации 2025-11-02 16:08:05 +03:00
Mikhail 92cb1e3971 Merge branch 'Fr1ngg:main' into main 2025-11-02 15:08:01 +03:00
gy9vin 005fc95433 Массовая синхронизация пользователей с ремнем! 2025-11-02 15:07:28 +03:00
Egor f09771bc1c Merge pull request #1677 from Fr1ngg/dev4
Dev4
2025-11-02 06:31:39 +03:00
Egor d5b72198d7 Merge pull request #1676 from Fr1ngg/main
w
2025-11-02 06:30:31 +03:00
Egor 991bf1161c Merge pull request #1675 from Fr1ngg/sq2b4y-bedolaga/fix
Handle repeated return-to-cart callbacks without redundant edits
2025-11-02 06:13:27 +03:00
Egor 1a955d920a Handle repeated return-to-cart callbacks without redundant edits 2025-11-02 06:13:16 +03:00
Egor d575c914a6 Merge pull request #1674 from Fr1ngg/revert-1673-i0kn8d-bedolaga/fix
Revert "Prevent duplicate edits when resuming saved subscription carts"
2025-11-02 06:12:09 +03:00
Egor 90a054e942 Revert "Prevent duplicate edits when resuming saved subscription carts" 2025-11-02 06:11:59 +03:00
Egor aac5fabbca Merge pull request #1673 from Fr1ngg/i0kn8d-bedolaga/fix
Prevent duplicate edits when resuming saved subscription carts
2025-11-02 06:08:17 +03:00
Egor 3ce5d1650b Avoid redundant edits when reopening saved subscription cart 2025-11-02 06:08:05 +03:00
Egor 24879b9cf2 Merge pull request #1672 from Fr1ngg/revert-1671-eup68i-bedolaga/fix
Revert "Fix saved subscription cart resume button when returning from balance top-up"
2025-11-02 06:07:36 +03:00
Egor 37f27df062 Revert "Fix saved subscription cart resume button when returning from balance top-up" 2025-11-02 06:07:27 +03:00
Egor b78ed6797a Merge pull request #1671 from Fr1ngg/eup68i-bedolaga/fix
Fix saved subscription cart resume button when returning from balance top-up
2025-11-02 05:33:03 +03:00
Egor f73dcd6f66 Fix saved cart resume button for subscription checkout 2025-11-02 05:32:47 +03:00
Egor 0634d01336 Merge pull request #1670 from Fr1ngg/9xjrx8-bedolaga/update-user-status-logic-in-bot
Stop disabling RemnaWave users when subscriptions expire
2025-11-02 04:52:32 +03:00
Egor b43d6a83ee Stop disabling panel users when subscriptions expire 2025-11-02 04:52:00 +03:00
Egor 93144eea0e Merge pull request #1669 from Fr1ngg/ulngpf-bedolaga/fix-user-sync-timezone-issue
Normalize RemnaWave sync times to UTC
2025-11-02 03:13:29 +03:00
Egor 7d078a58e1 Normalize RemnaWave sync times to UTC 2025-11-02 03:13:13 +03:00
Egor 224c346bc0 Merge pull request #1657 from remnawave-contrib/ivan-nginx-timezone
patch: add missed timezone parameter to .env.example
2025-11-02 03:08:21 +03:00
Egor 51e937ae0c Merge pull request #1662 from Fr1ngg/dev4
Dev4
2025-11-01 16:41:24 +03:00
Egor 04073815ca Merge pull request #1661 from Fr1ngg/main
w
2025-11-01 16:41:05 +03:00
Egor ff556ba347 Merge pull request #1660 from Fr1ngg/qrn17i-bedolaga/deactivate-active-subscriptions
Fix monitoring to only deactivate trial subscriptions after channel unsubscribe
2025-11-01 16:39:21 +03:00
Egor 2d751c238c Fix trial subscription monitoring not to disable paid plans 2025-11-01 16:39:04 +03:00
Ivan.Nginx 5d27fa2963 patch: add missed timezone parameter to .env.example 2025-11-01 09:10:39 +03:00
Ivan.Nginx fe71935d58 Merge branch 'Fr1ngg:main' into main 2025-11-01 08:59:41 +03:00
Egor d0d1b7617a Update Python version to 2.5.7 in Dockerfile 2025-11-01 06:15:32 +03:00
Egor 310990ae32 Update versioning to 2.5.7 in workflow 2025-11-01 06:15:15 +03:00
Egor b565c32e77 Update version number to 2.5.7 in workflow 2025-11-01 06:14:58 +03:00
Egor f3edb4344b Merge pull request #1655 from Fr1ngg/dev4
Фиксы
2025-11-01 05:45:50 +03:00
Egor ccdcff6a7b Merge pull request #1654 from Fr1ngg/7sqsqp-bedolaga/fix-user-deletion-errors
Handle Heleket payments when removing users
2025-11-01 05:43:19 +03:00
Egor 1d4090ea7a Handle Heleket payments on user deletion 2025-11-01 05:43:03 +03:00
Egor 68c7d35165 Merge pull request #1653 from Fr1ngg/fifw2h-bedolaga/move-configuration-sections-for-the-bot
Group timezone and payment verification settings
2025-11-01 05:28:12 +03:00
Egor 221cc05af3 Group timezone and payment verification settings 2025-11-01 05:27:56 +03:00
Egor 1524970401 Merge pull request #1652 from Fr1ngg/revert-1651-bw734r-bedolaga/move-configuration-sections-for-the-bot
Revert "Reorganize admin configuration categories"
2025-11-01 05:27:47 +03:00
Egor 32a03c5e4b Revert "Reorganize admin configuration categories" 2025-11-01 05:27:37 +03:00
Egor a03036c099 Merge pull request #1651 from Fr1ngg/bw734r-bedolaga/move-configuration-sections-for-the-bot
Reorganize admin configuration categories
2025-11-01 05:18:46 +03:00
Egor 811e8a97b3 Reorganize bot configuration categories 2025-11-01 05:18:31 +03:00
Egor c7625643c6 Merge pull request #1650 from Fr1ngg/h9h52u-bedolaga/fix-log-time-discrepancy-with-server-time
Fix subscription timestamps to use configured timezone
2025-11-01 04:54:29 +03:00
Egor 5ce87238c8 Fix subscription timestamps to use configured timezone 2025-11-01 04:54:12 +03:00
Egor 7d12a327ca Merge pull request #1649 from Fr1ngg/revert-1648-es7eok-bedolaga/fix-log-time-discrepancy-with-server-time
Revert "Improve timezone-aware logging"
2025-11-01 04:54:03 +03:00
Egor 8c93637d79 Revert "Improve timezone-aware logging" 2025-11-01 04:53:53 +03:00
Egor 2d0ea02b4b Merge pull request #1648 from Fr1ngg/es7eok-bedolaga/fix-log-time-discrepancy-with-server-time
Improve timezone-aware logging
2025-11-01 04:31:10 +03:00
Egor 98692ac3bd Improve timezone-aware logging 2025-11-01 04:30:32 +03:00
Egor 940b6b93b2 Merge pull request #1647 from Fr1ngg/d76wg3-bedolaga/modify-parameter-priority-logic-in-bot
Respect .env configuration priority
2025-11-01 02:50:43 +03:00
Egor 17768c303f Ensure env configuration takes priority over DB overrides 2025-11-01 02:50:24 +03:00
Egor 441fcd2a36 Merge pull request #1646 from Fr1ngg/dev4
Фиксы и приколы
2025-11-01 02:27:46 +03:00
Egor c2b6b4e370 Merge pull request #1644 from Fr1ngg/w96px9-bedolaga/add-referrals-button-to-user-management
Add admin user referrals management
2025-11-01 01:58:47 +03:00
Egor 2f8d27b1ec Merge pull request #1645 from Fr1ngg/revert-1643-81vlci-bedolaga/add-referrals-button-to-user-management
Revert "Add admin user referrals management"
2025-11-01 01:58:37 +03:00
Egor dc32f53585 Revert "Add admin user referrals management" 2025-11-01 01:58:29 +03:00
Egor 165691412b Clear referral edit state when returning to list 2025-11-01 01:58:08 +03:00
Egor 824d4b3a23 Merge pull request #1643 from Fr1ngg/81vlci-bedolaga/add-referrals-button-to-user-management
Add admin user referrals management
2025-11-01 01:35:36 +03:00
Egor 060f0ca199 Add admin user referrals management 2025-11-01 01:35:20 +03:00
Egor ac43f17a43 Merge pull request #1642 from Fr1ngg/zvatmo-bedolaga/add-referral-percentage-for-all-deposits
Allow referral commissions on any top-up amount
2025-11-01 01:19:53 +03:00
Egor 54fc1c029c Allow referral commissions on any top-up amount 2025-11-01 01:19:31 +03:00
Egor d259bcf169 Merge pull request #1641 from Fr1ngg/revert-1640-qbz9v3-bedolaga/add-parsing-for-current-telegram-stars-rate
Revert "Add automatic Telegram Stars rate fetching and toggle"
2025-11-01 00:56:04 +03:00
Egor da46c18210 Revert "Add automatic Telegram Stars rate fetching and toggle" 2025-11-01 00:55:55 +03:00
Egor ace7121a4e Merge pull request #1640 from Fr1ngg/qbz9v3-bedolaga/add-parsing-for-current-telegram-stars-rate
Add automatic Telegram Stars rate fetching and toggle
2025-11-01 00:26:11 +03:00
Egor 61a93f86b3 Add dynamic Telegram Stars rate with auto refresh 2025-11-01 00:25:55 +03:00
Egor 49bcd18a9a Merge pull request #1638 from Fr1ngg/4vtqjj-bedolaga/fix-500-error-in-/promo-groups-due-to-date-validation
Fix promo group timestamp serialization issues
2025-10-31 22:52:24 +03:00
Egor 7df64d8edf Fix admin notification service f-string syntax 2025-10-31 22:52:11 +03:00
Egor ca63b4d758 Merge pull request #1637 from Fr1ngg/5j1ved-bedolaga/specify-existing-parameters-for-payment-verification
Add payment verification auto-check options to env template
2025-10-31 22:31:42 +03:00
Egor 3cbb304e9b Document payment verification auto-check settings 2025-10-31 22:31:22 +03:00
Egor ef5192234a Merge pull request #1636 from Fr1ngg/dev4
Dev4
2025-10-31 22:18:24 +03:00
Egor f7bb9ac4d9 Merge pull request #1635 from Fr1ngg/main
w
2025-10-31 22:17:49 +03:00
Egor b8bb4f3c3f Merge pull request #1633 from Gy9vin/main
СРОЧНЫЙ ФИКС
2025-10-31 22:15:52 +03:00
Egor e43ff27639 Merge pull request #1634 from Fr1ngg/cw91a0-bedolaga/add-attempts-parameter-to-maintenance-check
Add configurable retry attempts for maintenance panel checks
2025-10-31 22:15:25 +03:00
Egor e02123c1ed Add configurable retry attempts for maintenance checks 2025-10-31 22:15:05 +03:00
gy9vin f26df389b4 СРОЧНЫЙ ФИКС 2025-10-31 22:13:23 +03:00
Egor 10d36a829d Merge pull request #1632 from Fr1ngg/p7wjzx-bedolaga/fix-ad-campaign-notification-bug
Fix admin notification for first advertising campaign clicks
2025-10-31 22:04:04 +03:00
Egor 61e936dd49 Fix campaign visit notifications on initial link click 2025-10-31 22:01:03 +03:00
Egor d0f9a04d5f Merge pull request #1631 from Fr1ngg/ishx2m-bedolaga/fix-maintenance-mode-persistence-issue
Add configuration flag to disable automatic maintenance monitoring
2025-10-31 21:46:26 +03:00
Egor cf6bbffd9b Respect maintenance monitoring setting 2025-10-31 21:45:58 +03:00
Egor 5ad45addfa Merge pull request #1630 from Fr1ngg/s4pdi7-bedolaga/add-placeholder-for-user-name
Add configurable template for RemnaWave panel usernames
2025-10-31 21:32:13 +03:00
Egor 2af013b75b Add template support for RemnaWave usernames 2025-10-31 21:31:54 +03:00
Egor 74bda5c986 Merge pull request #1629 from Fr1ngg/l1utns-bedolaga/fix-pal24-payment-status-error
Fix Pal24 payment status retrieval crash
2025-10-31 21:17:57 +03:00
Egor b290ccfff2 Add Pal24 bill payments proxy and tests 2025-10-31 21:17:34 +03:00
Egor 904d2883e8 Merge pull request #1628 from Fr1ngg/gvkfjc-bedolaga/fix-duplicate-messages-in-operation-history
Fix duplicate MulenPay transactions in history
2025-10-31 21:08:29 +03:00
Egor 1a54beed17 Fix duplicate MulenPay transactions in history 2025-10-31 21:07:48 +03:00
Egor 701a8d33af Merge pull request #1627 from Fr1ngg/ibnw5b-bedolaga/remove-unused-referred_user_reward-parameter
Remove referred user reward setting
2025-10-31 20:38:58 +03:00
Egor aa03384012 Remove referred user reward setting 2025-10-31 20:38:38 +03:00
Egor d346c63184 Merge pull request #1626 from Fr1ngg/dev4
fix [BUG] Неправильное отображение статуса подписки #1585
2025-10-31 19:53:27 +03:00
Egor b8005aaad7 Merge pull request #1625 from Fr1ngg/3evicu-bedolaga/fix-subscription-status-display-issue
Improve FakeSession execute stub in payment webhook tests
2025-10-31 19:43:20 +03:00
Egor bfa9490425 Fix FakeSession execute stub for payment webhook tests 2025-10-31 19:42:53 +03:00
Egor 1f4af21a07 Merge pull request #1624 from Fr1ngg/dev4
Отключение лимита устройств
2025-10-31 19:28:58 +03:00
Egor f952c0e38d Merge pull request #1623 from Fr1ngg/main
w
2025-10-31 19:27:52 +03:00
Egor d7dab5d30f Merge pull request #1622 from Fr1ngg/plexxe-bedolaga/add-option-to-disable-device-selection
Align trial offer with disabled device selection defaults
2025-10-31 19:16:07 +03:00
Egor 8407f7cdb1 Restore device management button in disabled mode 2025-10-31 19:15:48 +03:00
Egor 6c72987201 Merge pull request #1621 from Fr1ngg/revert-1620-7irv2f-bedolaga/add-option-to-disable-device-selection
Revert "Align trial offer with disabled device selection defaults"
2025-10-31 19:15:32 +03:00
Egor 81b6d3f18f Revert "Align trial offer with disabled device selection defaults" 2025-10-31 19:15:20 +03:00
Egor 95754ab224 Merge pull request #1620 from Fr1ngg/7irv2f-bedolaga/add-option-to-disable-device-selection
Align trial offer with disabled device selection defaults
2025-10-31 19:08:36 +03:00
Egor 949d964a7f Fix trial messaging and limits for disabled device selection 2025-10-31 19:08:21 +03:00
Egor 8eab79434a Merge pull request #1619 from Fr1ngg/revert-1618-rfh1ig-bedolaga/add-option-to-disable-device-selection
Revert "Restore RemnaWave HWID caps when device selection is disabled"
2025-10-31 19:08:06 +03:00
Egor 8ab2351995 Revert "Restore RemnaWave HWID caps when device selection is disabled" 2025-10-31 19:07:58 +03:00
Egor e8802dde55 Merge pull request #1618 from Fr1ngg/rfh1ig-bedolaga/add-option-to-disable-device-selection
Restore RemnaWave HWID caps when device selection is disabled
2025-10-31 18:40:10 +03:00
Egor acc5ace234 Ensure disabled device mode keeps RemnaWave caps aligned 2025-10-31 18:39:45 +03:00
Egor 7b33721305 Merge pull request #1617 from Fr1ngg/revert-1616-c6i24i-bedolaga/add-option-to-disable-device-selection
Revert "Guard disabled-mode device caps when unset"
2025-10-31 18:39:33 +03:00
Egor e94f4598b5 Revert "Guard disabled-mode device caps when unset" 2025-10-31 18:39:21 +03:00
Egor c66c8b7933 Merge pull request #1616 from Fr1ngg/c6i24i-bedolaga/add-option-to-disable-device-selection
Guard disabled-mode device caps when unset
2025-10-31 18:27:08 +03:00
Egor 0b934efa09 Guard disabled-mode device caps when unset 2025-10-31 18:26:50 +03:00
Egor 79e644f6dd Merge pull request #1615 from Fr1ngg/revert-1613-el6wtr-bedolaga/add-option-to-disable-device-selection
Revert "Align simple subscription device caps with disabled mode"
2025-10-31 18:26:39 +03:00
Egor 5c68a1aca7 Revert "Align simple subscription device caps with disabled mode" 2025-10-31 18:26:27 +03:00
Egor bde613c4bf Merge pull request #1613 from Fr1ngg/el6wtr-bedolaga/add-option-to-disable-device-selection
Align simple subscription device caps with disabled mode
2025-10-31 18:05:59 +03:00
Egor 184d8c3ba7 Merge pull request #1614 from Fr1ngg/revert-1612-9j4uf1-bedolaga/add-option-to-disable-device-selection
Revert "Add configurable device cap for disabled selection mode"
2025-10-31 18:05:49 +03:00
Egor d48221d57f Revert "Add configurable device cap for disabled selection mode" 2025-10-31 18:05:40 +03:00
Egor 657fd6d07d Align simple subscription device caps with disabled mode 2025-10-31 18:05:20 +03:00
Egor 1211cd22ad Merge pull request #1612 from Fr1ngg/9j4uf1-bedolaga/add-option-to-disable-device-selection
Add configurable device cap for disabled selection mode
2025-10-31 17:43:01 +03:00
Egor 19ed88009d Add disabled-mode device limit controls 2025-10-31 17:42:40 +03:00
Egor 0f30ee87ab Merge pull request #1611 from Fr1ngg/revert-1610-dvh9qt-bedolaga/add-option-to-disable-device-selection
Revert "Preserve saved cart metadata when normalizing devices"
2025-10-31 17:42:17 +03:00
Egor 4cc6778e45 Revert "Preserve saved cart metadata when normalizing devices" 2025-10-31 17:42:04 +03:00
Egor ab00a0d136 Merge pull request #1610 from Fr1ngg/dvh9qt-bedolaga/add-option-to-disable-device-selection
Preserve saved cart metadata when normalizing devices
2025-10-31 14:01:24 +03:00
Egor 3d4f1af9a9 Preserve saved cart metadata during normalization 2025-10-31 14:01:06 +03:00
Egor 0302176fd2 Merge pull request #1609 from Fr1ngg/revert-1608-0ol61g-bedolaga/add-option-to-disable-device-selection
Revert "Normalize saved cart devices when selection disabled"
2025-10-31 14:00:37 +03:00
Egor 8910780332 Revert "Normalize saved cart devices when selection disabled" 2025-10-31 14:00:28 +03:00
Egor 8bea64179a Merge pull request #1608 from Fr1ngg/0ol61g-bedolaga/add-option-to-disable-device-selection
Normalize saved cart devices when selection disabled
2025-10-31 13:48:02 +03:00
Egor 777f90eab1 Normalize saved cart devices when selection disabled 2025-10-31 13:47:42 +03:00
Egor ad0b0f475a Merge pull request #1607 from Fr1ngg/revert-1606-3jxg23-bedolaga/add-option-to-disable-device-selection
Revert "Hide device limits when device selection disabled"
2025-10-31 13:47:01 +03:00
Egor 499292eab6 Revert "Hide device limits when device selection disabled" 2025-10-31 13:46:52 +03:00
Egor 21b2f1bd2a Merge pull request #1606 from Fr1ngg/3jxg23-bedolaga/add-option-to-disable-device-selection
Hide device limits when device selection disabled
2025-10-31 13:21:58 +03:00
Egor 3c025b536e Preserve default devices when selection disabled 2025-10-31 13:21:14 +03:00
Egor bded6fe59f Merge pull request #1605 from Fr1ngg/revert-1603-ej06m7-bedolaga/add-option-to-disable-device-selection
Revert "Hide device limits when device selection disabled"
2025-10-31 13:21:01 +03:00
Egor 344ad8f3c0 Revert "Hide device limits when device selection disabled" 2025-10-31 13:20:52 +03:00
Egor b43dd3167f Merge pull request #1603 from Fr1ngg/ej06m7-bedolaga/add-option-to-disable-device-selection
Hide device limits when device selection disabled
2025-10-31 12:51:49 +03:00
Egor 79ed9dbab2 Merge pull request #1604 from Fr1ngg/revert-1602-n5lhq1-bedolaga/add-option-to-disable-device-selection
Revert "Add toggle to disable device selection during subscription checkout"
2025-10-31 12:51:41 +03:00
Egor 38ff59f71f Revert "Add toggle to disable device selection during subscription checkout" 2025-10-31 12:51:29 +03:00
Egor 65c85e146d Hide device info when device selection disabled 2025-10-31 12:50:44 +03:00
Egor 467955e7b3 Merge pull request #1602 from Fr1ngg/n5lhq1-bedolaga/add-option-to-disable-device-selection
Add toggle to disable device selection during subscription checkout
2025-10-31 12:19:29 +03:00
Egor 1a4250a7ed Add toggle to disable device selection 2025-10-31 12:19:10 +03:00
Egor e181e4325b Merge pull request #1601 from Fr1ngg/revert-1600-o0l191-bedolaga/add-option-to-disable-device-selection
Revert "Add toggle to disable device selection"
2025-10-31 12:18:34 +03:00
Egor 1a35237852 Revert "Add toggle to disable device selection" 2025-10-31 12:18:26 +03:00
Egor 90baf3da52 Merge pull request #1600 from Fr1ngg/o0l191-bedolaga/add-option-to-disable-device-selection
Add toggle to disable device selection
2025-10-31 12:13:32 +03:00
Egor 348e4dd4dc Add toggle to disable device selection 2025-10-31 12:13:11 +03:00
Egor d2cd96a45c Merge pull request #1599 from remnawave-contrib/ivan-nginx-issue-templates
Added issue templates
2025-10-31 06:32:35 +03:00
Ivan.Nginx 64238e3280 Merge pull request #1 from remnawave-contrib/ivan-nginx-issue-templates
Added issue templates
2025-10-31 05:02:44 +03:00
Ivan.Nginx fcfac2df1f Added issue templates
1. Bug Template — «Bug Bedolage»
2. Teature Template — «Feat Bedolage»
2025-10-31 05:02:00 +03:00
Egor a0734a6eb4 Merge pull request #1596 from Fr1ngg/dev4
Dev4
2025-10-31 03:32:04 +03:00
Egor 0d7a235715 Merge pull request #1595 from Fr1ngg/49rs9y-bedolaga/fix-ticket-system-telegram-error
Fix admin ticket view for users without username
2025-10-31 03:28:11 +03:00
Egor 180788bc7b Fix admin ticket view for users without username 2025-10-31 03:27:55 +03:00
Egor 922681891a Merge pull request #1594 from Fr1ngg/revert-1593-jy8fc9-bedolaga/fix-ticket-system-telegram-error
Revert "Fix admin ticket DM links to support ID-only users"
2025-10-31 03:27:47 +03:00
Egor 257c628cc8 Revert "Fix admin ticket DM links to support ID-only users" 2025-10-31 03:27:38 +03:00
Egor dcee842c92 Merge pull request #1593 from Fr1ngg/jy8fc9-bedolaga/fix-ticket-system-telegram-error
Fix admin ticket DM links to support ID-only users
2025-10-31 03:20:12 +03:00
Egor 5f02471fd5 Fix admin ticket DM links to support ID-only users 2025-10-31 03:19:54 +03:00
Egor 82f0f7ce17 Merge pull request #1575 from Fr1ngg/dev4
Dev4
2025-10-30 03:10:21 +03:00
Egor b805b7eef9 Merge pull request #1574 from Fr1ngg/3fc7wf-bedolaga/update-subscription-period-menu-pricing
Hide zero price in subscription period options
2025-10-30 03:06:34 +03:00
Egor ad03fa070a Hide zero price in subscription period options 2025-10-30 03:06:16 +03:00
Egor 9c354d971f Merge pull request #1573 from Fr1ngg/dev4
Dev4
2025-10-30 01:15:58 +03:00
Egor 5cc70c4bb8 Merge pull request #1572 from Fr1ngg/soqxxz-bedolaga/fix-duplicate-telegram-id-handling
Handle Telegram ID uniqueness during RemnaWave sync
2025-10-30 01:15:36 +03:00
Egor 9db806d387 Handle Telegram ID uniqueness during RemnaWave sync 2025-10-30 01:15:19 +03:00
Egor b7f7621d28 Merge pull request #1571 from Fr1ngg/revert-1569-hyyncd-bedolaga/fix-duplicate-telegram-id-handling
Revert "Deduplicate RemnaWave sync users with freshest subscriptions"
2025-10-30 01:14:40 +03:00
Egor 822ac2417d Revert "Deduplicate RemnaWave sync users with freshest subscriptions" 2025-10-30 01:14:32 +03:00
Egor b3c80a2a82 Merge pull request #1570 from Fr1ngg/dev4
Dev4
2025-10-30 01:04:37 +03:00
Egor 3e9d606647 Merge pull request #1569 from Fr1ngg/hyyncd-bedolaga/fix-duplicate-telegram-id-handling
Deduplicate RemnaWave sync users with freshest subscriptions
2025-10-30 01:04:13 +03:00
Egor 0ab89f4956 Deduplicate RemnaWave sync users by telegram id 2025-10-30 01:03:58 +03:00
Egor 0090f1c409 Merge pull request #1568 from Fr1ngg/revert-1567-tfx50u-bedolaga/fix-duplicate-telegram-id-handling
Revert "Handle duplicate RemnaWave panel users during sync"
2025-10-30 01:03:44 +03:00
Egor 1cc37a57c1 Revert "Handle duplicate RemnaWave panel users during sync" 2025-10-30 01:03:35 +03:00
Egor 7809efa556 Merge pull request #1567 from Fr1ngg/tfx50u-bedolaga/fix-duplicate-telegram-id-handling
Handle duplicate RemnaWave panel users during sync
2025-10-30 01:03:10 +03:00
Egor b4a69e3c85 Filter RemnaWave sync duplicates by latest subscription 2025-10-30 01:02:53 +03:00
Egor 67c975d9c2 Merge pull request #1566 from Fr1ngg/main
w
2025-10-30 00:55:05 +03:00
Egor c0812a7ee9 Merge pull request #1565 from Gy9vin/main
Очередной фикс юкассы
2025-10-29 11:32:10 +03:00
gy9vin 8ed755a339 Очередной фикс юкассы 2025-10-29 11:13:55 +03:00
Egor 1aae8783ca Update Dockerfile 2025-10-26 22:09:13 +03:00
Egor 5997a741fc Update docker-registry.yml 2025-10-26 22:09:01 +03:00
Egor c5d522e6b8 Update docker-hub.yml 2025-10-26 22:08:51 +03:00
Egor fa1d128d76 Merge pull request #1560 from Fr1ngg/dev4
Clear saved cart when cancelling subscription checkout
2025-10-26 21:36:44 +03:00
Egor f30516a6c2 Merge pull request #1559 from Fr1ngg/dh2k0d-bedolaga/fix-button-visibility-after-order-cancellation
Clear saved cart when cancelling subscription checkout
2025-10-26 21:32:30 +03:00
Egor 1feb0538cf Clear saved cart after subscription cancellation 2025-10-26 21:32:16 +03:00
Egor 3a791d624f Merge pull request #1558 from Fr1ngg/dev4
Dev4
2025-10-26 20:28:12 +03:00
Egor fe0f1f7641 Merge pull request #1557 from Fr1ngg/9scuix-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle YooKassa auto-purchase subscription refresh
2025-10-26 20:27:34 +03:00
Egor 212783ae3d Fix auto-purchase subscription refresh after YooKassa top-up 2025-10-26 20:26:35 +03:00
Egor 7d72fc9555 Merge pull request #1556 from Fr1ngg/revert-1554-0ru012-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Handle auto-purchase for subscription extensions after top-up"
2025-10-26 20:26:25 +03:00
Egor fb4df5cee1 Revert "Handle auto-purchase for subscription extensions after top-up" 2025-10-26 20:26:12 +03:00
Egor 45cd847b44 Merge pull request #1555 from Fr1ngg/dev4
Автопокупка и продление после пополнения баланса
2025-10-26 19:51:25 +03:00
Egor 98fd97dea2 Merge pull request #1554 from Fr1ngg/0ru012-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle auto-purchase for subscription extensions after top-up
2025-10-26 19:40:50 +03:00
Egor e334086ad1 Fix auto-purchase for subscription extensions 2025-10-26 19:40:31 +03:00
Egor 2e792911f1 Merge pull request #1553 from Fr1ngg/revert-1552-hq8vy3-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Handle simple subscription star purchase flow"
2025-10-26 19:40:00 +03:00
Egor 878b666a0b Revert "Handle simple subscription star purchase flow" 2025-10-26 19:39:51 +03:00
Egor 69c3239469 Merge pull request #1552 from Fr1ngg/hq8vy3-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle simple subscription star purchase flow
2025-10-26 19:15:25 +03:00
Egor b8d48745c5 Handle simple subscription stars purchases 2025-10-26 19:15:02 +03:00
Egor 6b64e73d6e Merge pull request #1551 from Fr1ngg/revert-1550-yp0t6x-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Fix auto-purchase after top-up to use miniapp purchase service"
2025-10-26 19:14:21 +03:00
Egor b433a96cd6 Revert "Fix auto-purchase after top-up to use miniapp purchase service" 2025-10-26 19:14:12 +03:00
Egor 0e4c83299d Merge pull request #1550 from Fr1ngg/yp0t6x-bedolaga/fix-subscription-auto-purchase-after-top-up
Fix auto-purchase after top-up to use miniapp purchase service
2025-10-26 18:51:54 +03:00
Egor c4d1f3eccc Fix auto-purchase to use miniapp service submit 2025-10-26 18:50:13 +03:00
Egor 22dcf425cb Merge pull request #1547 from Fr1ngg/revert-1544-revert-1543-tfkufc-bedolaga/add-automatic-subscription-purchase-feature
Revert "Revert "feat: auto purchase subscription after top-up""
2025-10-26 18:27:10 +03:00
Egor edf4132c2d Revert "Revert "feat: auto purchase subscription after top-up"" 2025-10-26 18:27:01 +03:00
Egor a56e14c736 Merge pull request #1546 from Fr1ngg/revert-1545-67lhig-bedolaga/add-automatic-subscription-purchase-feature
Revert "Add automatic subscription purchase after balance top-up"
2025-10-26 18:26:41 +03:00
Egor 2e3c707745 Revert "Add automatic subscription purchase after balance top-up" 2025-10-26 18:26:32 +03:00
Egor ebd8a10fd9 Merge pull request #1545 from Fr1ngg/67lhig-bedolaga/add-automatic-subscription-purchase-feature
Add automatic subscription purchase after balance top-up
2025-10-26 18:04:30 +03:00
Egor a681a9ff1b Add automatic subscription purchase after balance top-up 2025-10-26 18:04:13 +03:00
Egor d11458c4a8 Merge pull request #1544 from Fr1ngg/revert-1543-tfkufc-bedolaga/add-automatic-subscription-purchase-feature
Revert "feat: auto purchase subscription after top-up"
2025-10-26 18:03:58 +03:00
Egor 9d6e17b609 Revert "feat: auto purchase subscription after top-up" 2025-10-26 18:03:49 +03:00
Egor cb27487021 Merge pull request #1543 from Fr1ngg/tfkufc-bedolaga/add-automatic-subscription-purchase-feature
feat: auto purchase subscription after top-up
2025-10-26 17:56:15 +03:00
Egor e1e6b40a19 feat: auto purchase subscription after top-up 2025-10-26 17:55:51 +03:00
Egor 2616292f97 Merge pull request #1542 from Fr1ngg/revert-1541-bedolaga-oew5ch
Revert "Add automatic subscription purchase after top-ups"
2025-10-26 17:39:13 +03:00
Egor 2e1dfa6638 Revert "Add automatic subscription purchase after top-ups" 2025-10-26 17:39:04 +03:00
Egor 3a730492cb Merge pull request #1541 from Fr1ngg/bedolaga-oew5ch
Add automatic subscription purchase after top-ups
2025-10-26 17:35:16 +03:00
Egor c35da4b98d Add automatic subscription purchase after top-ups 2025-10-26 17:35:01 +03:00
Egor 5e98c25020 Merge pull request #1539 from Fr1ngg/dev4
Сервис автопроверки транзакций update
2025-10-26 10:47:42 +03:00
Egor 11a4d5b436 Merge pull request #1538 from Fr1ngg/revert-1536-revert-1533-4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Revert "Revert "Improve Pal24 and Heleket verification coverage""
2025-10-26 10:46:48 +03:00
Egor 93c733aac0 Revert "Revert "Improve Pal24 and Heleket verification coverage"" 2025-10-26 10:46:38 +03:00
Egor 5b03e7ab8c Merge pull request #1537 from Fr1ngg/main
w
2025-10-26 10:46:24 +03:00
Egor 33912331af Merge pull request #1536 from Fr1ngg/revert-1533-4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Revert "Improve Pal24 and Heleket verification coverage"
2025-10-26 10:45:44 +03:00
Egor aacc07835e Revert "Improve Pal24 and Heleket verification coverage" 2025-10-26 10:45:35 +03:00
Egor 86df2a1408 Update README.md 2025-10-26 10:44:32 +03:00
Egor 018de17c5b Merge pull request #1534 from Fr1ngg/74yls6-bedolaga/fix-back-button-error-during-payment
Handle back navigation from YooKassa SBP payments
2025-10-26 10:38:11 +03:00
Egor 66afa2b924 Handle back navigation from YooKassa SBP payments 2025-10-26 10:37:49 +03:00
Egor 4ee32fe0de Merge pull request #1533 from Fr1ngg/4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Improve Pal24 and Heleket verification coverage
2025-10-26 10:28:57 +03:00
Egor 5b59ca3c4f Display transaction numbers in admin payment list 2025-10-26 10:28:32 +03:00
Egor 0108b0fc22 Merge pull request #1532 from Fr1ngg/revert-1531-ue9uqd-bedolaga/add-auto-check-service-for-user-deposits
Revert "Improve Pal24 and Heleket verification coverage"
2025-10-26 10:28:18 +03:00
Egor 2e575f82af Revert "Improve Pal24 and Heleket verification coverage" 2025-10-26 10:28:10 +03:00
Egor e8af09041d Merge pull request #1531 from Fr1ngg/ue9uqd-bedolaga/add-auto-check-service-for-user-deposits
Improve Pal24 and Heleket verification coverage
2025-10-26 10:15:51 +03:00
Egor 1cf041fb86 Improve Pal24 and Heleket verification coverage 2025-10-26 10:15:31 +03:00
Egor 63074cb009 Merge pull request #1529 from Fr1ngg/revert-1528-3wrea3-bedolaga/add-auto-check-service-for-user-deposits
Revert "Fix CryptoBot admin status checks"
2025-10-26 09:58:22 +03:00
Egor 89b9a6e04f Revert "Fix CryptoBot admin status checks" 2025-10-26 09:58:13 +03:00
Egor 4efa344e43 Merge pull request #1528 from Fr1ngg/3wrea3-bedolaga/add-auto-check-service-for-user-deposits
Fix CryptoBot admin status checks
2025-10-26 09:47:04 +03:00
Egor 2d3d1ba5d3 Fix CryptoBot admin status checks 2025-10-26 09:46:46 +03:00
Egor 849d781e92 Merge pull request #1527 from Fr1ngg/revert-1524-utdzdq-bedolaga/add-auto-check-service-for-user-deposits
Revert "Guard YooKassa manual checks from duplicate processing"
2025-10-26 09:46:33 +03:00
Egor 48a988aa43 Revert "Guard YooKassa manual checks from duplicate processing" 2025-10-26 09:46:25 +03:00
Egor ec71f733ad Merge pull request #1526 from Fr1ngg/revert-1525-dev4
Revert "Сервис проверки платежей"
2025-10-26 09:46:04 +03:00
Egor 051121a366 Revert "Сервис проверки платежей" 2025-10-26 09:45:56 +03:00
Egor e2c2b9127c Merge pull request #1525 from Fr1ngg/dev4
Сервис проверки платежей
2025-10-26 09:32:29 +03:00
Egor fdf7263bb0 Merge pull request #1524 from Fr1ngg/utdzdq-bedolaga/add-auto-check-service-for-user-deposits
Guard YooKassa manual checks from duplicate processing
2025-10-26 09:25:40 +03:00
Egor c8dcde0919 Guard auto verification commits 2025-10-26 09:25:14 +03:00
Egor 9db0cd6328 Merge pull request #1523 from Fr1ngg/revert-1522-z30p3c-bedolaga/add-auto-check-service-for-user-deposits
Revert "Guard YooKassa manual checks from duplicate processing"
2025-10-26 09:24:57 +03:00
Egor 2375bf51b3 Revert "Guard YooKassa manual checks from duplicate processing" 2025-10-26 09:24:49 +03:00
Egor 3631b49d6d Merge pull request #1522 from Fr1ngg/z30p3c-bedolaga/add-auto-check-service-for-user-deposits
Guard YooKassa manual checks from duplicate processing
2025-10-26 09:15:50 +03:00
Egor 3938b56802 Guard YooKassa manual checks from duplicate processing 2025-10-26 09:15:35 +03:00
Egor 3c3b793a9f Merge pull request #1521 from Fr1ngg/revert-1520-vcxme6-bedolaga/add-auto-check-service-for-user-deposits
Revert "Log payment verification service activation during startup"
2025-10-26 09:09:55 +03:00
Egor 8985a89a9f Revert "Log payment verification service activation during startup" 2025-10-26 09:09:46 +03:00
Egor 5750e6a4d2 Merge pull request #1520 from Fr1ngg/vcxme6-bedolaga/add-auto-check-service-for-user-deposits
Log payment verification service activation during startup
2025-10-26 08:54:28 +03:00
Egor 8d7f73edec Log payment verification service activation 2025-10-26 08:54:05 +03:00
Egor 4853d5b19b Merge pull request #1519 from Fr1ngg/revert-1518-x45isk-bedolaga/add-auto-check-service-for-user-deposits
Revert "Prevent admin payment detail handler from handling manual check callbacks"
2025-10-26 08:52:53 +03:00
Egor 901f5fd90d Revert "Prevent admin payment detail handler from handling manual check callbacks" 2025-10-26 08:52:46 +03:00
Egor 89e3865019 Merge pull request #1518 from Fr1ngg/x45isk-bedolaga/add-auto-check-service-for-user-deposits
Prevent admin payment detail handler from handling manual check callbacks
2025-10-26 08:36:43 +03:00
Egor 538b2b52d3 Avoid admin payment detail handler catching manual checks 2025-10-26 08:36:27 +03:00
Egor f1c9df8471 Merge pull request #1517 from Fr1ngg/revert-1516-kvif2x-bedolaga/add-auto-check-service-for-user-deposits
Revert "Show CryptoBot and Stars invoices in admin payments"
2025-10-26 08:32:39 +03:00
Egor 9e09e97ed8 Revert "Show CryptoBot and Stars invoices in admin payments" 2025-10-26 08:32:30 +03:00
Egor e50355be23 Merge pull request #1516 from Fr1ngg/kvif2x-bedolaga/add-auto-check-service-for-user-deposits
Show CryptoBot and Stars invoices in admin payments
2025-10-26 08:27:40 +03:00
Egor bd036fb4a9 Show CryptoBot and Stars invoices in admin payments 2025-10-26 08:27:22 +03:00
Egor 0181f8e5fb Merge pull request #1515 from Fr1ngg/revert-1514-vpe5pb-bedolaga/add-auto-check-service-for-user-deposits
Revert "Include CryptoBot and Stars invoices in admin checks"
2025-10-26 08:27:12 +03:00
Egor f5eea6798a Revert "Include CryptoBot and Stars invoices in admin checks" 2025-10-26 08:27:04 +03:00
Egor 7c8d0254b2 Merge pull request #1514 from Fr1ngg/vpe5pb-bedolaga/add-auto-check-service-for-user-deposits
Include CryptoBot and Stars invoices in admin checks
2025-10-26 08:26:31 +03:00
Egor 49c3c1c0c9 Include CryptoBot and Stars invoices in admin checks 2025-10-26 08:25:43 +03:00
Egor 38522442d1 Merge pull request #1513 from Fr1ngg/revert-1512-6709dc-bedolaga/add-auto-check-service-for-user-deposits
Revert "Include CryptoBot and Stars in admin payment verification"
2025-10-26 08:25:11 +03:00
Egor 0dacd79598 Revert "Include CryptoBot and Stars in admin payment verification" 2025-10-26 08:25:03 +03:00
Egor 2022654055 Merge pull request #1512 from Fr1ngg/6709dc-bedolaga/add-auto-check-service-for-user-deposits
Include CryptoBot and Stars in admin payment verification
2025-10-26 08:18:48 +03:00
Egor f3a1de6335 Include CryptoBot and Stars in admin top-up checks 2025-10-26 08:18:30 +03:00
Egor 0b5b89ea9a Merge pull request #1511 from Fr1ngg/revert-1510-k70a53-bedolaga/add-auto-check-service-for-user-deposits
Revert "Fix admin YooKassa manual check lookup"
2025-10-26 08:18:16 +03:00
Egor 8e21c34c8c Revert "Fix admin YooKassa manual check lookup" 2025-10-26 08:18:07 +03:00
Egor 400f3b01d6 Merge pull request #1510 from Fr1ngg/k70a53-bedolaga/add-auto-check-service-for-user-deposits
Fix admin YooKassa manual check lookup
2025-10-26 07:38:34 +03:00
Egor 6a0b2aabaf Add payment service wrapper for YooKassa local lookup 2025-10-26 07:38:17 +03:00
Egor 13a1ddad11 Merge pull request #1509 from Fr1ngg/revert-1508-ur8a6k-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin top-up verification tools"
2025-10-26 07:37:32 +03:00
Egor ed404a0e54 Revert "Add admin top-up verification tools" 2025-10-26 07:37:15 +03:00
Egor 05acfc0733 Merge pull request #1508 from Fr1ngg/ur8a6k-bedolaga/add-auto-check-service-for-user-deposits
Add admin top-up verification tools
2025-10-26 07:24:23 +03:00
Egor 969cd6ed90 Add admin top-up verification tools 2025-10-26 07:24:03 +03:00
Egor 939c9b9b09 Merge pull request #1507 from Fr1ngg/revert-1506-6evcye-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin pending payment verification endpoints"
2025-10-26 07:23:33 +03:00
Egor 051c0db106 Revert "Add admin pending payment verification endpoints" 2025-10-26 07:23:24 +03:00
Egor e5fbe005f8 Merge pull request #1506 from Fr1ngg/6evcye-bedolaga/add-auto-check-service-for-user-deposits
Add admin pending payment verification endpoints
2025-10-26 07:20:38 +03:00
Egor 63efb31785 Add admin APIs for pending payment checks 2025-10-26 07:20:23 +03:00
Egor 32586e177b Merge pull request #1505 from Fr1ngg/revert-1504-ig0xdj-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin top-up monitoring and manual payment checks"
2025-10-26 07:19:49 +03:00
Egor 651dba2648 Revert "Add admin top-up monitoring and manual payment checks" 2025-10-26 07:19:41 +03:00
Egor 1bbcf2a95c Merge pull request #1504 from Fr1ngg/ig0xdj-bedolaga/add-auto-check-service-for-user-deposits
Add admin top-up monitoring and manual payment checks
2025-10-26 07:16:53 +03:00
Egor 82af176018 Add admin top-up monitoring and manual payment checks 2025-10-26 07:16:35 +03:00
Egor c10a4e9f8a Merge pull request #1503 from Fr1ngg/dev4
Fix mulen/юкасса webhooks
2025-10-26 06:56:37 +03:00
Egor 9727e07464 Merge pull request #1502 from Fr1ngg/9cf17y-bedolaga/improve-webhook-handling-for-payments
Improve YooKassa webhook recovery
2025-10-26 06:52:39 +03:00
Egor b780fe2278 Improve YooKassa webhook recovery 2025-10-26 06:52:18 +03:00
Egor 947d61f2a8 Merge pull request #1501 from Fr1ngg/revert-1500-pfpi1q-bedolaga/improve-webhook-handling-for-payments
Revert "Improve MulenPay hook resilience"
2025-10-26 06:47:15 +03:00
Egor 27725ec1a7 Revert "Improve MulenPay hook resilience" 2025-10-26 06:47:08 +03:00
Egor 93a29463f4 Merge pull request #1500 from Fr1ngg/pfpi1q-bedolaga/improve-webhook-handling-for-payments
Improve MulenPay hook resilience
2025-10-26 06:07:12 +03:00
Egor 84cfcf45fa Improve MulenPay hook resilience 2025-10-26 06:04:16 +03:00
Egor 6651e59f63 Merge pull request #1499 from Fr1ngg/main
ц
2025-10-26 05:55:27 +03:00
Egor ed46b7c919 Update miniapp-setup.md 2025-10-24 13:23:23 +03:00
Egor 88cd9fbc5b Update README.md 2025-10-24 13:14:17 +03:00
Egor 46fc5827ca Merge pull request #1497 from Fr1ngg/dev4
Dev4
2025-10-24 10:05:03 +03:00
Egor 65d386f3b2 Merge pull request #1496 from Fr1ngg/0x48r9-bedolaga/fix-datetime-error-in-payment-status-check
Normalize Heleket payment timestamps before saving
2025-10-24 10:01:40 +03:00
Egor 9db640931e Normalize Heleket paid_at timestamps to UTC 2025-10-24 10:01:23 +03:00
Egor 53e4dec12d Merge pull request #1495 from Fr1ngg/dev4
w
2025-10-24 09:45:03 +03:00
Egor 174d9f9b84 Merge pull request #1494 from Fr1ngg/ur3x81-bedolaga/expand-bot-api-for-surveys
Optimize poll listing response counts
2025-10-24 09:39:57 +03:00
Egor 30ec07f7fe Avoid loading poll responses in list endpoint 2025-10-24 09:39:39 +03:00
Egor dbf1fd8085 Merge pull request #1493 from Fr1ngg/revert-1492-yc9gsm-bedolaga/expand-bot-api-for-surveys
Revert "Document poll management endpoints in admin guide"
2025-10-24 09:36:37 +03:00
Egor 32e1f231d3 Revert "Document poll management endpoints in admin guide" 2025-10-24 09:36:28 +03:00
Egor 96b788693d Merge pull request #1492 from Fr1ngg/yc9gsm-bedolaga/expand-bot-api-for-surveys
Document poll management endpoints in admin guide
2025-10-24 09:32:52 +03:00
Egor a0e00c3735 Document poll administration API 2025-10-24 09:32:38 +03:00
Egor 51e68cf215 Merge pull request #1491 from Fr1ngg/wugnye-bedolaga/expand-bot-api-for-system-logs
Expose system log through admin API
2025-10-24 09:20:48 +03:00
Egor fa3ed3af81 Add system log endpoints to admin API 2025-10-24 09:20:34 +03:00
Egor 6e72cbb5b0 Merge pull request #1490 from Fr1ngg/u14a7y-bedolaga/expand-api-for-bot-logs-visibility
feat: expose monitoring logs through web api
2025-10-24 09:08:07 +03:00
Egor 5f316f85b3 feat: expose monitoring logs through web api 2025-10-24 09:06:08 +03:00
Egor 2abd1fd9df Merge pull request #1489 from Fr1ngg/main
w
2025-10-24 08:56:20 +03:00
Egor fcb5fedf9f Update README.md 2025-10-23 13:31:36 +03:00
Egor d35c9ad87e Merge pull request #1487 from Fr1ngg/dbunp5-bedolaga/fix-double-reward-issue-in-poll-system
Prevent double payout when rewarding poll
2025-10-23 08:21:19 +03:00
Egor 0cc0368b0b Prevent double payout when rewarding poll 2025-10-23 08:17:18 +03:00
Egor 00fb154cb9 Merge pull request #1485 from Fr1ngg/main
w
2025-10-23 08:12:14 +03:00
Egor 4441cf79ff Update Dockerfile 2025-10-23 08:02:41 +03:00
Egor 7428bd5cf7 Update docker-registry.yml 2025-10-23 08:02:29 +03:00
Egor 76c524e980 Update docker-hub.yml 2025-10-23 08:02:18 +03:00
Egor 5a9fa167c2 Merge pull request #1484 from Fr1ngg/dev4
Опросы
2025-10-23 07:52:15 +03:00
Egor c61e9c78ba Merge pull request #1483 from Fr1ngg/bcb1c4-bedolaga/fix-poll-sending-error-in-bot
Refresh poll answers after recording selections
2025-10-23 07:44:34 +03:00
Egor e5e3a9e4b5 Refresh poll answers after recording selections 2025-10-23 07:44:20 +03:00
Egor a1f47e13e3 Merge pull request #1482 from Fr1ngg/revert-1481-rnz085-bedolaga/fix-poll-sending-error-in-bot
Revert "Prevent duplicate taps when answering polls"
2025-10-23 07:43:51 +03:00
Egor 7ff0ed1248 Revert "Prevent duplicate taps when answering polls" 2025-10-23 07:43:43 +03:00
Egor 5eef24fb91 Merge pull request #1481 from Fr1ngg/rnz085-bedolaga/fix-poll-sending-error-in-bot
Prevent duplicate taps when answering polls
2025-10-23 07:40:19 +03:00
Egor 05ba26d3ac Avoid redundant poll edits and acknowledge answers 2025-10-23 07:39:57 +03:00
Egor ef68ce19ce Merge pull request #1480 from Fr1ngg/revert-1479-bqfm5n-bedolaga/fix-poll-sending-error-in-bot
Revert "Handle redundant poll message edits gracefully"
2025-10-23 07:39:30 +03:00
Egor 2fa84f6dc9 Revert "Handle redundant poll message edits gracefully" 2025-10-23 07:39:20 +03:00
Egor 32409d7471 Merge pull request #1479 from Fr1ngg/bqfm5n-bedolaga/fix-poll-sending-error-in-bot
Handle redundant poll message edits gracefully
2025-10-23 07:19:12 +03:00
Egor 270282a340 Gracefully handle poll message updates 2025-10-23 07:18:59 +03:00
Egor 87eaa06652 Merge pull request #1478 from Fr1ngg/revert-1477-9bx38j-bedolaga/fix-poll-sending-error-in-bot
Revert "Streamline poll creation prompts and skip unreachable chats"
2025-10-23 07:18:12 +03:00
Egor 845f03e873 Revert "Streamline poll creation prompts and skip unreachable chats" 2025-10-23 07:18:04 +03:00
Egor 97eabb0604 Merge pull request #1477 from Fr1ngg/9bx38j-bedolaga/fix-poll-sending-error-in-bot
Streamline poll creation prompts and skip unreachable chats
2025-10-23 07:10:27 +03:00
Egor f0d714d787 Improve poll creation prompts and poll delivery 2025-10-23 07:10:13 +03:00
Egor 27a34d6e9f Merge pull request #1476 from Fr1ngg/revert-1475-revert-1471-revert-1470-xvg3de-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Revert "Stabilize poll creation UI and delivery loop"""
2025-10-23 07:10:00 +03:00
Egor a4e97e0317 Revert "Revert "Revert "Stabilize poll creation UI and delivery loop""" 2025-10-23 07:09:51 +03:00
Egor 97dcd0b7cc Merge pull request #1475 from Fr1ngg/revert-1471-revert-1470-xvg3de-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Stabilize poll creation UI and delivery loop""
2025-10-23 06:58:40 +03:00
Egor 9b57904cf4 Revert "Revert "Stabilize poll creation UI and delivery loop"" 2025-10-23 06:58:29 +03:00
Egor 6ebdae0cae Merge pull request #1474 from Fr1ngg/revert-1469-56bjws-bedolaga/fix-poll-sending-error-in-bot
Revert "Stabilize poll creation flow and poll delivery"
2025-10-23 06:57:45 +03:00
Egor b9d4a8f8e9 Revert "Stabilize poll creation flow and poll delivery" 2025-10-23 06:57:36 +03:00
Egor 707c3c5daf Merge pull request #1469 from Fr1ngg/56bjws-bedolaga/fix-poll-sending-error-in-bot
Stabilize poll creation flow and poll delivery
2025-10-23 06:55:37 +03:00
Egor d075abfeea Merge pull request #1473 from Fr1ngg/revert-1472-1yvk15-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll creation wizard updates and poll delivery"
2025-10-23 06:55:14 +03:00
Egor d3399c569c Revert "Fix poll creation wizard updates and poll delivery" 2025-10-23 06:55:06 +03:00
Egor e36e3f7f8f Merge pull request #1472 from Fr1ngg/1yvk15-bedolaga/fix-poll-sending-error-in-bot
Fix poll creation wizard updates and poll delivery
2025-10-23 06:52:24 +03:00
Egor 583e01f5e6 Stabilize poll creation prompts and delivery 2025-10-23 06:51:48 +03:00
Egor 784b5e0af6 Merge pull request #1471 from Fr1ngg/revert-1470-xvg3de-bedolaga/fix-poll-sending-error-in-bot
Revert "Stabilize poll creation UI and delivery loop"
2025-10-23 06:51:28 +03:00
Egor cc42d33fbe Revert "Stabilize poll creation UI and delivery loop" 2025-10-23 06:51:19 +03:00
Egor 9c3d8fd701 Merge pull request #1470 from Fr1ngg/xvg3de-bedolaga/fix-poll-sending-error-in-bot
Stabilize poll creation UI and delivery loop
2025-10-23 06:47:12 +03:00
Egor d281eeb69d Stabilize poll creation UI and delivery loop 2025-10-23 06:46:46 +03:00
Egor b510ffb7d8 Stabilize poll creation UI and sending 2025-10-23 06:46:11 +03:00
Egor 6855b7765c Merge pull request #1468 from Fr1ngg/revert-1467-revert-1462-revert-1461-ahggij-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Revert "Fix poll sending flow and tidy poll creation prompts"""
2025-10-23 06:45:44 +03:00
Egor 9ed1129268 Revert "Revert "Revert "Fix poll sending flow and tidy poll creation prompts""" 2025-10-23 06:45:35 +03:00
Egor 0c4ba3c0d0 Merge pull request #1467 from Fr1ngg/revert-1462-revert-1461-ahggij-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Fix poll sending flow and tidy poll creation prompts""
2025-10-23 06:32:55 +03:00
Egor 70337ac310 Revert "Revert "Fix poll sending flow and tidy poll creation prompts"" 2025-10-23 06:32:40 +03:00
Egor e981082713 Merge pull request #1466 from Fr1ngg/revert-1465-bh9v34-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll delivery session usage and tidy poll creation prompts"
2025-10-23 06:32:01 +03:00
Egor 104d98542e Revert "Fix poll delivery session usage and tidy poll creation prompts" 2025-10-23 06:31:20 +03:00
Egor fe63ef5fc5 Merge pull request #1465 from Fr1ngg/bh9v34-bedolaga/fix-poll-sending-error-in-bot
Fix poll delivery session usage and tidy poll creation prompts
2025-10-23 06:30:12 +03:00
Egor b1ff964dd6 Fix poll sending session access and clean poll creation chat 2025-10-23 06:29:59 +03:00
Egor aba1d5dbe5 Merge pull request #1464 from Fr1ngg/revert-1463-hg0g0v-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll sending DB session usage and clean poll creation chat"
2025-10-23 06:29:35 +03:00
Egor 0af7e4ac68 Revert "Fix poll sending DB session usage and clean poll creation chat" 2025-10-23 06:29:23 +03:00
Egor 7d9a572e6e Merge pull request #1463 from Fr1ngg/hg0g0v-bedolaga/fix-poll-sending-error-in-bot
Fix poll sending DB session usage and clean poll creation chat
2025-10-23 06:27:12 +03:00
Egor de6557d7e7 Fix poll sending session usage and clean creation prompts 2025-10-23 06:26:59 +03:00
Egor a052c7bb80 Merge pull request #1462 from Fr1ngg/revert-1461-ahggij-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll sending flow and tidy poll creation prompts"
2025-10-23 06:26:13 +03:00
Egor 8c47f3c49b Revert "Fix poll sending flow and tidy poll creation prompts" 2025-10-23 06:26:04 +03:00
Egor 323ad903e7 Merge pull request #1461 from Fr1ngg/ahggij-bedolaga/fix-poll-sending-error-in-bot
Fix poll sending flow and tidy poll creation prompts
2025-10-23 06:22:46 +03:00
Egor 8105fe285d Fix poll sending flow and tidy poll creation prompts 2025-10-23 06:22:31 +03:00
Egor 82a19025f1 Merge pull request #1460 from Fr1ngg/revert-1455-revert-1454-k4r4kv-bedolaga/add-survey-functionality-to-bot
Revert "Revert "Add poll management and delivery system""
2025-10-23 06:03:49 +03:00
Egor e592b3e5c4 Revert "Revert "Add poll management and delivery system"" 2025-10-23 06:03:41 +03:00
Egor 2da12354a1 Merge pull request #1459 from Fr1ngg/revert-1458-itqeed-bedolaga/add-survey-functionality-to-bot
Revert "Fix aiogram Bot usage in poll handlers"
2025-10-23 06:03:17 +03:00
Egor fc65b62d65 Revert "Fix aiogram Bot usage in poll handlers" 2025-10-23 06:03:09 +03:00
Egor 28603a87e1 Merge pull request #1458 from Fr1ngg/itqeed-bedolaga/add-survey-functionality-to-bot
Fix aiogram Bot usage in poll handlers
2025-10-23 05:56:02 +03:00
Egor 8956aafc9a Fix poll handlers Bot type annotations 2025-10-23 05:55:48 +03:00
Egor 2ee9b263b8 Merge pull request #1457 from Fr1ngg/revert-1456-9v7qqh-bedolaga/add-survey-functionality-to-bot
Revert "Add admin-managed polls with user rewards"
2025-10-23 05:55:15 +03:00
Egor 3a5d838be3 Revert "Add admin-managed polls with user rewards" 2025-10-23 05:55:06 +03:00
Egor bf25fcfda6 Merge pull request #1456 from Fr1ngg/9v7qqh-bedolaga/add-survey-functionality-to-bot
Add admin-managed polls with user rewards
2025-10-23 05:38:12 +03:00
Egor ecdf2fcae4 feat: add admin polls with rewards and delivery 2025-10-23 05:38:01 +03:00
Egor 6fec14de4b Merge pull request #1455 from Fr1ngg/revert-1454-k4r4kv-bedolaga/add-survey-functionality-to-bot
Revert "Add poll management and delivery system"
2025-10-23 05:37:48 +03:00
Egor 7b4cbb27b7 Revert "Add poll management and delivery system" 2025-10-23 05:37:40 +03:00
Egor d7d7795257 Merge pull request #1454 from Fr1ngg/k4r4kv-bedolaga/add-survey-functionality-to-bot
Add poll management and delivery system
2025-10-23 05:28:28 +03:00
Egor 93554c7034 Add poll management and delivery system 2025-10-23 05:28:13 +03:00
Egor fbe7febdde Merge pull request #1453 from Fr1ngg/g16e9c-bedolaga/check-referral-income-on-balance-top-up
Ensure MulenPay top-ups trigger referral rewards
2025-10-23 05:05:31 +03:00
Egor f091b3ea5e Add referral rewards for MulenPay top-ups 2025-10-23 05:05:16 +03:00
Egor aae469853e Merge pull request #1452 from Fr1ngg/main
ц
2025-10-23 04:55:09 +03:00
Egor dac68ccd8c Merge pull request #1451 from Gy9vin/main
ФИКС ПО ЮКАССЕ
2025-10-23 03:13:21 +03:00
gy9vin fb140e82f6 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-22 13:59:24 +03:00
gy9vin 8cac442de1 ФИКС ПРОСТОЙ ПОКУПКИ 2025-10-22 13:59:20 +03:00
Egor 8d8a465c43 Merge pull request #1450 from Gy9vin/main
фикс простой покупки кнопка всегда отображается
2025-10-22 10:01:50 +03:00
gy9vin 51f2c660e8 фикс простой покупки кнопка все отображается 2025-10-22 09:35:22 +03:00
Egor 39ecca306d Merge pull request #1449 from Fr1ngg/dev4
Dev4
2025-10-22 03:06:09 +03:00
Egor 7896a24e13 Merge pull request #1448 from Fr1ngg/beb27u-bedolaga/fix-quick-purchase-price-calculation
Fix simple subscription price calculations
2025-10-22 02:36:40 +03:00
Egor c21325f260 Skip unlimited traffic surcharge for simple plans 2025-10-22 02:36:23 +03:00
Egor 18df7395a2 Merge pull request #1447 from Fr1ngg/revert-1446-qg2f9r-bedolaga/fix-quick-purchase-price-calculation
Revert "Fix simple subscription price calculations"
2025-10-22 02:36:12 +03:00
Egor f139251333 Revert "Fix simple subscription price calculations" 2025-10-22 02:36:04 +03:00
Egor 369e384b7d Merge pull request #1446 from Fr1ngg/qg2f9r-bedolaga/fix-quick-purchase-price-calculation
Fix simple subscription price calculations
2025-10-22 02:23:05 +03:00
Egor 008f62e02b Add async test runner shim and fix Pal24 adapter test 2025-10-22 02:22:50 +03:00
Egor 6e8981a245 Merge pull request #1445 from Fr1ngg/revert-1444-z1xs4t-bedolaga/fix-quick-purchase-price-calculation
Revert "Fix simple subscription price calculations"
2025-10-22 02:22:41 +03:00
Egor bb17cf7361 Revert "Fix simple subscription price calculations" 2025-10-22 02:22:33 +03:00
Egor d13c6767e6 Merge pull request #1444 from Fr1ngg/z1xs4t-bedolaga/fix-quick-purchase-price-calculation
Fix simple subscription price calculations
2025-10-22 02:12:40 +03:00
Egor bf2ee37f33 Fix simple subscription pricing to include squad costs 2025-10-22 02:12:23 +03:00
Egor b82929ac3d Merge pull request #1443 from Fr1ngg/dev4
Dev4
2025-10-21 20:23:34 +03:00
Egor 9b0ea9bf2a Merge pull request #1442 from Fr1ngg/0opqnu-bedolaga/fix-balance-update-issue-with-heleket
Improve Heleket payment status synchronisation
2025-10-21 20:05:17 +03:00
Egor 5bde7196dc Improve Heleket payment status synchronisation 2025-10-21 20:04:59 +03:00
Egor 71c3e7fe0d Merge pull request #1441 from Fr1ngg/dev4
Dev4
2025-10-21 19:29:43 +03:00
Egor 201543c263 Merge pull request #1440 from Fr1ngg/revert-1439-revert-1437-tnkwdf-bedolaga/fix-webhook-not-receiving-notifications
Revert "Revert "Fix YooKassa webhook paid flag persistence""
2025-10-21 19:28:16 +03:00
Egor 5ce7417c80 Revert "Revert "Fix YooKassa webhook paid flag persistence"" 2025-10-21 19:28:01 +03:00
Egor e98564b5ab Merge pull request #1438 from Fr1ngg/main
ww
2025-10-21 19:27:13 +03:00
Egor 37dd48cc20 Merge pull request #1439 from Fr1ngg/revert-1437-tnkwdf-bedolaga/fix-webhook-not-receiving-notifications
Revert "Fix YooKassa webhook paid flag persistence"
2025-10-21 19:26:28 +03:00
Egor 9dff6e8045 Revert "Fix YooKassa webhook paid flag persistence" 2025-10-21 19:26:18 +03:00
Egor 572094050e Merge pull request #1434 from Gy9vin/main
Доработки
2025-10-21 19:25:27 +03:00
Egor 2b41337bbf Merge pull request #1437 from Fr1ngg/tnkwdf-bedolaga/fix-webhook-not-receiving-notifications
Fix YooKassa webhook paid flag persistence
2025-10-21 19:20:17 +03:00
Egor ca94c402bd Fix YooKassa webhook paid flag 2025-10-21 19:19:50 +03:00
gy9vin 6fdc46c3c6 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-21 15:24:38 +03:00
gy9vin 34fc568f97 простую покупку можно сделать с активированной подпиской добавит n дней к текущему количеству 2025-10-21 15:24:35 +03:00
Egor 38664ff853 Merge pull request #1433 from Fr1ngg/dev4
Dev4
2025-10-21 12:01:56 +03:00
Egor e44284d4ba Merge pull request #1432 from Fr1ngg/main
w
2025-10-21 12:01:10 +03:00
Egor d54dc822cc Merge pull request #1431 from Fr1ngg/uum2gm-bedolaga/fix-unresolved-resolved_squad_uuid-error
Ensure resolved squad uuid is set for simple subscription payments
2025-10-21 11:59:59 +03:00
Egor dbe9c2d5e9 Ensure resolved squad is set for simple subscription payments 2025-10-21 11:57:41 +03:00
Egor b036d090c6 Update Dockerfile 2025-10-21 11:31:44 +03:00
Egor d44b303dd3 Update docker-registry.yml 2025-10-21 11:31:34 +03:00
Egor e69bfdac20 Update docker-hub.yml 2025-10-21 11:31:20 +03:00
Egor 53f6c4e71b Merge pull request #1429 from Fr1ngg/dev4
Heleket
2025-10-21 11:22:48 +03:00
Egor c67223a332 Merge pull request #1428 from Fr1ngg/7mhcv8-bedolaga/move-heleket-to-payment-systems
Move Heleket settings into payment systems group
2025-10-21 11:21:57 +03:00
Egor 2c253382d5 Move Heleket settings into payment systems group 2025-10-21 11:21:37 +03:00
Egor 02b65cb1c9 Merge pull request #1427 from Fr1ngg/kbxpuv-bedolaga/add-heleket-balance-top-up-method
Polish Heleket payment integration
2025-10-21 11:13:09 +03:00
Egor 49ace78a68 Polish Heleket payment integration 2025-10-21 11:12:47 +03:00
Egor 0669086742 Merge pull request #1426 from Fr1ngg/k9cky4-bedolaga/add-balance-refill-method-for-heleket
Add Heleket payment provider integration
2025-10-21 10:59:36 +03:00
Egor 2f2d9bc1af Add Heleket payment provider integration 2025-10-21 10:59:19 +03:00
Egor 9b2b11ee44 Merge pull request #1425 from Fr1ngg/1n7xv6-bedolaga/update-simple-subscription-logic
Fix simple subscription start state data usage
2025-10-21 10:09:57 +03:00
Egor d318738596 Fix state data lookup during simple subscription start 2025-10-21 10:09:43 +03:00
Egor 6ae41369e9 Merge pull request #1424 from Fr1ngg/revert-1423-v20afv-bedolaga/update-simple-subscription-logic
Revert "Fix simple subscription button visibility and random squad assignment"
2025-10-21 10:09:19 +03:00
Egor ce9f2e0d41 Revert "Fix simple subscription button visibility and random squad assignment" 2025-10-21 10:09:08 +03:00
Egor aa7bd42375 Merge pull request #1423 from Fr1ngg/v20afv-bedolaga/update-simple-subscription-logic
Fix simple subscription button visibility and random squad assignment
2025-10-21 10:01:17 +03:00
Egor a6b19e4b95 Adjust simple subscription menu and squad assignment 2025-10-21 10:01:01 +03:00
Egor 7a21518ce2 Merge pull request #1422 from Fr1ngg/dev4
Dev4
2025-10-21 09:45:23 +03:00
Egor ba92f7da6c Merge pull request #1421 from Fr1ngg/52n798-bedolaga/escape-referrer-names-in-html-report
Escape referrer names in HTML report
2025-10-21 09:36:43 +03:00
Egor f62f37581f Escape referrer names in HTML report 2025-10-21 09:36:30 +03:00
Egor 0d08c2c7f0 Merge pull request #1420 from Fr1ngg/8jof80-bedolaga/fix-syntaxerror-in-purchase.py
Fix syntax issues in subscription purchase handler
2025-10-21 09:31:32 +03:00
Egor 078e59f09c Fix syntax issues in subscription purchase handler 2025-10-21 09:31:18 +03:00
Egor ab708a78a8 Merge pull request #1419 from Fr1ngg/mjma6t-bedolaga/integrate-reporting-service-code
feat: enhance reporting statistics
2025-10-21 09:26:09 +03:00
Egor 630688ecf5 feat: enhance reporting statistics 2025-10-21 09:25:53 +03:00
Egor 2b6901e87a Merge pull request #1418 from Fr1ngg/xfpbps-bedolaga/fix-button-in-subscription-renewal-section
Fix autopay day button label
2025-10-21 09:10:41 +03:00
Egor 0e43daf48b Fix autopay day button label 2025-10-21 09:10:26 +03:00
Egor e9badff1d8 Merge pull request #1417 from Fr1ngg/x4oldj-bedolaga/fix-subscription_config_back-button-behavior
Fix back navigation during subscription confirmation
2025-10-21 09:06:03 +03:00
Egor f588191931 Fix subscription back navigation restoring configuration 2025-10-21 09:05:44 +03:00
Egor e17dfbff35 Merge pull request #1415 from Fr1ngg/uq97y3-bedolaga/fix-back-button-issue-in-subscription-purchase
Handle back navigation after simple subscription QR message
2025-10-21 09:00:24 +03:00
Egor b7e9770588 Handle back navigation after simple subscription QR 2025-10-21 09:00:08 +03:00
Egor 9371524257 Merge pull request #1414 from Fr1ngg/main
w
2025-10-21 07:42:49 +03:00
Egor c1f8fe69cf Update requirements.txt 2025-10-21 00:04:51 +03:00
Egor 87e3449444 Update Dockerfile 2025-10-21 00:04:31 +03:00
Egor e8ef1bc255 Update requirements.txt 2025-10-21 00:00:50 +03:00
Egor fe3a593da0 Update requirements.txt 2025-10-20 23:58:29 +03:00
Egor bf96d85edb Update requirements.txt 2025-10-20 23:56:30 +03:00
Egor 700d718da6 Update Dockerfile 2025-10-20 23:52:48 +03:00
Egor 0c9ad3cec8 Merge pull request #1413 from Fr1ngg/revert-1295-dependabot/pip/python-dependencies-e109b03380
Revert "deps(deps): bump the python-dependencies group across 1 directory with 10 updates"
2025-10-20 23:48:46 +03:00
Egor 73d789d3f7 Revert "deps(deps): bump the python-dependencies group across 1 directory with 10 updates" 2025-10-20 23:48:35 +03:00
Egor 0a09ae4290 Update requirements.txt 2025-10-20 23:44:04 +03:00
Egor 0758988b5f Update requirements.txt 2025-10-20 23:42:11 +03:00
Egor 089f668e14 Merge pull request #1295 from Fr1ngg/dependabot/pip/python-dependencies-e109b03380
deps(deps): bump the python-dependencies group across 1 directory with 10 updates
2025-10-20 23:37:14 +03:00
dependabot[bot] 1e5bf143c4 deps(deps): bump the python-dependencies group across 1 directory with 10 updates
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: sqlalchemy
  dependency-version: 2.0.44
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: alembic
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic
  dependency-version: 2.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic-settings
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pyyaml
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: fastapi
  dependency-version: 0.119.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: uvicorn
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: yookassa
  dependency-version: 3.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: flask
  dependency-version: 3.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-20 07:03:13 +00:00
Egor d1132c82f8 Merge pull request #1309 from Fr1ngg/dependabot/docker/python-3.14-slim
docker(deps): bump python from 3.13-slim to 3.14-slim
2025-10-20 00:40:11 +03:00
Egor 62545276e4 Merge pull request #1296 from Fr1ngg/dependabot/pip/aiofiles-25.1.0
deps(deps): bump aiofiles from 23.2.1 to 25.1.0
2025-10-20 00:40:01 +03:00
Egor 2878bc3c3d Merge pull request #1412 from Gy9vin/main
Локализация и перенос кнопки простая покупка в главное меню на постоя…
2025-10-20 00:38:41 +03:00
gy9vin eed0fe39e7 Фикс расположения кнопок в главном меню 2025-10-19 16:23:40 +03:00
Mikhail 6bbee5586f Merge branch 'Fr1ngg:main' into main 2025-10-19 16:09:12 +03:00
gy9vin 7ac2354197 Локализация и перенос кнопки простая покупка в главное меню на постоянку! 2025-10-19 16:08:23 +03:00
Egor 3463f08162 Merge pull request #1411 from Gy9vin/main
Фимк мули в простой подписке
2025-10-19 16:01:14 +03:00
gy9vin 4b0aaa02c7 Фимк мули в простой подписке 2025-10-19 15:54:01 +03:00
Egor a946f7b501 Merge pull request #1410 from Gy9vin/main
Фикс простой покупки
2025-10-19 15:53:44 +03:00
gy9vin 7f2e4615ee Фикс простой покупки 2025-10-19 15:52:10 +03:00
Egor 28ab7719f5 Merge pull request #1409 from Gy9vin/main
Добовление всех платежек у простую покупку
2025-10-19 15:37:01 +03:00
gy9vin 31b5101f64 Исправление локализации 2025-10-19 15:23:09 +03:00
gy9vin 62777f8995 Возможность настройки простой покупки 2025-10-19 15:10:22 +03:00
gy9vin f9cff3e169 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-19 13:38:28 +03:00
gy9vin 04c255d748 Добовление оставшихся платежек в простую подписку 2025-10-19 13:33:56 +03:00
dependabot[bot] aae771764e docker(deps): bump python from 3.13-slim to 3.14-slim
Bumps python from 3.13-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-18 23:42:42 +00:00
dependabot[bot] 7be8757964 deps(deps): bump aiofiles from 23.2.1 to 25.1.0
Bumps [aiofiles](https://github.com/Tinche/aiofiles) from 23.2.1 to 25.1.0.
- [Release notes](https://github.com/Tinche/aiofiles/releases)
- [Changelog](https://github.com/Tinche/aiofiles/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Tinche/aiofiles/compare/v23.2.1...v25.1.0)

---
updated-dependencies:
- dependency-name: aiofiles
  dependency-version: 25.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-13 07:22:49 +00:00
546 changed files with 182788 additions and 65929 deletions
+522 -14
View File
@@ -3,26 +3,125 @@
# ===============================================
# ===== TELEGRAM BOT =====
# Токен бота от @BotFather
# ВАЖНО: Также используется для авторизации виджета личного кабинета (Cabinet WebApp)
# через Telegram.WebApp.initData
BOT_TOKEN=
ADMIN_IDS=
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
SUPPORT_USERNAME=@support
# Имя пользователя бота (опционально, автоопределяется)
# BOT_USERNAME=
# ===== СИСТЕМА ПОДДЕРЖКИ =====
# Включить меню поддержки в интерфейсе
SUPPORT_MENU_ENABLED=true
# Режим системы поддержки: tickets (тикеты), contact (контакт), both (оба)
SUPPORT_SYSTEM_MODE=both
# SLA для тикетов поддержки
SUPPORT_TICKET_SLA_ENABLED=false
SUPPORT_TICKET_SLA_MINUTES=60
SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS=300
SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES=30
# ===== ЛИЧНЫЙ КАБИНЕТ (CABINET) =====
# Включить личный кабинет пользователя (веб-интерфейс для управления подпиской)
CABINET_ENABLED=false
# URL кабинета для ссылок в email (например: https://cabinet.example.com)
CABINET_URL=
# Секретный ключ для JWT токенов (если не указан, используется BOT_TOKEN)
CABINET_JWT_SECRET=
# Время жизни access token в минутах (по умолчанию 15)
CABINET_ACCESS_TOKEN_EXPIRE_MINUTES=15
# Время жизни refresh token в днях (по умолчанию 7)
CABINET_REFRESH_TOKEN_EXPIRE_DAYS=7
# Разрешенные origins для CORS (через запятую, например: https://cabinet.example.com)
CABINET_ALLOWED_ORIGINS=
# Включить верификацию email (требует настройки SMTP)
CABINET_EMAIL_VERIFICATION_ENABLED=false
# Включить регистрацию/вход по email (если false - только Telegram)
CABINET_EMAIL_AUTH_ENABLED=true
# ===== ТЕСТОВЫЙ EMAIL ДЛЯ РАЗРАБОТКИ =====
# Тестовый email для проверки регистрации без SMTP
# При использовании этого email верификация пропускается
TEST_EMAIL=
TEST_EMAIL_PASSWORD=
# Время жизни токена верификации email в часах
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS=24
# Время жизни токена сброса пароля в часах
CABINET_PASSWORD_RESET_EXPIRE_HOURS=1
# Время жизни кода подтверждения смены email в минутах
CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES=15
# ===== SMTP НАСТРОЙКИ (для email в личном кабинете) =====
# SMTP сервер (например: smtp.gmail.com, smtp.yandex.ru)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
# Email отправителя (если не указан, используется SMTP_USER)
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=VPN Service
# Использовать TLS шифрование
SMTP_USE_TLS=true
# Уведомления администраторов
ADMIN_NOTIFICATIONS_ENABLED=true
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID=133 # Опционально: ID топика для уведомлений о чеках NaloGO
# Автоматические отчеты
ADMIN_REPORTS_ENABLED=false
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
# ===== МОНИТОРИНГ ТРАФИКА =====
# Логика: при запуске бота создаётся snapshot трафика всех пользователей.
# Через указанный интервал проверяется дельта (разница) трафика.
# Если дельта превышает порог — отправляется уведомление админам.
# Быстрая проверка (дельта трафика за интервал)
TRAFFIC_FAST_CHECK_ENABLED=false # Включить быструю проверку
TRAFFIC_FAST_CHECK_INTERVAL_MINUTES=10 # Интервал проверки в минутах
TRAFFIC_FAST_CHECK_THRESHOLD_GB=5.0 # Порог дельты в ГБ (сколько потрачено за интервал)
# Суточная проверка (трафик за 24 часа через bandwidth API)
TRAFFIC_DAILY_CHECK_ENABLED=false # Включить суточную проверку
TRAFFIC_DAILY_CHECK_TIME=00:00 # Время суточной проверки (HH:MM по UTC)
TRAFFIC_DAILY_THRESHOLD_GB=50.0 # Порог суточного трафика в ГБ
# Куда отправлять уведомления
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для уведомлений о подозрительной активности
# Фильтрация по серверам (UUID нод через запятую)
TRAFFIC_MONITORED_NODES= # Только эти ноды (пусто = все)
TRAFFIC_IGNORED_NODES= # Исключить эти ноды
# Исключить пользователей (UUID через запятую)
TRAFFIC_EXCLUDED_USER_UUIDS= # Служебные/тунельные пользователи
# Производительность
TRAFFIC_CHECK_BATCH_SIZE=1000 # Размер батча для получения пользователей
TRAFFIC_CHECK_CONCURRENCY=10 # Параллельных запросов к API
TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES=60 # Кулдаун уведомлений на пользователя (минуты)
TRAFFIC_SNAPSHOT_TTL_HOURS=24 # TTL snapshot трафика в Redis (часы, сохраняется при рестарте)
# Черный список
BLACKLIST_CHECK_ENABLED=false # Включить проверку пользователей по черному списку
BLACKLIST_GITHUB_URL=https://raw.githubusercontent.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/refs/heads/main/blacklist.txt # URL к файлу черного списка на GitHub
BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновления черного списка с GitHub (в часах)
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
# Обязательная подписка на канал
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
# ===== DATABASE CONFIGURATION =====
# Режим базы данных: "auto", "postgresql", "sqlite"
@@ -44,13 +143,16 @@ LOCALES_PATH=./locales
# Redis
REDIS_URL=redis://redis:6379/0
# Время жизни корзины пользователя в Redis (секунды, по умолчанию 1 час)
CART_TTL_SECONDS=3600
# ===== REMNAWAVE API =====
REMNAWAVE_API_URL=https://panel.example.com
REMNAWAVE_API_KEY=your_api_key_here
# Тип авторизации: "api_key", "basic_auth"
# Тип авторизации: "api_key", "basic_auth", "caddy"
REMNAWAVE_AUTH_TYPE=api_key
REMNAWAVE_CADDY_TOKEN=
# Для панелей с Basic Auth (опционально)
REMNAWAVE_USERNAME=
@@ -67,23 +169,100 @@ REMNAWAVE_SECRET_KEY=
# {telegram_id} — ID Telegram
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
# Шаблон имени пользователя в панели Remnawave
# Доступные плейсхолдеры аналогичны описанию выше
# {full_name} — Имя, Фамилия из Telegram
# {username} — @логин из Telegram (c @)
# {username_clean} — логин из Telegram (без @)
# {telegram_id} — ID Telegram
REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
# Режим удаления пользователей из панели RemnaWave
# delete - полностью удалить пользователя из панели
# disable - только деактивировать пользователя
REMNAWAVE_USER_DELETE_MODE=delete
# Автоматическая синхронизация пользователей с панелью Remnawave
REMNAWAVE_AUTO_SYNC_ENABLED=false
# Времена синхронизации (через запятую, формат HH:MM по МСК)
REMNAWAVE_AUTO_SYNC_TIMES=03:00
# ===== REMNAWAVE WEBHOOKS (входящие события из панели) =====
# Включить приём вебхуков от панели Remnawave (real-time события)
REMNAWAVE_WEBHOOK_ENABLED=false
# Путь для приёма вебхуков (должен совпадать с настройкой в панели)
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
# Общий секрет для подписи HMAC-SHA256 (минимум 32 символа)
# Сгенерируйте: openssl rand -hex 32
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
REMNAWAVE_WEBHOOK_SECRET=
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
# Глобальный переключатель уведомлений пользователям от вебхуков
WEBHOOK_NOTIFY_USER_ENABLED=true
# Отключение/активация подписки администратором
WEBHOOK_NOTIFY_SUB_STATUS=true
# Истечение подписки
WEBHOOK_NOTIFY_SUB_EXPIRED=true
# Предупреждения о скором истечении (72ч, 48ч, 24ч)
WEBHOOK_NOTIFY_SUB_EXPIRING=true
# Достижение лимита трафика
WEBHOOK_NOTIFY_SUB_LIMITED=true
# Сброс счётчика трафика
WEBHOOK_NOTIFY_TRAFFIC_RESET=true
# Удаление пользователя из панели
WEBHOOK_NOTIFY_SUB_DELETED=true
# Обновление ключей подписки (revoke)
WEBHOOK_NOTIFY_SUB_REVOKED=true
# Первое подключение к VPN
WEBHOOK_NOTIFY_FIRST_CONNECTED=true
# Напоминание о неподключении
WEBHOOK_NOTIFY_NOT_CONNECTED=true
# Предупреждение о приближении к лимиту трафика
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD=true
# Подключение и отключение устройств
WEBHOOK_NOTIFY_DEVICES=true
# Теги пользователей в Remnawave (A-Z, 0-9, _, макс. 16 символов)
# Тег для пробных пользователей (опционально)
# TRIAL_USER_TAG=TRIAL
# Тег для платных пользователей (опционально)
# PAID_SUBSCRIPTION_USER_TAG=PAID
# ========= ПОДПИСКИ =========
# ===== РЕЖИМ ПРОДАЖ =====
# Режим продаж подписок (можно переключить в кабинете: Настройки → Подписки):
# "classic" - классический режим:
# - Пользователь выбирает период, серверы, трафик, устройства отдельно
# - Цены периодов берутся из PERIOD_PRICES ниже
# - Подходит для гибкой настройки под каждого пользователя
# "tariffs" - режим тарифов:
# - Пользователь выбирает готовый тариф (Premium, Basic и т.д.)
# - Тарифы создаются в кабинете: Админ → Тарифы
# - Каждый тариф имеет свои серверы, трафик, устройства и цены за периоды
# - Подходит для продажи готовых пакетов услуг
SALES_MODE=tariffs
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
TRIAL_SQUAD_UUID=
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
# Длительность триала всё равно берётся из TRIAL_DURATION_DAYS
TRIAL_TARIFF_ID=0
# Платный триал: если TRIAL_ACTIVATION_PRICE > 0, триал становится платным
# Цена в копейках (1000 = 10 рублей). Пользователь может оплатить триал любым методом оплаты.
# TRIAL_PAYMENT_ENABLED опционален (для обратной совместимости)
TRIAL_PAYMENT_ENABLED=false
TRIAL_ACTIVATION_PRICE=0
# ===== ПЛАТНАЯ ПОДПИСКА =====
# Сколько устройств доступно по дефолту при покупке платной подписки
DEFAULT_DEVICE_LIMIT=3
# Максимум устройств достопных к покупке (0 = Нет лимита)
# Максимум устройств доступных к покупке (0 = Нет лимита)
MAX_DEVICES_LIMIT=15
# Дефолт параметры для подписок выданных через админку
@@ -107,15 +286,36 @@ TRAFFIC_SELECTION_MODE=selectable
# 0 = безлимит
FIXED_TRAFFIC_LIMIT_GB=100
# ===== ДОКУПКА ТРАФИКА =====
# Включить/выключить функцию докупки трафика к существующей подписке
TRAFFIC_TOPUP_ENABLED=true
# Показывать кнопку "Докупить трафик" в меню
BUY_TRAFFIC_BUTTON_VISIBLE=true
# Пакеты для докупки трафика (формат: "гб:цена_в_копейках:enabled")
# Пустая строка = использовать TRAFFIC_PACKAGES_CONFIG
# Пример: "10:5000:true,25:10000:true,50:15000:true,100:25000:true"
TRAFFIC_TOPUP_PACKAGES_CONFIG=
# ===== СБРОС ТРАФИКА =====
# Режим расчета цены сброса трафика:
# "period" - фиксированная цена = стоимость периода 30 дней (старое поведение, может быть абьюзом!)
# "traffic" - цена = стоимость текущего пакета трафика подписки
# "traffic_with_purchased" - цена = стоимость базового + докупленного трафика (рекомендуется)
TRAFFIC_RESET_PRICE_MODE=traffic_with_purchased
# Базовая цена сброса в копейках (0 = использовать PERIOD_PRICES[30])
# Используется как минимальная цена или фиксированная в режиме "period"
TRAFFIC_RESET_BASE_PRICE=0
# ===== ПЕРИОДЫ ПОДПИСКИ =====
# Доступные периоды подписки (через запятую)
# Возможные значения: 14,30,60,90,180,360
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
AVAILABLE_RENEWAL_PERIODS=30,90,180
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
# Включить упрощённую покупку из меню
SIMPLE_SUBSCRIPTION_ENABLED=false
# ===== ПРОСТАЯ ПОКУПКА ПОДПИСКИ =====
SIMPLE_SUBSCRIPTION_ENABLED=true
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
# Сколько устройств выдаётся в рамках простой подписки
@@ -143,9 +343,27 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:0:true"
# Цена за безлимитный трафик (в копейках)
PRICE_TRAFFIC_UNLIMITED=20000
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
PRICE_PER_DEVICE=10000
# Включить выбор количества устройств при покупке и продлении
DEVICES_SELECTION_ENABLED=true
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
DEVICES_SELECTION_DISABLED_AMOUNT=0
# ===== МОДЕМ =====
# Включить функционал подключения модема
MODEM_ENABLED=false
# Цена модема в копейках за месяц (добавляется к ежемесячному платежу)
MODEM_PRICE_PER_MONTH=10000
# Скидки на модем за длительный срок: "месяцев:процент,месяцев:процент"
# Пример: 3 мес = 15%, 6 мес = 20%, 12 мес = 25%
MODEM_PERIOD_DISCOUNTS=3:15,6:20,12:25
# Отключение превью ссылок в сообщениях бота
DISABLE_WEB_PAGE_PREVIEW=false
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true
@@ -154,12 +372,39 @@ REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# Уведомления
REFERRAL_NOTIFICATIONS_ENABLED=true
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
# ===== ВЫВОД РЕФЕРАЛЬНОГО БАЛАНСА =====
# Включить функцию вывода реферального баланса
REFERRAL_WITHDRAWAL_ENABLED=false
# Минимальная сумма вывода в копейках (по умолчанию 50000 = 500₽)
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS=50000
# Интервал между запросами на вывод (дни)
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS=30
# Выводить только реферальный баланс (true) или весь баланс (false)
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE=true
# ID топика для уведомлений о заявках на вывод (0 = основной чат)
REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID=0
# Тестовый режим (позволяет админам тестировать функционал)
REFERRAL_WITHDRAWAL_TEST_MODE=false
# Настройки анализа на подозрительную активность
# Минимальная сумма депозита от реферала для анализа (в копейках)
REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS=100000
# Максимум пополнений от одного реферала в месяц
REFERRAL_WITHDRAWAL_SUSPICIOUS_MAX_DEPOSITS_PER_MONTH=10
# Коэффициент подозрительности (пополнено в X раз больше, чем потрачено)
REFERRAL_WITHDRAWAL_SUSPICIOUS_NO_PURCHASES_RATIO=3
# ===== АВТОПРОДЛЕНИЕ =====
# Глобально включить/выключить функцию автопродления (false = функция скрыта)
ENABLE_AUTOPAY=false
# Дни до окончания подписки, когда отправлять предупреждение (через запятую)
AUTOPAY_WARNING_DAYS=3,1
# Включить автопродление для новых пользователей по умолчанию
DEFAULT_AUTOPAY_ENABLED=true
DEFAULT_AUTOPAY_DAYS_BEFORE=3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
@@ -167,8 +412,8 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
# Telegram Stars (работает автоматически)
TELEGRAM_STARS_ENABLED=true
TELEGRAM_STARS_RATE_RUB=1.3
TELEGRAM_STARS_ENABLED=false
TELEGRAM_STARS_RATE_RUB=1.79
# Tribute (https://tribute.app)
TRIBUTE_ENABLED=false
@@ -176,6 +421,8 @@ TRIBUTE_API_KEY=
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_HOST=0.0.0.0
# Примечание: индивидуальные *_WEBHOOK_PORT используются только для старой схемы запуска.
# В режиме единого FastAPI сервера оставьте эти значения по умолчанию.
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
@@ -197,6 +444,12 @@ YOOKASSA_VAT_CODE=1
# 4 - НДС 20%
# 5 - НДС 10/110
# 6 - НДС 20/120
# 7 - НДС 5%
# 8 - НДС 7%
# 9 - НДС 5/105
# 10 - НДС 7/107
# 11 - НДС 22%
# 12 - НДС 22/122
YOOKASSA_PAYMENT_MODE=full_payment
# Способы расчета:
@@ -228,7 +481,8 @@ YOOKASSA_PAYMENT_SUBJECT=service
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
# Доверенные сети для webhook (IP-адреса YooKassa, через запятую)
# YOOKASSA_TRUSTED_PROXY_NETWORKS=185.71.76.0/24,185.71.77.0/24
# Лимиты сумм пополнения через YooKassa (в копейках)
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
@@ -239,9 +493,27 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# Отключить пополнение баланса через поддержку
SUPPORT_TOPUP_ENABLED=true
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
# Интервал (в минутах) между автоматическими проверками пополнений
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
# ===== НАЛОГОВАЯ СЛУЖБА (NaloGO) =====
# Автоматическая отправка чеков в налоговую при пополнении баланса
NALOGO_ENABLED=false
NALOGO_INN= # ИНН самозанятого
NALOGO_PASSWORD= # Пароль от личного кабинета налоговой
NALOGO_DEVICE_ID= # Опционально: ID устройства для авторизации
NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токенами
NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды)
NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды)
NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
PAYMENT_SERVICE_NAME=Интернет-сервис
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
@@ -256,11 +528,29 @@ CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
CRYPTOBOT_TESTNET=false
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
# Эти порты используются только при ручном запуске старого aiohttp сервера.
# В новой конфигурации все вебхуки обрабатываются через порт 8080.
CRYPTOBOT_WEBHOOK_PORT=8081
CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# HELEKET
HELEKET_ENABLED=false
HELEKET_MERCHANT_ID=
HELEKET_API_KEY=
HELEKET_BASE_URL=https://api.heleket.com/v1
HELEKET_DEFAULT_CURRENCY=USDT
HELEKET_DEFAULT_NETWORK=
HELEKET_INVOICE_LIFETIME=3600
HELEKET_MARKUP_PERCENT=0
HELEKET_WEBHOOK_PATH=/heleket-webhook
HELEKET_WEBHOOK_HOST=0.0.0.0
HELEKET_WEBHOOK_PORT=8086
HELEKET_CALLBACK_URL=
HELEKET_RETURN_URL=
HELEKET_SUCCESS_URL=
# MULENPAY
MULENPAY_ENABLED=false
MULENPAY_API_KEY=
@@ -269,13 +559,21 @@ MULENPAY_SHOP_ID=<ID магазина>
# необязательно, есть дефолтные значения
MULENPAY_BASE_URL=https://mulenpay.ru/api
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
# Название кнопки в интерфейсе
MULENPAY_DISPLAY_NAME=Mulen Pay
MULENPAY_DESCRIPTION="Пополнение баланса"
# Запрещённые ключевые слова в display_name (КАЗИНО, СТАВКИ и т.п. — блокируются Mulenpay)
# DISPLAY_NAME_BANNED_KEYWORDS=КАЗИНО,СТАВКИ,CASINO,BET,1XBET
MULENPAY_LANGUAGE=ru
MULENPAY_VAT_CODE=0
MULENPAY_PAYMENT_SUBJECT=4
MULENPAY_PAYMENT_MODE=4
MULENPAY_MIN_AMOUNT_KOPEKS=10000
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
# Ожидаемый origin для iframe (опционально, для безопасности)
# MULENPAY_IFRAME_EXPECTED_ORIGIN=https://mulenpay.ru
# URL для редиректа после оплаты (по умолчанию WEBHOOK_URL)
# MULENPAY_WEBSITE_URL=https://your-cabinet-url.com
# PAYPALYCH / PAL24
PAL24_ENABLED=false
@@ -284,7 +582,6 @@ PAL24_SHOP_ID=
PAL24_SIGNATURE_TOKEN=
PAL24_BASE_URL=https://pal24.pro/api/v1/
PAL24_WEBHOOK_PATH=/pal24-webhook
PAL24_WEBHOOK_PORT=8084
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
PAL24_MIN_AMOUNT_KOPEKS=10000
PAL24_MAX_AMOUNT_KOPEKS=100000000
@@ -294,14 +591,127 @@ PAL24_SBP_BUTTON_VISIBLE=true
# Отображать кнопку оплаты картой в PayPalych (true - отображать, false - скрывать)
PAL24_CARD_BUTTON_VISIBLE=true
# PLATEGA
PLATEGA_ENABLED=false
PLATEGA_MERCHANT_ID=
PLATEGA_SECRET=
PLATEGA_BASE_URL=https://app.platega.io
# Название кнопки в интерфейсе
PLATEGA_DISPLAY_NAME=Platega
PLATEGA_RETURN_URL=
PLATEGA_FAILED_URL=
PLATEGA_CURRENCY=RUB
# Список ID активных методов из кабинета Platega (через запятую)
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
PLATEGA_MIN_AMOUNT_KOPEKS=100
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
PLATEGA_WEBHOOK_PATH=/platega-webhook
PLATEGA_WEBHOOK_HOST=0.0.0.0
PLATEGA_WEBHOOK_PORT=8086
# ===== FREEKASSA =====
FREEKASSA_ENABLED=false
FREEKASSA_SHOP_ID=
FREEKASSA_API_KEY=
# Секретное слово 1 (для формы оплаты)
FREEKASSA_SECRET_WORD_1=
# Секретное слово 2 (для webhook)
FREEKASSA_SECRET_WORD_2=
FREEKASSA_DISPLAY_NAME=Freekassa
FREEKASSA_CURRENCY=RUB
FREEKASSA_MIN_AMOUNT_KOPEKS=10000
FREEKASSA_MAX_AMOUNT_KOPEKS=100000000
FREEKASSA_PAYMENT_TIMEOUT_SECONDS=3600
FREEKASSA_WEBHOOK_PATH=/freekassa-webhook
FREEKASSA_WEBHOOK_HOST=0.0.0.0
FREEKASSA_WEBHOOK_PORT=8088
# Способ оплаты: пусто = форма выбора, 42 = обычный СБП, 44 = NSPK СБП
FREEKASSA_PAYMENT_SYSTEM_ID=
# Использовать API для создания заказов (обязательно для NSPK СБП)
FREEKASSA_USE_API=false
# ===== KASSA AI (api.fk.life) =====
# Отдельная платёжная система, работает параллельно с Freekassa
KASSA_AI_ENABLED=false
KASSA_AI_SHOP_ID=
KASSA_AI_API_KEY=
# Секретное слово 2 (для webhook)
KASSA_AI_SECRET_WORD_2=
KASSA_AI_DISPLAY_NAME=KassaAI
KASSA_AI_CURRENCY=RUB
KASSA_AI_MIN_AMOUNT_KOPEKS=10000
KASSA_AI_MAX_AMOUNT_KOPEKS=100000000
KASSA_AI_WEBHOOK_PATH=/kassa-ai-webhook
KASSA_AI_WEBHOOK_HOST=0.0.0.0
KASSA_AI_WEBHOOK_PORT=8089
# Способ оплаты: 44 = СБП (QR), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID=44
# ===== WATA =====
WATA_ENABLED=false
WATA_BASE_URL=https://api.wata.pro
WATA_ACCESS_TOKEN=
WATA_TERMINAL_PUBLIC_ID=
WATA_PAYMENT_DESCRIPTION=Пополнение баланса
# Тип платежа: card, sbp, all
WATA_PAYMENT_TYPE=all
WATA_SUCCESS_REDIRECT_URL=
WATA_FAIL_REDIRECT_URL=
WATA_LINK_TTL_MINUTES=60
WATA_MIN_AMOUNT_KOPEKS=10000
WATA_MAX_AMOUNT_KOPEKS=10000000
WATA_REQUEST_TIMEOUT=30
WATA_WEBHOOK_PATH=/wata-webhook
WATA_WEBHOOK_HOST=0.0.0.0
WATA_WEBHOOK_PORT=8087
# Кэширование публичного ключа WATA (секунды)
WATA_PUBLIC_KEY_CACHE_SECONDS=3600
# URL для получения публичного ключа (опционально)
# WATA_PUBLIC_KEY_URL=
# ===== CLOUDPAYMENTS =====
CLOUDPAYMENTS_ENABLED=false
CLOUDPAYMENTS_PUBLIC_ID=
CLOUDPAYMENTS_API_SECRET=
# URL API CloudPayments
CLOUDPAYMENTS_API_URL=https://api.cloudpayments.ru
# URL виджета оплаты
CLOUDPAYMENTS_WIDGET_URL=https://widget.cloudpayments.ru/show
CLOUDPAYMENTS_DESCRIPTION=Пополнение баланса
CLOUDPAYMENTS_CURRENCY=RUB
CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS=10000
CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS=10000000
CLOUDPAYMENTS_WEBHOOK_PATH=/cloudpayments-webhook
CLOUDPAYMENTS_WEBHOOK_HOST=0.0.0.0
CLOUDPAYMENTS_WEBHOOK_PORT=8089
# URL для возврата после оплаты (опционально)
# CLOUDPAYMENTS_RETURN_URL=
# Скин виджета: mini, classic, modern
CLOUDPAYMENTS_SKIN=mini
CLOUDPAYMENTS_REQUIRE_EMAIL=false
CLOUDPAYMENTS_TEST_MODE=false
# ===== ИНТЕРФЕЙС И UX =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
ENABLE_LOGO_MODE=true
LOGO_FILE=vpn_logo.png
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
# Режим главного меню:
# default - классический режим работы бота (все кнопки внутри Telegram)
# cabinet - режим Cabinet с активным ЛК MiniApp, кнопки ведут на конкретные
# разделы кабинета (/balance, /subscription, /referral и т.д.)
# Требует MINIAPP_CUSTOM_URL
# Алиасы для обратной совместимости: text, text_only, minimal
MAIN_MENU_MODE=default
# Стиль кнопок в режиме Cabinet (Bot API 9.4):
# primary - синий
# success - зелёный
# danger - красный
# (пустое) - цвета по умолчанию для каждой секции
CABINET_BUTTON_STYLE=
# Включить управление меню через API (позволяет динамически менять структуру кнопок)
MENU_LAYOUT_ENABLED=false
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
HIDE_SUBSCRIPTION_LINK=false
@@ -312,10 +722,13 @@ HIDE_SUBSCRIPTION_LINK=false
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - Открывает ссылку напрямую в браузере (режим 4)
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
CONNECT_BUTTON_MODE=guide
CONNECT_BUTTON_MODE=miniapp_subscription
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_STATIC_PATH=miniapp
# URL для редиректа на страницу покупки в мини-приложении (опционально)
# MINIAPP_PURCHASE_URL=
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
@@ -327,6 +740,8 @@ HAPP_DOWNLOAD_LINK_IOS=
HAPP_DOWNLOAD_LINK_ANDROID=
HAPP_DOWNLOAD_LINK_MACOS=
HAPP_DOWNLOAD_LINK_WINDOWS=
# Универсальная ссылка для ПК (если MACOS и WINDOWS не заданы отдельно)
HAPP_DOWNLOAD_LINK_PC=
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
@@ -367,21 +782,42 @@ SERVER_STATUS_ITEMS_PER_PAGE=10
MAINTENANCE_MODE=false
MAINTENANCE_CHECK_INTERVAL=30
MAINTENANCE_AUTO_ENABLE=true
MAINTENANCE_MONITORING_ENABLED=true
MAINTENANCE_RETRY_ATTEMPTS=1
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
AVAILABLE_LANGUAGES=ru,en,ua,zh,fa
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
# true: 14.78₽ → 15₽, 14.12₽ → 14₽
# false: показывать точные суммы с копейками
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
# ===== BAN SYSTEM INTEGRATION (BedolagaBan) =====
# Интеграция с системой мониторинга банов BedolagaBan
# Включить интеграцию с Ban системой
BAN_SYSTEM_ENABLED=false
# URL API сервера Ban системы (например: http://ban-server:8000)
BAN_SYSTEM_API_URL=
# API токен для авторизации в Ban системе
BAN_SYSTEM_API_TOKEN=
# Таймаут запросов к API (секунды)
BAN_SYSTEM_REQUEST_TIMEOUT=30
# ===== СИСТЕМА БЕКАПОВ =====
BACKUP_AUTO_ENABLED=true
BACKUP_INTERVAL_HOURS=24
@@ -398,6 +834,8 @@ BACKUP_SEND_ENABLED=true
BACKUP_SEND_CHAT_ID=-100123456789 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА!
# ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
BACKUP_SEND_TOPIC_ID=123 # Опционально: ID топика
# Пароль для архива бекапа (опционально). Если задан - бекап отправляется в зашифрованном ZIP с AES
BACKUP_ARCHIVE_PASSWORD=
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
VERSION_CHECK_ENABLED=true
@@ -408,7 +846,77 @@ VERSION_CHECK_INTERVAL_HOURS=1
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
# === Ротация логов ===
# Включить новую систему ротации (по умолчанию старое поведение)
LOG_ROTATION_ENABLED=false
# Время ротации (HH:MM)
LOG_ROTATION_TIME=00:00
# Хранить архивы N дней
LOG_ROTATION_KEEP_DAYS=7
# Сжимать архивы gzip
LOG_ROTATION_COMPRESS=true
# Отправлять архивы в Telegram-канал
LOG_ROTATION_SEND_TO_TELEGRAM=false
# Канал для логов (если не задан, используется BACKUP_SEND_CHAT_ID)
LOG_ROTATION_CHAT_ID=
# Топик в канале (если не задан, используется BACKUP_SEND_TOPIC_ID)
LOG_ROTATION_TOPIC_ID=
# Пути к лог-файлам (при LOG_ROTATION_ENABLED=true)
LOG_DIR=logs
LOG_INFO_FILE=info.log
LOG_WARNING_FILE=warning.log
LOG_ERROR_FILE=error.log
LOG_PAYMENTS_FILE=payments.log
# ===== РАЗРАБОТКА =====
DEBUG=false
WEBHOOK_URL=
WEBHOOK_PATH=/webhook
WEBHOOK_SECRET_TOKEN=
WEBHOOK_DROP_PENDING_UPDATES=true
WEBHOOK_MAX_QUEUE_SIZE=1024
WEBHOOK_WORKERS=4
WEBHOOK_ENQUEUE_TIMEOUT=0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
BOT_RUN_MODE=polling # polling или webhook
# ===== КОНКУРСНАЯ СИСТЕМА =====
CONTESTS_ENABLED=false
CONTESTS_BUTTON_VISIBLE=false
# Реферальные конкурсы (турниры среди рефералов)
REFERRAL_CONTESTS_ENABLED=false
# ===== АВТОПОКУПКА ПОСЛЕ ПОПОЛНЕНИЯ =====
# Автоматическая покупка из сохранённой корзины после пополнения баланса
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
# ===== КНОПКА АКТИВАЦИИ =====
ACTIVATE_BUTTON_VISIBLE=false
# ACTIVATE_BUTTON_TEXT=активировать
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
WEB_API_ENABLED=false
WEB_API_HOST=0.0.0.0
WEB_API_PORT=8080
# Количество воркеров (для продакшена рекомендуется 2-4)
WEB_API_WORKERS=1
WEB_API_ALLOWED_ORIGINS=*
WEB_API_DOCS_ENABLED=false
# Название и версия API (для документации)
WEB_API_TITLE=Remnawave Bot Admin API
WEB_API_VERSION=1.0.0
# Токен по умолчанию для начальной настройки
WEB_API_DEFAULT_TOKEN=
WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token
# Алгоритм хеширования токенов
WEB_API_TOKEN_HASH_ALGORITHM=sha256
# Логирование запросов
WEB_API_REQUEST_LOGGING=true
# Внешний админ-токен (для интеграции с другими ботами/системами)
# Токен для доступа через API другого бота
# EXTERNAL_ADMIN_TOKEN=
# ID бота, от которого принимается токен
# EXTERNAL_ADMIN_TOKEN_BOT_ID=
MINIAPP_STATIC_PATH=miniapp
+42
View File
@@ -0,0 +1,42 @@
---
name: Bug Bedolage
about: Создать баг-репорт «Бедолаге»
title: "[BUG] Заголовок"
labels: ''
assignees: ''
---
## Описание / Конфигурация
Кратко описываем текущую проблему.
Указываем настройки из `.env` конфига (если таковые имеются):
```yml
```
## Шаги для воспроизведения
1.
2.
3.
### Фактический результат
*
*
*
### Скрины поведения
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
## Ожидаемое поведение
Опишите, как должно работать.
## Дополнительная информация
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
+32
View File
@@ -0,0 +1,32 @@
---
name: Feat Bedolage
about: Создать запрос на фичу «Бедолаге»
title: "[FEAT] Заголовок"
labels: ''
assignees: ''
---
## Описание / Конфигурация
Кратко описываем что хотим.
Указываем настройки из `.env` конфига (если таковые к месту):
```yml
```
## Текущее поведение
### Скрины поведения
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
## Желаемое поведение
Опишите, как хотелось бы, чтобы было.
## Дополнительная информация
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.5.3-$(git rev-parse --short HEAD)"
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.3-dev-$(git rev-parse --short HEAD)"
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.5.3-pr-$(git rev-parse --short HEAD)"
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+4 -4
View File
@@ -14,7 +14,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.5.3-$(git rev-parse --short HEAD)"
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.3-dev-$(git rev-parse --short HEAD)"
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.5.3-pr-$(git rev-parse --short HEAD)"
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+27
View File
@@ -0,0 +1,27 @@
name: Lint
on:
push:
branches: ['**']
pull_request:
branches: ['**']
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: uv sync --group dev
- name: Check formatting
run: uv run ruff format --check .
- name: Check linting
run: uv run ruff check .
+24
View File
@@ -0,0 +1,24 @@
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
+168
View File
@@ -0,0 +1,168 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
lint:
uses: ./.github/workflows/lint.yml
release:
needs: lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get previous tag
id: prev_tag
run: |
PREV_TAG=$(git describe --tags --abbrev=0 ${{ github.ref_name }}^ 2>/dev/null || echo "")
echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Generate changelog
id: changelog
run: |
TAG="${{ github.ref_name }}"
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
if [ -z "$PREV_TAG" ]; then
RANGE="$TAG"
else
RANGE="${PREV_TAG}..${TAG}"
fi
# Collect commits by category
FEATURES=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^feat" || true)
FIXES=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^fix" || true)
PERF=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^perf|^refactor" || true)
DOCS=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^docs|^style" || true)
CHORE=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^chore|^ci|^build|^test" || true)
OTHER=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -ivE "^(feat|fix|perf|refactor|docs|style|chore|ci|build|test)" || true)
# Collect unique contributors
CONTRIBUTORS=$(git log $RANGE --pretty=format:"%an" --no-merges | sort -u)
# Stats
TOTAL_COMMITS=$(git log $RANGE --oneline --no-merges | wc -l | tr -d ' ')
FILES_CHANGED=$(git diff --stat $RANGE 2>/dev/null | tail -1 || echo "N/A")
# Format function
format_section() {
local commits="$1"
if [ -n "$commits" ]; then
echo "$commits" | while IFS='|' read -r msg author hash; do
# Clean conventional commit prefix
clean_msg=$(echo "$msg" | sed -E 's/^(feat|fix|perf|refactor|docs|style|chore|ci|build|test)(\([^)]*\))?:\s*//')
echo "- ${clean_msg} (\`${hash}\`) — @${author}"
done
fi
}
# Build changelog
{
echo "changelog<<CHANGELOG_EOF"
if [ -n "$FEATURES" ]; then
echo "### New Features"
echo ""
format_section "$FEATURES"
echo ""
fi
if [ -n "$FIXES" ]; then
echo "### Bug Fixes"
echo ""
format_section "$FIXES"
echo ""
fi
if [ -n "$PERF" ]; then
echo "### Performance & Refactoring"
echo ""
format_section "$PERF"
echo ""
fi
if [ -n "$DOCS" ]; then
echo "### Documentation & Style"
echo ""
format_section "$DOCS"
echo ""
fi
if [ -n "$CHORE" ]; then
echo "### Maintenance"
echo ""
format_section "$CHORE"
echo ""
fi
if [ -n "$OTHER" ]; then
echo "### Other Changes"
echo ""
format_section "$OTHER"
echo ""
fi
echo "---"
echo ""
echo "### Contributors"
echo ""
if [ -n "$CONTRIBUTORS" ]; then
echo "$CONTRIBUTORS" | while read -r name; do
echo "- @${name}"
done
fi
echo ""
echo "### Stats"
echo ""
echo "- **Commits:** ${TOTAL_COMMITS}"
echo "- **Changes:** ${FILES_CHANGED}"
if [ -n "$PREV_TAG" ]; then
echo "- **Full diff:** [\`${PREV_TAG}...${TAG}\`](https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${TAG})"
fi
echo "CHANGELOG_EOF"
} >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
body: |
## What's Changed
${{ steps.changelog.outputs.changelog }}
---
### Docker
```bash
# Docker Hub
docker pull fr1ngg/remnawave-bedolaga-telegram-bot:${{ github.ref_name }}
# GitHub Container Registry
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}
```
### Update
```bash
# Docker Compose
docker compose pull && docker compose up -d
# Or with Make
make reload
```
draft: false
prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') || contains(github.ref_name, 'dev') }}
generate_release_notes: false
+87 -21
View File
@@ -1,36 +1,102 @@
# Игнорируем все файлы и папки по умолчанию
*
# Исключения: разрешаем только нужные файлы
# ========== WHITELIST: разрешённые файлы ==========
# Конфигурация проекта
!.dockerignore
!.env.example
!install_bot.sh
!.gitignore
!.python-version
!Dockerfile
!app-config.json
!main.py
!docker-compose.yml
!docker-compose.local.yml
!Makefile
!pyproject.toml
!uv.lock
!requirements.txt
!docs/
!docs/**
!alembic.ini
!app-config.json
!release-please-config.json
!.release-please-manifest.json
# Документация
!README.md
!LICENSE
!CONTRIBUTING.md
!SECURITY.md
# Скрипты
!install_bot.sh
!main.py
# Статические файлы
!vpn_logo.png
# ========== WHITELIST: разрешённые папки ==========
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
!app/**
!tests/
!tests/**
!migrations/
!migrations/**
!docs/
!docs/**
!assets/
!assets/**
!locales/
!locales/**
!.github/
!.github/**
# Дополнительно разрешаем README и лицензию (опционально)
!README.md
!LICENSE
# ========== BLACKLIST: игнорируемые внутри папок ==========
# Разрешаем .gitignore чтобы он попал в репозиторий
!.gitignore
# Python
__pycache__/
**/__pycache__/
*.py[cod]
*$py.class
*.so
# Внутри разрешенных папок игнорируем служебные файлы
app/__pycache__/
app/**/__pycache__/
app/**/*.pyc
app/**/*.pyo
app/**/*.pyd
*.pyc
*.pyo
*.pyd
# Virtual environments
.venv/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Build/dist
build/
dist/
*.egg-info/
.eggs/
# Testing/coverage
.coverage
htmlcov/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Local overrides (не коммитить!)
docker-compose.override.yml
.env
.env.local
.env.*.local
# Runtime data
logs/
data/
*.log
*.db
*.sqlite3
# OS files
.DS_Store
Thumbs.db
+1
View File
@@ -0,0 +1 @@
3.13
+3
View File
@@ -0,0 +1,3 @@
{
".": "3.12.0"
}
+302
View File
@@ -0,0 +1,302 @@
# Changelog
## [3.12.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.11.0...v3.12.0) (2026-02-15)
### New Features
* add 'default' (no color) option for button styles ([10538e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10538e735149bf3f3f2029ff44b94d11d48c478e))
* add button style and emoji support for cabinet mode (Bot API 9.4) ([bf2b2f1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf2b2f1c5650e527fcac0fb3e72b4e6e19bef406))
* add per-button enable/disable toggle and custom labels per locale ([68773b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68773b7e77aa344d18b0f304fa561c91d7631c05))
* add per-section button style and emoji customization via admin API ([a968791](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9687912dfe756e7d772d96cc253f78f2e97185c))
* add web admin button for admins in cabinet mode ([9ac6da4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ac6da490dffa03ce823009c6b4e5014b7d2bdfb))
* rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections ([ad87c5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad87c5fb5e1a4dd0ef7691f12764d3df1530f643))
### Bug Fixes
* daily tariff subscriptions stuck in expired/disabled with no resume path ([80914c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/80914c1af739aa0ee1ea75b0e5871bf391b9020d))
* filter out traffic packages with zero price from purchase options ([64a684c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64a684cd2ff51e663a1f70e61c07ca6b4f6bfc91))
* handle photo message in ticket creation flow ([e182280](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1822800aba3ea5eee721846b1e0d8df0a9398d1))
* handle tariff_extend callback without period (back button crash) ([ba0a5e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba0a5e9abd9bd582968d69a5c6e57f336094c782))
* pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults ([46c1a69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46c1a69456036cb1be784b8d952f27110e9124eb))
* remove redundant trial inactivity monitoring checks ([d712ab8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d712ab830166cab61ce38dd32498a8a9e3e602b0))
* webhook notification 'My Subscription' button uses unregistered callback_data ([1e2a7e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e2a7e3096af11540184d60885b8c08d73506c4a))
## [3.11.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.3...v3.11.0) (2026-02-12)
### New Features
* add cabinet admin API for pinned messages management ([1a476c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a476c49c19d1ec2ab2cda1c2ffb5fd242288bb6))
* add startup warnings for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE and MINIAPP_CUSTOM_URL ([476b89f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/476b89fe8e613c505acfc58a9554d31ccf92718a))
### Bug Fixes
* add passive_deletes to Subscription relationships to prevent NOT NULL violation on cascade delete ([bfd66c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfd66c42c1fba3763f41d641cea1bd101ec8c10c))
* add startup warning for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE in guide mode ([1d43ae5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d43ae5e25ffcf0e4fe6fec13319d393717e1e50))
* flood control handling in pinned messages and XSS hardening in HTML sanitizer ([454b831](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454b83138e4db8dc4f07171ee6fe262d2cd6d311))
* suppress expired callback query error in AuthMiddleware ([2de4384](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2de438426a647e2bcae9b4d99eef4093ff8b5429))
* ticket creation crash and webhook PendingRollbackError ([760c833](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/760c833b7402541d3c7cf2ed7fc0418119e75042))
## [3.10.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.2...v3.10.3) (2026-02-12)
### Bug Fixes
* handle unique constraint conflicts during backup restore without clear_existing ([5893874](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/589387477624691e0026086800428e7e52e06128))
* harden backup create/restore against serialization and constraint errors ([fc42916](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fc42916b10bb698895eb75c0e2568747647555d3))
* resolve deadlock on server_squads counter updates and add webhook notification toggles ([57dc1ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57dc1ff47f2f6183351db7594544a07ca6f27250))
## [3.10.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.1...v3.10.2) (2026-02-12)
### Bug Fixes
* allow email change for unverified emails ([93bb8e0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93bb8e0eb492ca59e29da86594e84e9c486fea65))
* clean stale squad UUIDs from tariffs during server sync ([fcaa9df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcaa9dfb27350ceda3765c6980ad67f671477caf))
* delete subscription_servers before subscription to prevent FK violation ([7d9ced8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d9ced8f4f71b43ed4ac798e6ff904a086e1ac4a))
* handle StaleDataError in webhook user.deleted server counter decrement ([c30c2fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30c2feee1db03f0a359b291117da88002dd0fe0))
* handle time/date types in backup JSON serialization ([27365b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27365b3c7518c09229afcd928f505d0f3f66213f))
* HTML parse fallback, email change race condition, username length limit ([d05ff67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d05ff678abfacaa7e55ad3e55f226d706d32a7b7))
* payment race conditions, balance atomicity, renewal rollback safety ([c5124b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c5124b97b63eda59b52d2cbf9e2dcdaa6141ed6e))
* remove DisplayNameRestrictionMiddleware ([640da34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/640da3473662cfdcceaa4346729467600ac3b14f))
* suppress bot-blocked-by-user error in AuthMiddleware ([fda9f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fda9f3beecbfcca4d7abc16cf661d5ad5e3b5141))
* UnboundLocalError for get_logo_media in required_sub_channel_check ([d3c14ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c14ac30363839d1340129f279a7a7b4b021ed1))
* use traffic topup config and add WATA 429 retry ([b5998ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5998ea9d22644ed2914b0e829b3a76a32a69ddf))
### Refactoring
* remove modem functionality from classic subscriptions ([ee2e79d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ee2e79db3114fe7a9852d2cd33c4b4fbbde311ea))
## [3.10.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.0...v3.10.1) (2026-02-11)
### Bug Fixes
* address review issues in backup, updates, and webhook handlers ([2094886](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20948869902dc570681b05709ac8d51996330a6e))
* allow purchase when recalculated price is lower than cached ([19dabf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/19dabf38512ae0c2121108d0b92fc8f384292484))
* change CryptoBot URL priority to bot_invoice_url for Telegram opening ([3193ffb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3193ffbd1bee07cb79824d87cb0f77b473b22989))
* clear subscription data when user deleted from Remnawave panel ([b0fd38d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b0fd38d60c22247a0086c570665b92c73a060f2f))
* downgrade Telegram timeout errors to warning in monitoring service ([e43a8d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e43a8d6ce4c40a7212bf90644f82da109717bdcb))
* expand backup coverage to all 68 models and harden restore ([02e40bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e40bd6f7ef8e653cae53ccd127f2f79009e0d4))
* handle nullable traffic_limit_gb and end_date in subscription model ([e94b93d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e94b93d0c10b4e61d7750ca47e1b2f888f5873ed))
* handle StaleDataError in webhook when user already deleted ([d58a80f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d58a80f3eaa64a6fc899e10b3b14584fb7fc18a9))
* ignore 'message is not modified' on privacy policy decline ([be1da97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be1da976e14a35e6cca01a7fca7529c55c1a208b))
* preserve purchased traffic when extending same tariff ([b167ed3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b167ed3dd1c6e6239db2bdbb8424bcb1fb7715d9))
* prevent cascading greenlet errors after sync rollback ([a1ffd5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1ffd5bda6b63145104ce750835d8e6492d781dc))
* protect server counter callers and fix tariff change detection ([bee4aa4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bee4aa42842b8b6611c7c268bcfced408a227bc0))
* suppress 'message is not modified' error in updates panel ([3a680b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a680b41b0124848572809d187cab720e1db8506))
* use callback fallback when MINIAPP_CUSTOM_URL is not set ([eaf3a07](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaf3a07579729031030308d77f61a5227b796c02))
* use flush instead of commit in server counter functions ([6cec024](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6cec024e46ef9177cb59aa81590953c9a75d81bb))
## [3.10.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.1...v3.10.0) (2026-02-10)
### New Features
* add all remaining RemnaWave webhook events (node, service, crm, device) ([1e37fd9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e37fd9dd271814e644af591343cada6ab12d612))
* add close button to all webhook notifications ([d9de15a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9de15a5a06aec3901415bdfd25b55d2ca01d28c))
* add MULENPAY_WEBSITE_URL setting for post-payment redirect ([fe5f5de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe5f5ded965e36300e1c73f25f16de22f84651ad))
* add RemnaWave incoming webhooks for real-time subscription events ([6d67cad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d67cad3e7aa07b8490d88b73c38c4aca6b9e315))
* handle errors.bandwidth_usage_threshold_reached_max_notifications webhook ([8e85e24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e85e244cb786fb4c06162f2b98d01202e893315))
* handle service.subpage_config_changed webhook event ([43a326a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43a326a98ccc3351de04d9b2d660d3e7e0cb0efc))
* unified notification delivery for webhook events (email + WS support) ([26637f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26637f0ae5c7264c0430487d942744fd034e78e8))
* webhook protection — prevent sync/monitoring from overwriting webhook data ([184c52d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/184c52d4ea3ce02d40cf8a5ab42be855c7c7ae23))
### Bug Fixes
* add action buttons to webhook notifications and fix empty device names ([7091eb9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7091eb9c148aaf913c4699fc86fef5b548002668))
* add missing placeholders to Arabic SUBSCRIPTION_INFO template ([fe54640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe546408857128649930de9473c7cde1f7cc450a))
* allow non-HTTP deep links in crypto link webhook updates ([f779225](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f77922522a85b3017be44b5fc71da9c95ec16379))
* build composite device name from platform + hwid short suffix ([17ce640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17ce64037f198837c8f2aa7bf863871f60bdf547))
* downgrade transient API errors (502/503/504) to warning level ([ec8eaf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ec8eaf52bfdc2bde612e4fc0324575ba7dc6b2e1))
* extract device name from nested hwidUserDevice object ([79793c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79793c47bbbdae8b0f285448d5f70e90c9d4f4b0))
* preserve payment initiation time in transaction created_at ([90d9df8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90d9df8f0e949913f09c4ebed8fe5280453ab3ab))
* security and architecture fixes for webhook handlers ([dc1e96b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc1e96bbe9b4496e91e9dea591c7fc0ef4cc245b))
* stop CryptoBot webhook retry loop and save cabinet payments to DB ([2cb6d73](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cb6d731e96cbfc305b098d8424b84bfd6826fb4))
* sync subscription status from panel in user.modified webhook ([5156d63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5156d635f0b5bc0493e8f18ce9710cca6ff4ffc8))
* use event field directly as event_name (already includes scope prefix) ([9aa22af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9aa22af3390a249d1b500d75a7d7189daaed265e))
* webhook:close button not working due to channel check timeout ([019fbc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/019fbc12b6cf61d374bbed4bce3823afc60445c9))
## [3.9.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.0...v3.9.1) (2026-02-10)
### Bug Fixes
* don't delete Heleket invoice message on status check ([9943253](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/994325360ca7665800177bfad8f831154f4d733f))
* safe HTML preview truncation and lazy-load subscription fallback ([40d8a6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/40d8a6dc8baf3f0f7c30b0883898b4655a907eb5))
* use actual DB columns for subscription fallback query ([f0e7f8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f0e7f8e3bec27d97a3f22445948b8dde37a92438))
## [3.9.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.8.0...v3.9.0) (2026-02-09)
### New Features
* add lite mode functionality with endpoints for retrieval and update ([7b0403a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b0403a307702c24efefc5c14af8cb2fb7525671))
* add Persian (fa) locale with complete translations ([29a3b39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29a3b395b6e67e4ce2437b75120b78c76b69ff4f))
* allow tariff deletion with active subscriptions ([ebd6bee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebd6bee05ed7d9187de9394c64dfd745bb06b65a))
* **localization:** add Persian (fa) locale support and wire it across app flows ([cc54a7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc54a7ad2fb98fe6e662e1923027f4989ae72868))
### Bug Fixes
* nullify payment FK references before deleting transactions in user restoration ([0b86f37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b86f379b4e55e499ca3d189137e2aed865774b5))
* prevent sync from overwriting end_date for non-ACTIVE panel users ([49871f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49871f82f37d84979ea9ec91055e3f046d5854be))
* promo code max_uses=0 conversion and trial UX after promo activation ([1cae713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cae7130bc87493ab8c7691b3c22ead8189dab55))
* skip users with active subscriptions in admin inactive cleanup ([e79f598](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e79f598d17ffa76372e6f88d2a498accf8175c76))
* use selection.period.days instead of selection.period_days ([4541016](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/45410168afe683675003a1c41c17074a54ce04f1))
### Performance
* cache logo file_id to avoid re-uploading on every message ([142ff14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/142ff14a502e629446be7d67fab880d12bee149d))
### Refactoring
* remove "both" mode from BOT_RUN_MODE, keep only polling and webhook ([efa3a5d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efa3a5d4579f24dabeeba01a4f2e981144dd6022))
* remove Flask, use FastAPI exclusively for all webhooks ([119f463](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/119f463c36a95685c3bc6cdf704e746b0ba20d56))
* remove smart auto-activation & activation prompt, fix production bugs ([a3903a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a3903a252efdd0db4b42ca3fd6771f1627050a7f))
## [3.8.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.2...v3.8.0) (2026-02-08)
### New Features
* add admin device management endpoints ([c57de10](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c57de1081a9e905ba191f64c37221c36713c82a6))
* add admin traffic packages and device limit management ([2f90f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f90f9134df58b8c0a329c20060efcf07d5d92f9))
* add admin updates endpoint for bot and cabinet releases ([11b8ab1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11b8ab1959e83fafe405be0b76dfa3dd1580a68b))
* add endpoint for updating user referral commission percent ([da6f746](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da6f746b093be8cdbf4e2889c50b35087fbc90de))
* add enrichment data to CSV export ([f2dbab6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f2dbab617155cdc41573d885f0e55222e5b9825b))
* add server-side sorting for enrichment columns ([15c7cc2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15c7cc2a58e1f1935d10712a981466629db251d1))
* add system info endpoint for admin dashboard ([02c30f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02c30f8e7eb6ba90ed8983cfd82199a22b473bbf))
* add traffic usage enrichment endpoint with devices, spending, dates, last node ([5cf3f2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5cf3f2f76eb2cd93282f845ea0850f6707bfcc09))
* admin panel enhancements & bug fixes ([e6ebf81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebf81752499df8eb0a710072785e3d603dba33))
### Bug Fixes
* add debug logging for bulk device response structure ([46da31d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46da31d89c55c225dec9136d225f2db967cf8961))
* add email field to traffic table for OAuth/email users ([94fcf20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94fcf20d17c54efd67fa7bd47eff1afdd1507e08))
* add email/UUID fallback for OAuth user panel sync ([165965d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/165965d8ea60a002c061fd75f88b759f2da66d7d))
* add enrichment device mapping debug logs ([5be82f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5be82f2d78aed9b54d74e86f261baa5655e5dcd9))
* include additional devices in tariff renewal price and display ([17e9259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17e9259eb1d41dbf1d313b6a7d500f6458359393))
* paginate bulk device endpoint to fetch all HWID devices ([4648a82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4648a82da959410603c92055bcde7f96131e0c29))
* read bot version from pyproject.toml when VERSION env is not set ([9828ff0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9828ff0845ec1d199a6fa63fe490ad3570cf9c8f))
* revert device pagination, add raw user data field discovery ([8f7fa76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f7fa76e6ab34a3ad2f61f4e1f06026fd3fbf4e3))
* use bulk device endpoint instead of per-user calls ([5f219c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f219c33e6d49b0e3e4405a57f8344a4237f1002))
* use correct pagination params (start/size) for bulk HWID devices ([17af51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17af51ce0bdfa45197384988d56960a1918ab709))
* use per-user panel endpoints for reliable device counts and last node data ([9d39901](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d39901f78ece55c740a5df2603601e5d0b1caca))
## [3.7.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.1...v3.7.2) (2026-02-08)
### Bug Fixes
* handle FK violation in create_yookassa_payment when user is deleted ([55d281b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/55d281b0e37a6e8977ceff792cccb8669560945b))
* remove dots from Remnawave username sanitization ([d6fa86b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d6fa86b870eccbf22327cd205539dd2084f0014e))
## [3.7.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.0...v3.7.1) (2026-02-08)
### Bug Fixes
* release-please config — remove blocked workflow files ([d88ca98](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d88ca980ec67e303e37f0094a2912471929b4cef))
* remove workflow files and pyproject.toml from release-please extra-files ([5070bb3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5070bb34e8a09b2641783f5e818bb624469ad610))
* resolve HWID reset and webhook FK violation ([5f3e426](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f3e426750c2adcb097b92f1a9e7725b1c5c5eba))
* resolve HWID reset context manager bug and webhook FK violation ([a9eee19](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9eee19c95efdc38ecf5fa28f7402a2bbba7dd07))
* resolve merge conflict in release-please config ([0ef4f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ef4f55304751571754f2027105af3e507f75dfd))
* resolve multiple production errors and performance issues ([071c23d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/071c23dd5297c20527442cb5d348d498ebf20af4))
## [3.7.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.6.0...v3.7.0) (2026-02-07)
### Features
* add admin traffic usage API ([aa1cd38](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa1cd3829c5c3671e220d49dd7ec2d83563e2cf9))
* add admin traffic usage API with per-node statistics ([6c2c25d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c2c25d2ccb27446c822e4ed94d9351bfeaf4549))
* add node/status filters and custom date range to traffic page ([ad260d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad260d9fe0b232c9d65176502476212902909660))
* add node/status filters, custom date range, connected devices to traffic page ([9ea533a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ea533a864e345647754f316bd27971fba1420af))
* add node/status filters, date range, devices to traffic page ([ad6522f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad6522f547e68ef5965e70d395ca381b0a032093))
* add risk columns to traffic CSV export ([7c1a142](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c1a1426537e43d14eff0a1c3faeca484611b58b))
* add tariff filter, fix traffic data aggregation ([fa01819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa01819674b2d2abb0d05b470559b09eb43abef8))
* node/status filters + custom date range for traffic page ([a161e2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a161e2f904732b459fef98a67abfaae1214ecfd4))
* tariff filter + fix traffic data aggregation ([1021c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1021c2cdcd07cf2194e59af7b59491108339e61f))
* traffic filters, date range & risk columns in CSV export ([4c40b5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c40b5b370616a9ab40cbf0cccdbc0ac4a3f8278))
### Bug Fixes
* close unclosed HTML tags in version notification ([0b61c7f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b61c7fe482e7bbfbb3421307a96d54addfd91ee))
* close unclosed HTML tags when truncating version notification ([b674550](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6745508da861af9b2ff05d89b4ac9a3933da510))
* correct response parsing for non-legacy node-users endpoint ([a076dfb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a076dfb5503a349450b5aa8aac3c6f40070b715d))
* correct response parsing for non-legacy node-users endpoint ([91ac90c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91ac90c2aecfb990679b3d0c835314dde448886a))
* handle mixed types in traffic sort ([eeed2d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eeed2d6369b07860505c59bcff391e7b17e0ffb7))
* handle mixed types in traffic sort for string fields ([a194be0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a194be0843856b3376167d9ba8a8ef737280998c))
* resolve 429 rate limiting on traffic page ([b12544d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b12544d3ea8f4bbd2d8c941f83ee3ac412157adb))
* resolve 429 rate limiting on traffic page ([924d6bc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/924d6bc09c815c1d188ea1d0e7974f7e803c1d3f))
* use legacy per-node endpoint for traffic aggregation ([cc1c8ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc1c8bacb42a9089021b7ae0fecd1f2717953efb))
* use legacy per-node endpoint with correct response format ([b707b79](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b707b7995b90c6465910a35e9a4403e1408c6568))
* use PaymentService for cabinet YooKassa payments ([61bb8fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61bb8fcafd94509568f134ccdba7769b66cc7d5d))
* use PaymentService for cabinet YooKassa payments to save local DB record ([ff5bba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff5bba3fc5d1e1b08d008b64215e487a9eb70960))
## [3.6.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.5.0...v3.6.0) (2026-02-07)
### Features
* add OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([97be4af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97be4afbffd809fe2786a6d248fc4d3f770cb8cf))
* add panel info, node usage endpoints and campaign to user detail ([287a43b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/287a43ba6527ff3464a527821d746a68e5371bbe))
* add panel info, node usage endpoints and campaign to user detail ([0703212](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/070321230bcb868e4bc7a39c287ed3431a4aef4a))
* add TRIAL_DISABLED_FOR setting to disable trial by user type ([c4794db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4794db1dd78f7c48b5da896bdb2f000e493e079))
* add user_id filter to admin tickets endpoint ([8886d0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8886d0dea20aa5a31c6b6f0c3391b3c012b4b34d))
* add user_id filter to admin tickets endpoint ([d3819c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3819c492f88794e4466c2da986fd3a928d7f3df))
* block registration with disposable email addresses ([9ca24ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ca24efe434278925c0c1f8d2f2d644a67985c89))
* block registration with disposable email addresses ([116c845](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/116c8453bb371b5eacf5c9d07f497eb449a355cc))
* disable trial by user type (email/telegram/all) ([4e7438b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e7438b9f9c01e30c48fcf2bbe191e9b11598185))
* migrate OAuth state storage from in-memory to Redis ([e9b98b8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e9b98b837a8552360ef4c41f6cd7a5779aa8b0a7))
* OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([3cbb9ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3cbb9ef024695352959ef9a82bf8b81f0ba1d940))
* return 30-day daily breakdown for node usage ([7102c50](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7102c50f52d583add863331e96f3a9de189f581a))
* return 30-day daily breakdown for node usage ([e4c65ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4c65ca220994cf08ed3510f51d9e2808bb2d154))
### Bug Fixes
* increase OAuth HTTP timeout to 30s ([333a3c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/333a3c590120a64f6b2963efab1edd861274840c))
* parse bandwidth stats series format for node usage ([557dbf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/557dbf3ebe777d2137e0e28303dc2a803b15c1c6))
* parse bandwidth stats series format for node usage ([462f7a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/462f7a99b9d5c0b7436dbc3d6ab5db6c6cfa3118))
* pass tariff object instead of tariff_id to set_tariff_promo_groups ([1ffb8a5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ffb8a5b85455396006e1fcddd48f4c9a2ca2700))
* query per-node legacy endpoint for user traffic breakdown ([b94e3ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b94e3edf80e747077992c03882119c7559ad1c31))
* query per-node legacy endpoint for user traffic breakdown ([51ca3e4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51ca3e42b75c1870c76a1b25f667629855cfe886))
* reduce node usage to 2 API calls to avoid 429 rate limit ([c68c4e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c68c4e59846abba9c7c78ae91ec18e2e0e329e3c))
* reduce node usage to 2 API calls to avoid 429 rate limit ([f00a051](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f00a051bb323e5ba94a3c38939870986726ed58e))
* use accessible nodes API and fix date format for node usage ([943e9a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/943e9a86aaa449cd3154b0919cfdc52d2a35b509))
* use accessible nodes API and fix date format for node usage ([c4da591](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4da59173155e2eeb69eca21416f816fcbd1fa9c))
## [3.5.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.4.0...v3.5.0) (2026-02-06)
### Features
* add tariff reorder API endpoint ([4c2e11e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2e11e64bed41592f5a12061dcca74ce43e0806))
* pass platform-level fields from RemnaWave config to frontend ([095bc00](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/095bc00b33d7082558a8b7252906db2850dce9da))
* serve original RemnaWave config from app-config endpoint ([43762ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43762ce8f4fa7142a1ca62a92b97a027dab2564d))
* tariff reorder API endpoint ([085a617](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/085a61721a8175b3f4fd744614c446d73346f2b7))
### Bug Fixes
* enforce blacklist via middleware ([561708b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/561708b7772ec5b84d6ee049aeba26dc70675583))
* enforce blacklist via middleware instead of per-handler checks ([966a599](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/966a599c2c778dce9eea3c61adf6067fb33119f6))
* exclude signature field from Telegram initData HMAC validation ([5b64046](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b6404613772610c595e55bde1249cdf6ec3269d))
* improve button URL resolution and pass uiConfig to frontend ([0ed98c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ed98c39b6c95911a38a26a32d0ffbcf9cfd7c80))
* restore unquote for user data parsing in telegram auth ([c2cabbe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c2cabbee097a41a95d16c34d43ab7e70d076c4dc))
### Reverts
* remove signature pop from HMAC validation ([4234769](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4234769e92104a6c4f8f1d522e1fca25bc7b20d0))
+3 -3
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.5.3"
ARG VERSION="v3.12.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
BUILD_DATE=${BUILD_DATE} \
VCS_REF=${VCS_REF}
EXPOSE 8080 8081 8082
EXPOSE 8080
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
@@ -57,6 +57,6 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.vendor="fr1ngg"
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["python", "main.py"]
+25
View File
@@ -1,3 +1,28 @@
"Commons Clause" License Condition v1.0
The Software is provided to you by the Licensor under the License,
as defined below, subject to the following condition.
Without limiting other conditions in the License, the grant of rights
under the License will not include, and the License does not grant to
you, the right to Sell the Software.
For purposes of the foregoing, "Sell" means practicing any or all of
the rights granted to you under the License to provide to third parties,
for a fee or other consideration (including without limitation fees for
hosting or consulting/support services related to the Software), a
product or service whose value derives, entirely or substantially, from
the functionality of the Software.
Any license notice or attribution required by the License must also
include this Commons Clause License Condition notice.
Software: remnawave-bedolaga-telegram-bot
License: MIT
Licensor: Fr1ngg
---
MIT License
Copyright (c) 2025 Fr1ngg
+49
View File
@@ -0,0 +1,49 @@
.PHONY: up
up: ## Поднять контейнеры (detached)
@echo "🚀 Поднимаем контейнеры (detached)..."
docker compose up -d --build
.PHONY: up-follow
up-follow: ## Поднять контейнеры с логами
@echo "📡 Поднимаем контейнеры (в консоли)..."
docker compose up --build
.PHONY: down
down: ## Остановить и удалить контейнеры
@echo "🛑 Останавливаем и удаляем контейнеры..."
docker compose down
.PHONY: reload
reload: ## Перезапустить контейнеры (detached)
@$(MAKE) down
@$(MAKE) up
.PHONY: reload-follow
reload-follow: ## Перезапустить контейнеры с логами
@$(MAKE) down
@$(MAKE) up-follow
.PHONY: test
test: ## Запустить тесты
uv run pytest -v
.PHONY: lint
lint: ## Проверить код (ruff check)
uv run ruff check .
.PHONY: format
format: ## Форматировать код (ruff format)
uv run ruff format .
.PHONY: fix
fix: ## Исправить код (ruff check --fix + format)
uv run ruff check . --fix
uv run ruff format .
.PHONY: help
help: ## Показать список доступных команд
@echo ""
@echo "📘 Команды Makefile:"
@echo ""
@awk -F':.*## ' '/^[a-zA-Z0-9_-]+:.*## / {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@echo ""
+1335 -686
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -136,8 +136,7 @@ logger.debug(f"Webhook payload: {webhook_data}")
# Открыть только необходимые порты
ufw allow 80/tcp # HTTP (redirect to HTTPS)
ufw allow 443/tcp # HTTPS
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
ufw deny 8082/tcp
ufw deny 8080/tcp # Unified FastAPI сервер доступен только из внутренней сети
```
### 📊 Мониторинг безопасности
@@ -239,7 +238,6 @@ REMNAWAVE_API_KEY=your_api_key
ADMIN_IDS=123456789,987654321
# Webhook секреты - генерируйте случайные значения
YOOKASSA_WEBHOOK_SECRET=random_secret_here
TRIBUTE_WEBHOOK_SECRET=another_random_secret
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
```
@@ -283,7 +281,7 @@ services:
# Не пробрасывайте порты наружу без необходимости
ports:
- "127.0.0.1:8081:8081" # Только localhost
- "127.0.0.1:8080:8080" # Только localhost
# Read-only root filesystem
read_only: true
+155 -83
View File
@@ -1,100 +1,115 @@
import logging
from aiogram import Bot, Dispatcher, types
from aiogram.fsm.storage.redis import RedisStorage
from aiogram.fsm.storage.memory import MemoryStorage
import redis.asyncio as redis
from aiogram import Bot, Dispatcher, types
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.storage.redis import RedisStorage
from app.config import settings
from app.middlewares.global_error import GlobalErrorMiddleware
from app.middlewares.auth import AuthMiddleware
from app.middlewares.logging import LoggingMiddleware
from app.middlewares.throttling import ThrottlingMiddleware
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
from app.services.maintenance_service import maintenance_service
from app.utils.cache import cache
from app.handlers import (
start, menu, subscription, balance, promocode,
referral, support, server_status, common, tickets
balance,
common,
contests as user_contests,
menu,
polls as user_polls,
promocode,
referral,
server_status,
simple_subscription,
start,
subscription,
support,
tickets,
)
from app.handlers import simple_subscription
from app.handlers.admin import (
backup as admin_backup,
blacklist as admin_blacklist,
blocked_users as admin_blocked_users,
bot_configuration as admin_bot_configuration,
bulk_ban as admin_bulk_ban,
campaigns as admin_campaigns,
contests as admin_contests,
daily_contests as admin_daily_contests,
faq as admin_faq,
main as admin_main,
users as admin_users,
subscriptions as admin_subscriptions,
promocodes as admin_promocodes,
maintenance as admin_maintenance,
messages as admin_messages,
monitoring as admin_monitoring,
referrals as admin_referrals,
rules as admin_rules,
remnawave as admin_remnawave,
statistics as admin_statistics,
servers as admin_servers,
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
campaigns as admin_campaigns,
promo_offers as admin_promo_offers,
user_messages as admin_user_messages,
updates as admin_updates,
backup as admin_backup,
system_logs as admin_system_logs,
welcome_text as admin_welcome_text,
tickets as admin_tickets,
reports as admin_reports,
bot_configuration as admin_bot_configuration,
payments as admin_payments,
polls as admin_polls,
pricing as admin_pricing,
privacy_policy as admin_privacy_policy,
promo_groups as admin_promo_groups,
promo_offers as admin_promo_offers,
promocodes as admin_promocodes,
public_offer as admin_public_offer,
faq as admin_faq,
referrals as admin_referrals,
remnawave as admin_remnawave,
reports as admin_reports,
rules as admin_rules,
servers as admin_servers,
statistics as admin_statistics,
subscriptions as admin_subscriptions,
system_logs as admin_system_logs,
tariffs as admin_tariffs,
tickets as admin_tickets,
trials as admin_trials,
updates as admin_updates,
user_messages as admin_user_messages,
users as admin_users,
welcome_text as admin_welcome_text,
)
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.global_error import GlobalErrorMiddleware
from app.middlewares.logging import LoggingMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
from app.middlewares.throttling import ThrottlingMiddleware
from app.services.maintenance_service import maintenance_service
from app.utils.cache import cache
from app.utils.message_patch import patch_message_methods
patch_message_methods()
logger = logging.getLogger(__name__)
async def debug_callback_handler(callback: types.CallbackQuery):
logger.info(f"🔍 DEBUG CALLBACK:")
logger.info(f" - Data: {callback.data}")
logger.info(f" - User: {callback.from_user.id}")
logger.info(f" - Username: {callback.from_user.username}")
logger.info('🔍 DEBUG CALLBACK:')
logger.info(f' - Data: {callback.data}')
logger.info(f' - User: {callback.from_user.id}')
logger.info(f' - Username: {callback.from_user.username}')
async def setup_bot() -> tuple[Bot, Dispatcher]:
try:
await cache.connect()
logger.info("Кеш инициализирован")
logger.info('Кеш инициализирован')
except Exception as e:
logger.warning(f"Кеш не инициализирован: {e}")
logger.warning(f'Кеш не инициализирован: {e}')
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
maintenance_service.set_bot(bot)
logger.info("Бот установлен в maintenance_service")
logger.info('Бот установлен в maintenance_service')
try:
redis_client = redis.from_url(settings.REDIS_URL)
await redis_client.ping()
storage = RedisStorage(redis_client)
logger.info("Подключено к Redis для FSM storage")
logger.info('Подключено к Redis для FSM storage')
except Exception as e:
logger.warning(f"Не удалось подключиться к Redis: {e}")
logger.info("Используется MemoryStorage для FSM")
logger.warning(f'Не удалось подключиться к Redis: {e}')
logger.info('Используется MemoryStorage для FSM')
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
dp.message.middleware(GlobalErrorMiddleware())
@@ -104,22 +119,28 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(MaintenanceMiddleware())
dp.callback_query.middleware(MaintenanceMiddleware())
display_name_middleware = DisplayNameRestrictionMiddleware()
dp.message.middleware(display_name_middleware)
dp.callback_query.middleware(display_name_middleware)
dp.pre_checkout_query.middleware(display_name_middleware)
blacklist_middleware = BlacklistMiddleware()
dp.message.middleware(blacklist_middleware)
dp.callback_query.middleware(blacklist_middleware)
dp.pre_checkout_query.middleware(blacklist_middleware)
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
# Middleware для автоматического логирования кликов по кнопкам
if settings.MENU_LAYOUT_ENABLED:
button_stats_middleware = ButtonStatsMiddleware()
dp.callback_query.middleware(button_stats_middleware)
logger.info('📊 ButtonStatsMiddleware активирован')
if settings.CHANNEL_IS_REQUIRED_SUB:
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 активирован")
logger.info('🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован')
else:
logger.info("🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован")
logger.info('🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован')
dp.message.middleware(AuthMiddleware())
dp.callback_query.middleware(AuthMiddleware())
dp.pre_checkout_query.middleware(AuthMiddleware())
@@ -137,7 +158,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_main.register_handlers(dp)
admin_users.register_handlers(dp)
admin_subscriptions.register_handlers(dp)
admin_servers.register_handlers(dp)
admin_servers.register_handlers(dp)
admin_promocodes.register_handlers(dp)
admin_messages.register_handlers(dp)
admin_monitoring.register_handlers(dp)
@@ -145,8 +166,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_rules.register_handlers(dp)
admin_remnawave.register_handlers(dp)
admin_statistics.register_handlers(dp)
admin_polls.register_handlers(dp)
admin_promo_groups.register_handlers(dp)
admin_campaigns.register_handlers(dp)
admin_contests.register_handlers(dp)
admin_daily_contests.register_handlers(dp)
admin_promo_offers.register_handlers(dp)
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
@@ -161,34 +185,82 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_privacy_policy.register_handlers(dp)
admin_public_offer.register_handlers(dp)
admin_faq.register_handlers(dp)
admin_payments.register_handlers(dp)
admin_trials.register_handlers(dp)
admin_tariffs.register_handlers(dp)
admin_bulk_ban.register_bulk_ban_handlers(dp)
admin_blacklist.register_blacklist_handlers(dp)
admin_blocked_users.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_contests.register_handlers(dp)
user_polls.register_handlers(dp)
simple_subscription.register_simple_subscription_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
logger.info("⚡ Зарегистрированы обработчики простой покупки")
logger.info("⚡ Зарегистрированы обработчики простой подписки")
try:
await maintenance_service.start_monitoring()
logger.info("Мониторинг техработ запущен")
except Exception as e:
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
logger.info("Бот успешно настроен")
logger.info('⭐ Зарегистрированы обработчики Telegram Stars платежей')
logger.info('⚡ Зарегистрированы обработчики простой покупки')
logger.info('⚡ Зарегистрированы обработчики простой подписки')
if settings.is_maintenance_monitoring_enabled():
try:
await maintenance_service.start_monitoring()
logger.info('Мониторинг техработ запущен')
except Exception as e:
logger.error(f'Ошибка запуска мониторинга техработ: {e}')
else:
logger.info('Мониторинг техработ отключен настройками')
logger.info('🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries')
# Validate CONNECT_BUTTON_MODE dependencies
if not settings.get_happ_cryptolink_redirect_template():
if settings.CONNECT_BUTTON_MODE == 'happ_cryptolink':
logger.warning(
'⚠️ CONNECT_BUTTON_MODE=happ_cryptolink, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
'Кнопка "Подключиться" не будет отображаться.'
)
elif settings.CONNECT_BUTTON_MODE == 'guide':
logger.warning(
'⚠️ CONNECT_BUTTON_MODE=guide, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
'Кнопка "Подключиться" в гайдах не будет работать — Telegram не поддерживает '
'кастомные схемы (happ://, v2ray://) в inline-кнопках без HTTPS-редиректа.'
)
if settings.CONNECT_BUTTON_MODE == 'miniapp_custom' and not settings.MINIAPP_CUSTOM_URL:
logger.warning(
'⚠️ CONNECT_BUTTON_MODE=miniapp_custom, но MINIAPP_CUSTOM_URL не задан! '
'Кнопка "Подключиться" не будет работать.'
)
if settings.is_cabinet_mode() and not settings.MINIAPP_CUSTOM_URL:
logger.warning(
'⚠️ MAIN_MENU_MODE=cabinet, но MINIAPP_CUSTOM_URL не задан! '
'Кнопки кабинета не смогут открывать разделы MiniApp. '
'Установите MINIAPP_CUSTOM_URL.'
)
elif settings.is_cabinet_mode():
logger.info(f'🏠 Режим Cabinet активен, базовый URL: {settings.MINIAPP_CUSTOM_URL}')
# Load per-section button styles cache
if settings.is_cabinet_mode():
try:
from app.utils.button_styles_cache import load_button_styles_cache
await load_button_styles_cache()
except Exception as e:
logger.warning(f'Failed to load button styles cache: {e}')
logger.info('Бот успешно настроен')
return bot, dp
async def shutdown_bot():
try:
await maintenance_service.stop_monitoring()
logger.info("Мониторинг техработ остановлен")
logger.info('Мониторинг техработ остановлен')
except Exception as e:
logger.error(f"Ошибка остановки мониторинга: {e}")
logger.error(f'Ошибка остановки мониторинга: {e}')
try:
await cache.close()
logger.info("Соединения с кешем закрыты")
logger.info('Соединения с кешем закрыты')
except Exception as e:
logger.error(f"Ошибка закрытия кеша: {e}")
logger.error(f'Ошибка закрытия кеша: {e}')
+10
View File
@@ -0,0 +1,10 @@
"""
Cabinet module - Personal Account for VPN Bot users.
This module provides:
- JWT-based authentication (Telegram + Email)
- Subscription management
- Balance & payments
- Referral program
- Support tickets
"""
+22
View File
@@ -0,0 +1,22 @@
"""Cabinet authentication module."""
from .jwt_handler import (
create_access_token,
create_refresh_token,
decode_token,
get_token_payload,
)
from .password_utils import hash_password, verify_password
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
__all__ = [
'create_access_token',
'create_refresh_token',
'decode_token',
'get_token_payload',
'hash_password',
'validate_telegram_init_data',
'validate_telegram_login_widget',
'verify_password',
]
+84
View File
@@ -0,0 +1,84 @@
"""Email verification token generation and validation."""
import secrets
from datetime import datetime, timedelta
from app.config import settings
def generate_email_change_code() -> str:
"""
Generate a 6-digit verification code for email change.
Returns:
6-digit numeric string
"""
return str(secrets.randbelow(900000) + 100000)
def get_email_change_expires_at() -> datetime:
"""
Get the expiration datetime for an email change code.
Returns:
Datetime when the email change code expires
"""
minutes = settings.get_cabinet_email_change_code_expire_minutes()
return datetime.utcnow() + timedelta(minutes=minutes)
def generate_verification_token() -> str:
"""
Generate a secure random verification token.
Returns:
32-character hex token string
"""
return secrets.token_hex(32)
def generate_password_reset_token() -> str:
"""
Generate a secure random password reset token.
Returns:
32-character hex token string
"""
return secrets.token_hex(32)
def get_verification_expires_at() -> datetime:
"""
Get the expiration datetime for a verification token.
Returns:
Datetime when the verification token expires
"""
hours = settings.get_cabinet_email_verification_expire_hours()
return datetime.utcnow() + timedelta(hours=hours)
def get_password_reset_expires_at() -> datetime:
"""
Get the expiration datetime for a password reset token.
Returns:
Datetime when the password reset token expires
"""
hours = settings.get_cabinet_password_reset_expire_hours()
return datetime.utcnow() + timedelta(hours=hours)
def is_token_expired(expires_at: datetime | None) -> bool:
"""
Check if a token has expired.
Args:
expires_at: Token expiration datetime
Returns:
True if expired or no expiration set, False otherwise
"""
if expires_at is None:
return True
return datetime.utcnow() > expires_at
+111
View File
@@ -0,0 +1,111 @@
"""JWT token handling for cabinet authentication."""
from datetime import datetime, timedelta
from typing import Any
import jwt
from app.config import settings
JWT_ALGORITHM = 'HS256'
def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
"""
Create a short-lived access token.
Args:
user_id: Database user ID
telegram_id: Telegram user ID (optional for email-only users)
Returns:
Encoded JWT access token
"""
expire_minutes = settings.get_cabinet_access_token_expire_minutes()
expires = datetime.utcnow() + timedelta(minutes=expire_minutes)
payload = {
'sub': str(user_id),
'type': 'access',
'exp': expires,
'iat': datetime.utcnow(),
}
# Добавляем telegram_id только если он есть
if telegram_id is not None:
payload['telegram_id'] = telegram_id
secret = settings.get_cabinet_jwt_secret()
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
def create_refresh_token(user_id: int) -> str:
"""
Create a long-lived refresh token.
Args:
user_id: Database user ID
Returns:
Encoded JWT refresh token
"""
expire_days = settings.get_cabinet_refresh_token_expire_days()
expires = datetime.utcnow() + timedelta(days=expire_days)
payload = {
'sub': str(user_id),
'type': 'refresh',
'exp': expires,
'iat': datetime.utcnow(),
}
secret = settings.get_cabinet_jwt_secret()
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
def decode_token(token: str) -> dict[str, Any] | None:
"""
Decode and validate a JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict or None if invalid/expired
"""
try:
secret = settings.get_cabinet_jwt_secret()
return jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, Any] | None:
"""
Decode token and verify its type.
Args:
token: JWT token string
expected_type: Expected token type ("access" or "refresh")
Returns:
Decoded payload dict or None if invalid/expired/wrong type
"""
payload = decode_token(token)
if not payload:
return None
if payload.get('type') != expected_type:
return None
return payload
def get_refresh_token_expires_at() -> datetime:
"""Get the expiration datetime for a new refresh token."""
expire_days = settings.get_cabinet_refresh_token_expire_days()
return datetime.utcnow() + timedelta(days=expire_days)
+429
View File
@@ -0,0 +1,429 @@
"""OAuth 2.0 provider implementations for cabinet authentication."""
import logging
import secrets
from abc import ABC, abstractmethod
from typing import Any, TypedDict
import httpx
from pydantic import BaseModel
from app.config import settings
from app.utils.cache import cache, cache_key
logger = logging.getLogger(__name__)
STATE_TTL_SECONDS = 600 # 10 minutes
# --- Typed dicts for provider API responses ---
class OAuthProviderConfig(TypedDict):
client_id: str
client_secret: str
enabled: bool
display_name: str
class OAuthTokenResponse(TypedDict, total=False):
access_token: str
token_type: str
expires_in: int
refresh_token: str
scope: str
# VK-specific: email and user_id come in token response
email: str
user_id: int
class GoogleUserInfoResponse(TypedDict, total=False):
sub: str
email: str
email_verified: bool
given_name: str
family_name: str
picture: str
name: str
class YandexUserInfoResponse(TypedDict, total=False):
id: str
login: str
default_email: str
emails: list[str]
first_name: str
last_name: str
default_avatar_id: str
class DiscordUserInfoResponse(TypedDict, total=False):
id: str
username: str
global_name: str
email: str
verified: bool
avatar: str
class VKUserInfoItem(TypedDict, total=False):
id: int
first_name: str
last_name: str
photo_200: str
class VKUserInfoResponse(TypedDict, total=False):
response: list[VKUserInfoItem]
# --- Models ---
class OAuthUserInfo(BaseModel):
"""Normalized user info from OAuth provider."""
provider: str
provider_id: str
email: str | None = None
email_verified: bool = False
first_name: str | None = None
last_name: str | None = None
username: str | None = None
avatar_url: str | None = None
# --- 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."""
state = secrets.token_urlsafe(32)
await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS)
return state
async def validate_oauth_state(state: str, provider: str) -> bool:
"""Validate and consume a CSRF state token from Redis."""
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
# --- Provider implementations ---
class OAuthProvider(ABC):
"""Base class for OAuth 2.0 providers."""
name: str
display_name: str
def __init__(self, client_id: str, client_secret: str, redirect_uri: str) -> None:
self.client_id = client_id
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."""
@abstractmethod
async def exchange_code(self, code: str) -> OAuthTokenResponse:
"""Exchange authorization code for tokens."""
@abstractmethod
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
"""Fetch user info from the provider."""
class GoogleProvider(OAuthProvider):
name = 'google'
display_name = 'Google'
AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
TOKEN_URL = 'https://oauth2.googleapis.com/token'
USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
def get_authorization_url(self, state: str) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'openid email profile',
'state': state,
'access_type': 'offline',
'prompt': 'select_account',
}
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
json={
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'grant_type': 'authorization_code',
'redirect_uri': self.redirect_uri,
},
)
response.raise_for_status()
data: OAuthTokenResponse = response.json()
return data
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
access_token = token_data['access_token']
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
self.USERINFO_URL,
headers={'Authorization': f'Bearer {access_token}'},
)
response.raise_for_status()
data: GoogleUserInfoResponse = response.json()
return OAuthUserInfo(
provider='google',
provider_id=str(data['sub']),
email=data.get('email'),
email_verified=data.get('email_verified', False),
first_name=data.get('given_name'),
last_name=data.get('family_name'),
avatar_url=data.get('picture'),
)
class YandexProvider(OAuthProvider):
name = 'yandex'
display_name = 'Yandex'
AUTHORIZE_URL = 'https://oauth.yandex.com/authorize'
TOKEN_URL = 'https://oauth.yandex.com/token'
USERINFO_URL = 'https://login.yandex.ru/info'
def get_authorization_url(self, state: str) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'login:info login:email',
'state': state,
'force_confirm': 'yes',
}
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
data={
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'grant_type': 'authorization_code',
},
)
response.raise_for_status()
data: OAuthTokenResponse = response.json()
return data
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
access_token = token_data['access_token']
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
self.USERINFO_URL,
params={'format': 'json'},
headers={'Authorization': f'OAuth {access_token}'},
)
response.raise_for_status()
data: YandexUserInfoResponse = response.json()
default_email = data.get('default_email')
emails = data.get('emails', [])
email = default_email or (emails[0] if emails else None)
return OAuthUserInfo(
provider='yandex',
provider_id=str(data['id']),
email=email,
email_verified=bool(email),
first_name=data.get('first_name'),
last_name=data.get('last_name'),
username=data.get('login'),
avatar_url=(
f'https://avatars.yandex.net/get-yapic/{data["default_avatar_id"]}/islands-200'
if data.get('default_avatar_id')
else None
),
)
class DiscordProvider(OAuthProvider):
name = 'discord'
display_name = 'Discord'
AUTHORIZE_URL = 'https://discord.com/api/oauth2/authorize'
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:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'identify email',
'state': state,
'prompt': 'consent',
}
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
data={
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'grant_type': 'authorization_code',
'redirect_uri': self.redirect_uri,
},
)
response.raise_for_status()
data: OAuthTokenResponse = response.json()
return data
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
access_token = token_data['access_token']
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
self.USERINFO_URL,
headers={'Authorization': f'Bearer {access_token}'},
)
response.raise_for_status()
data: DiscordUserInfoResponse = response.json()
avatar_url: str | None = None
if data.get('avatar'):
avatar_url = f'https://cdn.discordapp.com/avatars/{data["id"]}/{data["avatar"]}.png'
return OAuthUserInfo(
provider='discord',
provider_id=str(data['id']),
email=data.get('email'),
email_verified=data.get('verified', False),
first_name=data.get('global_name') or data.get('username'),
username=data.get('username'),
avatar_url=avatar_url,
)
class VKProvider(OAuthProvider):
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'
def get_authorization_url(self, state: str) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'email',
'state': state,
'v': self.API_VERSION,
}
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
self.TOKEN_URL,
params={
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'redirect_uri': self.redirect_uri,
},
)
response.raise_for_status()
data: OAuthTokenResponse = response.json()
return data
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(
self.USERINFO_URL,
params={
'access_token': access_token,
'fields': 'photo_200',
'v': self.API_VERSION,
},
)
response.raise_for_status()
data: VKUserInfoResponse = response.json()
users: list[Any] = data.get('response', [])
user_data: VKUserInfoItem = users[0] if users else {} # type: ignore[assignment]
return OAuthUserInfo(
provider='vk',
provider_id=str(user_id or user_data.get('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'),
)
# --- Provider factory ---
_PROVIDERS: dict[str, type[OAuthProvider]] = {
'google': GoogleProvider,
'yandex': YandexProvider,
'discord': DiscordProvider,
'vk': VKProvider,
}
def get_provider(name: str) -> OAuthProvider | None:
"""Get an OAuth provider instance if enabled.
Returns None if the provider is not enabled or not found.
"""
providers_config: dict[str, OAuthProviderConfig] = settings.get_oauth_providers_config()
config = providers_config.get(name)
if not config or not config['enabled']:
return None
provider_class = _PROVIDERS.get(name)
if not provider_class:
return None
redirect_uri = f'{settings.CABINET_URL}/auth/oauth/callback'
return provider_class(
client_id=config['client_id'],
client_secret=config['client_secret'],
redirect_uri=redirect_uri,
)
+41
View File
@@ -0,0 +1,41 @@
"""Password hashing utilities using bcrypt."""
import bcrypt
BCRYPT_ROUNDS = 12
def hash_password(password: str) -> str:
"""
Hash a password using bcrypt.
Args:
password: Plain text password
Returns:
Hashed password string
"""
password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode('utf-8')
def verify_password(password: str, password_hash: str) -> bool:
"""
Verify a password against its hash.
Args:
password: Plain text password to verify
password_hash: Previously hashed password
Returns:
True if password matches, False otherwise
"""
try:
password_bytes = password.encode('utf-8')
hash_bytes = password_hash.encode('utf-8')
return bcrypt.checkpw(password_bytes, hash_bytes)
except (ValueError, TypeError):
return False
+127
View File
@@ -0,0 +1,127 @@
"""Telegram authentication validation for cabinet."""
import hashlib
import hmac
import json
from datetime import datetime
from typing import Any
from urllib.parse import parse_qsl, unquote
from app.config import settings
def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int = 86400) -> bool:
"""
Validate Telegram Login Widget data.
https://core.telegram.org/widgets/login#checking-authorization
Args:
data: Dictionary with Telegram login data (id, first_name, auth_date, hash, etc.)
max_age_seconds: Maximum allowed age of auth_date (default 24 hours)
Returns:
True if data is valid, False otherwise
"""
auth_data = data.copy()
check_hash = auth_data.pop('hash', None)
if not check_hash:
return False
# Check auth_date is not too old
auth_date = auth_data.get('auth_date')
if auth_date:
try:
# Use UTC timestamp to avoid timezone issues
auth_time = datetime.utcfromtimestamp(int(auth_date))
age = (datetime.utcnow() - auth_time).total_seconds()
if age > max_age_seconds:
return False
except (ValueError, TypeError, OSError):
return False
# Build data-check-string (sorted key=value pairs, newline-separated)
data_check_arr = [f'{k}={v}' for k, v in sorted(auth_data.items()) if v is not None]
data_check_string = '\n'.join(data_check_arr)
# Create secret key from bot token using SHA256
bot_token = settings.BOT_TOKEN
secret_key = hashlib.sha256(bot_token.encode()).digest()
# Calculate expected hash
calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(calculated_hash, check_hash)
def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) -> dict[str, Any] | None:
"""
Validate Telegram WebApp initData.
https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
Args:
init_data: Raw initData string from Telegram WebApp
max_age_seconds: Maximum allowed age of auth_date (default 24 hours)
Returns:
Parsed user data dict if valid, None otherwise
"""
try:
# Parse the init_data string
parsed = dict(parse_qsl(init_data, keep_blank_values=True))
received_hash = parsed.pop('hash', None)
if not received_hash:
return None
# Check auth_date is not too old
auth_date = parsed.get('auth_date')
if auth_date:
try:
# Use UTC timestamp to avoid timezone issues
auth_time = datetime.utcfromtimestamp(int(auth_date))
age = (datetime.utcnow() - auth_time).total_seconds()
if age > max_age_seconds:
return None
except (ValueError, TypeError, OSError):
return None
# Build data-check-string
data_check_arr = [f'{k}={v}' for k, v in sorted(parsed.items())]
data_check_string = '\n'.join(data_check_arr)
# Create secret key: HMAC_SHA256(bot_token, "WebAppData")
bot_token = settings.BOT_TOKEN
secret_key = hmac.new(b'WebAppData', bot_token.encode(), hashlib.sha256).digest()
# Calculate expected hash
calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(calculated_hash, received_hash):
return None
# Parse user data from the validated data
user_data_str = parsed.get('user')
if user_data_str:
user_data = json.loads(unquote(user_data_str))
return user_data
return parsed
except (ValueError, TypeError, json.JSONDecodeError):
return None
def extract_telegram_user_from_init_data(init_data: str) -> dict[str, Any] | None:
"""
Extract and validate user info from Telegram WebApp initData.
Args:
init_data: Raw initData string from Telegram WebApp
Returns:
User data dict with id, first_name, last_name, username, etc. or None if invalid
"""
return validate_telegram_init_data(init_data)
+228
View File
@@ -0,0 +1,228 @@
"""FastAPI dependencies for cabinet module."""
import asyncio
import logging
from aiogram import Bot
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.user import get_user_by_id
from app.database.database import AsyncSessionLocal
from app.database.models import User
from app.services.blacklist_service import blacklist_service
from app.services.maintenance_service import maintenance_service
from .auth.jwt_handler import get_token_payload
logger = logging.getLogger(__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."""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def get_current_cabinet_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
"""
Get current authenticated cabinet user from JWT token.
Args:
credentials: HTTP Bearer credentials
db: Database session
Returns:
Authenticated User object
Raises:
HTTPException: If token is invalid, expired, or user not found
"""
# Check maintenance mode first (except for admins - checked later)
if maintenance_service.is_maintenance_active():
# We need to check token first to see if user is admin
pass # Will check after getting user
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Authentication required',
headers={'WWW-Authenticate': 'Bearer'},
)
token = credentials.credentials
payload = get_token_payload(token, expected_type='access')
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired token',
headers={'WWW-Authenticate': 'Bearer'},
)
try:
user_id = int(payload.get('sub'))
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid token payload',
headers={'WWW-Authenticate': 'Bearer'},
)
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
if user.status != 'active':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User account is not active',
)
# Check blacklist
if user.telegram_id is not None:
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
if is_blacklisted:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'blacklisted',
'message': reason or 'Доступ запрещен',
},
)
# Check maintenance mode (allow admins to pass)
if maintenance_service.is_maintenance_active():
# Проверяем админа по telegram_id ИЛИ email
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
if not is_admin:
status_info = maintenance_service.get_status_info()
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
'code': 'maintenance',
'message': maintenance_service.get_maintenance_message() or 'Service is under maintenance',
'reason': status_info.get('reason'),
},
)
# Check required channel subscription - ТОЛЬКО для Telegram юзеров
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
# Пропускаем проверку для email-only юзеров (нет telegram_id)
if user.telegram_id is not None:
# Проверяем админа по telegram_id ИЛИ email
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,
)
# Не закрываем сессию - бот переиспользуется
if chat_member.status not in ['member', 'administrator', 'creator']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to our channel to continue',
'channel_link': settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except TimeoutError:
logger.warning(f'Timeout checking channel subscription for user {user.telegram_id}')
# Don't block user if check times out
except Exception as e:
logger.warning(f'Failed to check channel subscription for user {user.telegram_id}: {e}')
# Don't block user if check fails
return user
async def get_optional_cabinet_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User | None:
"""
Optionally get current authenticated cabinet user.
Returns None if no valid token is provided instead of raising an exception.
"""
if not credentials:
return None
token = credentials.credentials
payload = get_token_payload(token, expected_type='access')
if not payload:
return None
try:
user_id = int(payload.get('sub'))
except (TypeError, ValueError):
return None
user = await get_user_by_id(db, user_id)
if not user or user.status != 'active':
return None
return user
async def get_current_admin_user(
user: User = Depends(get_current_cabinet_user),
) -> User:
"""
Get current authenticated admin user.
Checks if the user is admin by telegram_id or email.
Args:
user: Authenticated User object
Returns:
Authenticated admin User object
Raises:
HTTPException: If user is not an admin
"""
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',
)
return user
+100
View File
@@ -0,0 +1,100 @@
"""Cabinet API routes."""
from fastapi import APIRouter
from .admin_apps import router as admin_apps_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_email_templates import router as admin_email_templates_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_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_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
from .admin_tariffs import router as admin_tariffs_router
from .admin_tickets import router as admin_tickets_router
from .admin_traffic import router as admin_traffic_router
from .admin_updates import router as admin_updates_router
from .admin_users import router as admin_users_router
from .admin_wheel import router as admin_wheel_router
from .auth import router as auth_router
from .balance import router as balance_router
from .branding import router as branding_router
from .contests import router as contests_router
from .info import router as info_router
from .media import router as media_router
from .notifications import router as notifications_router
from .oauth import router as oauth_router
from .polls import router as polls_router
from .promo import router as promo_router
from .promocode import router as promocode_router
from .referral import router as referral_router
from .subscription import router as subscription_router
from .ticket_notifications import (
admin_router as admin_ticket_notifications_router,
router as ticket_notifications_router,
)
from .tickets import router as tickets_router
from .websocket import router as websocket_router
from .wheel import router as wheel_router
# Main cabinet router
router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
# Include all sub-routers
router.include_router(auth_router)
router.include_router(oauth_router)
router.include_router(subscription_router)
router.include_router(balance_router)
router.include_router(referral_router)
# Notifications router MUST be before tickets router to avoid route conflict
router.include_router(ticket_notifications_router)
router.include_router(tickets_router)
router.include_router(promocode_router)
router.include_router(contests_router)
router.include_router(polls_router)
router.include_router(promo_router)
router.include_router(notifications_router)
router.include_router(info_router)
router.include_router(branding_router)
router.include_router(media_router)
# Wheel routes
router.include_router(wheel_router)
# Admin routes (notifications router MUST be before tickets router to avoid route conflict)
router.include_router(admin_ticket_notifications_router)
router.include_router(admin_tickets_router)
router.include_router(admin_settings_router)
router.include_router(admin_apps_router)
router.include_router(admin_wheel_router)
router.include_router(admin_tariffs_router)
router.include_router(admin_servers_router)
router.include_router(admin_stats_router)
router.include_router(admin_ban_system_router)
router.include_router(admin_broadcasts_router)
router.include_router(admin_promocodes_router)
router.include_router(admin_promo_groups_router)
router.include_router(admin_campaigns_router)
router.include_router(admin_users_router)
router.include_router(admin_payment_methods_router)
router.include_router(admin_payments_router)
router.include_router(admin_promo_offers_router)
router.include_router(admin_remnawave_router)
router.include_router(admin_email_templates_router)
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)
# WebSocket route
router.include_router(websocket_router)
__all__ = ['router']
+579
View File
@@ -0,0 +1,579 @@
"""Admin routes for managing VPN applications in app-config.json."""
import json
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.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
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
# ============ Schemas ============
class LocalizedText(BaseModel):
"""Localized text for multiple languages."""
en: str = ''
ru: str = ''
zh: str | None = ''
fa: str | None = ''
class AppButton(BaseModel):
"""Button with link and localized text."""
buttonLink: str
buttonText: LocalizedText
class AppStep(BaseModel):
"""Step with description and optional buttons/title."""
description: LocalizedText
buttons: list[AppButton] | None = None
title: LocalizedText | None = None
class AppDefinition(BaseModel):
"""VPN application definition."""
id: str
name: str
isFeatured: bool = False
urlScheme: str
isNeedBase64Encoding: bool | None = None
installationStep: AppStep
addSubscriptionStep: AppStep
connectAndUseStep: AppStep
additionalBeforeAddSubscriptionStep: AppStep | None = None
additionalAfterAddSubscriptionStep: AppStep | None = None
class PlatformApps(BaseModel):
"""Apps for a specific platform."""
platform: str
apps: list[AppDefinition]
class AppConfigBranding(BaseModel):
"""Branding configuration."""
name: str
logoUrl: str
supportUrl: str
class AppConfigConfig(BaseModel):
"""Top-level config section."""
additionalLocales: list[str]
branding: AppConfigBranding
class AppConfigResponse(BaseModel):
"""Full app config response."""
config: AppConfigConfig
platforms: dict[str, list[AppDefinition]]
class CreateAppRequest(BaseModel):
"""Request to create a new app."""
platform: str
app: AppDefinition
class UpdateAppRequest(BaseModel):
"""Request to update an app."""
app: AppDefinition
class ReorderAppsRequest(BaseModel):
"""Request to reorder apps in a platform."""
app_ids: list[str]
class UpdateBrandingRequest(BaseModel):
"""Request to update branding."""
branding: AppConfigBranding
# ============ Helpers ============
def _get_config_path() -> Path:
"""Get path to app-config.json."""
return Path(settings.get_app_config_path())
def _load_config() -> dict:
"""Load app config from file."""
config_path = _get_config_path()
if not config_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'App config file not found: {config_path}',
)
try:
with open(config_path, encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to parse app config: {e}',
)
def _save_config(config: dict) -> None:
"""Save app config to file."""
config_path = _get_config_path()
try:
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2, ensure_ascii=False)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to save app config: {e}',
)
VALID_PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']
# ============ Routes ============
@router.get('', response_model=AppConfigResponse)
async def get_app_config(
admin: User = Depends(get_current_admin_user),
):
"""Get full app configuration."""
config = _load_config()
return config
@router.get('/platforms', response_model=list[str])
async def get_platforms(
admin: User = Depends(get_current_admin_user),
):
"""Get list of available platforms."""
return VALID_PLATFORMS
@router.get('/platforms/{platform}', response_model=list[AppDefinition])
async def get_platform_apps(
platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Get apps for a specific platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}',
)
config = _load_config()
platforms = config.get('platforms', {})
return platforms.get(platform, [])
@router.post('/platforms/{platform}', response_model=AppDefinition)
async def create_app(
platform: str,
request: CreateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Create a new app for a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
if platform not in platforms:
platforms[platform] = []
# Check if app with same ID already exists
existing_ids = [app.get('id') for app in platforms[platform]]
if request.app.id in existing_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App with ID '{request.app.id}' already exists in {platform}",
)
# Add new app
app_dict = request.app.model_dump(exclude_none=True)
platforms[platform].append(app_dict)
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'")
return request.app
@router.put('/platforms/{platform}/{app_id}', response_model=AppDefinition)
async def update_app(
platform: str,
app_id: str,
request: UpdateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update an existing app."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Find and update app
app_index = None
for i, app in enumerate(apps):
if app.get('id') == app_id:
app_index = i
break
if app_index is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Update app
app_dict = request.app.model_dump(exclude_none=True)
apps[app_index] = app_dict
platforms[platform] = apps
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'")
return request.app
@router.delete('/platforms/{platform}/{app_id}')
async def delete_app(
platform: str,
app_id: str,
admin: User = Depends(get_current_admin_user),
):
"""Delete an app from a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Find and remove app
original_length = len(apps)
apps = [app for app in apps if app.get('id') != app_id]
if len(apps) == original_length:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
platforms[platform] = apps
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'")
return {'status': 'deleted', 'app_id': app_id}
@router.post('/platforms/{platform}/reorder')
async def reorder_apps(
platform: str,
request: ReorderAppsRequest,
admin: User = Depends(get_current_admin_user),
):
"""Reorder apps in a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Create a map of apps by ID
apps_map = {app.get('id'): app for app in apps}
# Verify all IDs exist
for app_id in request.app_ids:
if app_id not in apps_map:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Reorder apps
reordered_apps = [apps_map[app_id] for app_id in request.app_ids]
# Add any apps that weren't in the reorder list (shouldn't happen but just in case)
for app in apps:
if app.get('id') not in request.app_ids:
reordered_apps.append(app)
platforms[platform] = reordered_apps
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'")
return {'status': 'reordered', 'order': request.app_ids}
@router.put('/branding', response_model=AppConfigBranding)
async def update_branding(
request: UpdateBrandingRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update branding configuration."""
config = _load_config()
if 'config' not in config:
config['config'] = {}
config['config']['branding'] = request.branding.model_dump()
_save_config(config)
logger.info(f'Admin {admin.id} updated branding')
return request.branding
@router.get('/branding', response_model=AppConfigBranding)
async def get_branding(
admin: User = Depends(get_current_admin_user),
):
"""Get branding configuration."""
config = _load_config()
branding = config.get('config', {}).get('branding', {})
return branding
@router.post('/platforms/{platform}/copy/{app_id}')
async def copy_app_to_platform(
platform: str,
app_id: str,
target_platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Copy an app from one platform to another."""
if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid platform(s)',
)
config = _load_config()
platforms = config.get('platforms', {})
source_apps = platforms.get(platform, [])
# Find source app
source_app = None
for app in source_apps:
if app.get('id') == app_id:
source_app = app.copy()
break
if not source_app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Generate new ID for copied app
import time
new_id = f'{app_id}-copy-{int(time.time())}'
source_app['id'] = new_id
# Add to target platform
if target_platform not in platforms:
platforms[target_platform] = []
platforms[target_platform].append(source_app)
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'")
return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform}
# ============ RemnaWave Config Routes ============
class RemnaWaveConfigStatus(BaseModel):
"""Status of RemnaWave config integration."""
enabled: bool
config_uuid: str | None = None
class UpdateRemnaWaveUuidRequest(BaseModel):
"""Request to update RemnaWave config UUID."""
uuid: str | None = None
def _get_remnawave_config_uuid() -> str | None:
"""Get RemnaWave config UUID from system settings or env."""
try:
return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
except Exception:
return settings.CABINET_REMNA_SUB_CONFIG
@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus)
async def get_remnawave_config_status(
admin: User = Depends(get_current_admin_user),
):
"""Get RemnaWave config integration status."""
config_uuid = _get_remnawave_config_uuid()
return RemnaWaveConfigStatus(
enabled=bool(config_uuid),
config_uuid=config_uuid,
)
@router.put('/remnawave/uuid', response_model=RemnaWaveConfigStatus)
async def set_remnawave_config_uuid(
request: UpdateRemnaWaveUuidRequest,
admin: User = Depends(get_current_admin_user),
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',
)
try:
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value)
await db.commit()
logger.info(f"Admin {admin.id} updated CABINET_REMNA_SUB_CONFIG to '{uuid_value}'")
except Exception as e:
logger.error(f'Error saving RemnaWave config UUID: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to save configuration',
)
return RemnaWaveConfigStatus(
enabled=bool(uuid_value),
config_uuid=uuid_value,
)
@router.get('/remnawave/config')
async def get_remnawave_subscription_config(
admin: User = Depends(get_current_admin_user),
):
"""
Fetch subscription page config from RemnaWave panel.
Uses CABINET_REMNA_SUB_CONFIG setting for the config UUID.
"""
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',
)
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
config = await api.get_subscription_page_config(config_uuid)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Subscription config '{config_uuid}' not found in RemnaWave",
)
# Return the raw config data from RemnaWave
return {
'uuid': config.uuid,
'name': config.name,
'view_position': config.view_position,
'config': config.config,
}
except HTTPException:
raise
except Exception as e:
logger.error(f'Error fetching RemnaWave config: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch config from RemnaWave: {e!s}',
)
@router.get('/remnawave/configs')
async def list_remnawave_subscription_configs(
admin: User = Depends(get_current_admin_user),
):
"""List available subscription page configs from RemnaWave panel."""
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
configs = await api.get_subscription_page_configs()
return [
{
'uuid': c.uuid,
'name': c.name,
'view_position': c.view_position,
}
for c in configs
]
except Exception as e:
logger.error(f'Error listing RemnaWave configs: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch configs from RemnaWave: {e!s}',
)
File diff suppressed because it is too large Load Diff
+726
View File
@@ -0,0 +1,726 @@
"""Admin routes for broadcasts in cabinet."""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import BroadcastHistory, Subscription, SubscriptionStatus, Tariff, User
from app.handlers.admin.messages import get_target_users_count
from app.keyboards.admin import BROADCAST_BUTTONS, DEFAULT_BROADCAST_BUTTONS
from app.services.broadcast_service import (
BroadcastConfig,
BroadcastMediaConfig,
EmailBroadcastConfig,
broadcast_service,
email_broadcast_service,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.broadcasts import (
BroadcastButton,
BroadcastButtonsResponse,
BroadcastCreateRequest,
BroadcastFilter,
BroadcastFiltersResponse,
BroadcastListResponse,
BroadcastPreviewRequest,
BroadcastPreviewResponse,
BroadcastResponse,
BroadcastTariffsResponse,
CombinedBroadcastCreateRequest,
EmailFilterItem,
EmailFiltersResponse,
EmailPreviewRequest,
EmailPreviewResponse,
TariffFilter,
TariffForBroadcast,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/broadcasts', tags=['Cabinet Admin Broadcasts'])
# ============ Filter Labels ============
FILTER_LABELS = {
'all': 'Все пользователи',
'active': 'Активные подписки',
'trial': 'Триальные',
'no': 'Без подписки',
'expiring': 'Истекают (3 дня)',
'expired': 'Истекшие',
'zero': 'Нулевой трафик',
'active_zero': 'Активные с нулевым трафиком',
'trial_zero': 'Триальные с нулевым трафиком',
}
FILTER_GROUPS = {
'all': 'basic',
'active': 'subscription',
'trial': 'subscription',
'no': 'subscription',
'expiring': 'subscription',
'expired': 'subscription',
'zero': 'traffic',
'active_zero': 'traffic',
'trial_zero': 'traffic',
}
CUSTOM_FILTER_LABELS = {
'custom_today': 'Регистрация сегодня',
'custom_week': 'Регистрация за неделю',
'custom_month': 'Регистрация за месяц',
'custom_active_today': 'Активны сегодня',
'custom_inactive_week': 'Неактивны 7+ дней',
'custom_inactive_month': 'Неактивны 30+ дней',
'custom_referrals': 'Пришли по рефералу',
'custom_direct': 'Прямая регистрация',
}
CUSTOM_FILTER_GROUPS = {
'custom_today': 'registration',
'custom_week': 'registration',
'custom_month': 'registration',
'custom_active_today': 'activity',
'custom_inactive_week': 'activity',
'custom_inactive_month': 'activity',
'custom_referrals': 'source',
'custom_direct': 'source',
}
# ============ Email Filter Labels ============
EMAIL_FILTER_LABELS = {
'all_email': 'Все с email',
'email_only': 'Только email-регистрация',
'telegram_with_email': 'Telegram с email',
'active_email': 'С активной подпиской',
'expired_email': 'С истекшей подпиской',
}
EMAIL_FILTER_GROUPS = {
'all_email': 'basic',
'email_only': 'auth_type',
'telegram_with_email': 'auth_type',
'active_email': 'subscription',
'expired_email': 'subscription',
}
# ============ Helper Functions ============
def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
"""Serialize broadcast to response model."""
progress = 0.0
if broadcast.total_count > 0:
progress = round((broadcast.sent_count + broadcast.failed_count) / broadcast.total_count * 100, 1)
return BroadcastResponse(
id=broadcast.id,
target_type=broadcast.target_type,
message_text=broadcast.message_text,
has_media=broadcast.has_media,
media_type=broadcast.media_type,
media_file_id=broadcast.media_file_id,
media_caption=broadcast.media_caption,
total_count=broadcast.total_count,
sent_count=broadcast.sent_count,
failed_count=broadcast.failed_count,
status=broadcast.status,
admin_id=broadcast.admin_id,
admin_name=broadcast.admin_name,
created_at=broadcast.created_at,
completed_at=broadcast.completed_at,
progress_percent=progress,
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
email_subject=getattr(broadcast, 'email_subject', None),
email_html_content=getattr(broadcast, 'email_html_content', None),
)
async def _get_email_filter_count(db: AsyncSession, target: str) -> int:
"""Get count of email users matching the filter."""
base_conditions = [
User.email.isnot(None),
User.email_verified == True,
User.status == 'active',
]
if target == 'all_email':
query = select(func.count(User.id)).where(*base_conditions)
elif target == 'email_only':
query = select(func.count(User.id)).where(
*base_conditions,
User.auth_type == 'email',
)
elif target == 'telegram_with_email':
query = select(func.count(User.id)).where(
*base_conditions,
User.auth_type == 'telegram',
User.telegram_id.isnot(None),
)
elif target == 'active_email':
query = (
select(func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
*base_conditions,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
elif target == 'expired_email':
query = (
select(func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
*base_conditions,
Subscription.status.in_(
[
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
]
),
)
)
else:
return 0
result = await db.execute(query)
return result.scalar() or 0
def _validate_email_target(target: str) -> bool:
"""Validate email target filter."""
return target in EMAIL_FILTER_LABELS
async def _get_tariff_user_counts(db: AsyncSession) -> dict:
"""Get count of active users per tariff."""
result = await db.execute(
select(Subscription.tariff_id, func.count(func.distinct(Subscription.user_id)).label('count'))
.join(User, User.id == Subscription.user_id)
.where(
User.status == 'active',
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
.group_by(Subscription.tariff_id)
)
return {row.tariff_id: row.count for row in result.all()}
def _validate_target(target: str, tariff_ids: set) -> bool:
"""Validate target value."""
if target in FILTER_LABELS:
return True
if target in CUSTOM_FILTER_LABELS:
return True
if target.startswith('tariff_'):
try:
tariff_id = int(target.split('_')[1])
return tariff_id in tariff_ids
except (ValueError, IndexError):
return False
return False
def _validate_buttons(buttons: list[str]) -> bool:
"""Validate button keys."""
return all(button in BROADCAST_BUTTONS for button in buttons)
# ============ Endpoints ============
@router.get('/filters', response_model=BroadcastFiltersResponse)
async def get_filters(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastFiltersResponse:
"""Get all available filters with user counts."""
# Basic filters
filters = []
for key, label in FILTER_LABELS.items():
try:
count = await get_target_users_count(db, key)
except Exception as e:
logger.warning(f'Failed to get count for filter {key}: {e}')
count = 0
filters.append(
BroadcastFilter(
key=key,
label=label,
count=count,
group=FILTER_GROUPS.get(key),
)
)
# Custom filters
custom_filters = []
for key, label in CUSTOM_FILTER_LABELS.items():
try:
count = await get_target_users_count(db, key)
except Exception as e:
logger.warning(f'Failed to get count for custom filter {key}: {e}')
count = 0
custom_filters.append(
BroadcastFilter(
key=key,
label=label,
count=count,
group=CUSTOM_FILTER_GROUPS.get(key),
)
)
# Tariff filters
tariff_counts = await _get_tariff_user_counts(db)
result = await db.execute(select(Tariff).where(Tariff.is_active == True).order_by(Tariff.name))
tariffs = result.scalars().all()
tariff_filters = []
for tariff in tariffs:
tariff_filters.append(
TariffFilter(
key=f'tariff_{tariff.id}',
label=tariff.name,
tariff_id=tariff.id,
count=tariff_counts.get(tariff.id, 0),
)
)
return BroadcastFiltersResponse(
filters=filters,
tariff_filters=tariff_filters,
custom_filters=custom_filters,
)
@router.get('/tariffs', response_model=BroadcastTariffsResponse)
async def get_tariffs(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastTariffsResponse:
"""Get tariffs for broadcast filtering."""
tariff_counts = await _get_tariff_user_counts(db)
result = await db.execute(select(Tariff).where(Tariff.is_active == True).order_by(Tariff.name))
tariffs = result.scalars().all()
return BroadcastTariffsResponse(
tariffs=[
TariffForBroadcast(
id=t.id,
name=t.name,
filter_key=f'tariff_{t.id}',
active_users_count=tariff_counts.get(t.id, 0),
)
for t in tariffs
]
)
@router.get('/buttons', response_model=BroadcastButtonsResponse)
async def get_buttons(
admin: User = Depends(get_current_admin_user),
) -> BroadcastButtonsResponse:
"""Get available buttons for broadcasts."""
default_buttons = set(DEFAULT_BROADCAST_BUTTONS)
buttons = []
for key, config in BROADCAST_BUTTONS.items():
buttons.append(
BroadcastButton(
key=key,
label=config.get('default_text', key),
default=key in default_buttons,
)
)
return BroadcastButtonsResponse(buttons=buttons)
@router.post('/preview', response_model=BroadcastPreviewResponse)
async def preview_broadcast(
request: BroadcastPreviewRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastPreviewResponse:
"""Preview broadcast recipients count."""
# Get tariff IDs for validation
result = await db.execute(select(Tariff.id))
tariff_ids = {row[0] for row in result.all()}
if not _validate_target(request.target, tariff_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid target: {request.target}',
)
try:
count = await get_target_users_count(db, request.target)
except Exception as e:
logger.error(f'Failed to get count for target {request.target}: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to count recipients',
)
return BroadcastPreviewResponse(target=request.target, count=count)
@router.post('', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_broadcast(
request: BroadcastCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Create and start a broadcast."""
# Validate target
result = await db.execute(select(Tariff.id))
tariff_ids = {row[0] for row in result.all()}
if not _validate_target(request.target, tariff_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid target: {request.target}',
)
# Validate buttons
if not _validate_buttons(request.selected_buttons):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid button key',
)
message_text = request.message_text.strip()
if not message_text:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Message text must not be empty',
)
media_payload = request.media
# Create broadcast record
broadcast = BroadcastHistory(
target_type=request.target,
message_text=message_text,
has_media=media_payload is not None,
media_type=media_payload.type if media_payload else None,
media_file_id=media_payload.file_id if media_payload else None,
media_caption=media_payload.caption if media_payload else None,
total_count=0,
sent_count=0,
failed_count=0,
status='queued',
admin_id=admin.id,
admin_name=admin.username or f'Admin #{admin.id}',
)
db.add(broadcast)
await db.commit()
await db.refresh(broadcast)
# Prepare media config
media_config = None
if media_payload:
media_config = BroadcastMediaConfig(
type=media_payload.type,
file_id=media_payload.file_id,
caption=media_payload.caption or message_text,
)
# Create broadcast config
config = BroadcastConfig(
target=request.target,
message_text=message_text,
selected_buttons=request.selected_buttons,
media=media_config,
initiator_name=admin.username or f'Admin #{admin.id}',
)
# Start broadcast
await broadcast_service.start_broadcast(broadcast.id, config)
await db.refresh(broadcast)
logger.info(f"Admin {admin.id} created broadcast {broadcast.id} for target '{request.target}'")
return _serialize_broadcast(broadcast)
@router.get('', response_model=BroadcastListResponse)
async def list_broadcasts(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> BroadcastListResponse:
"""Get list of broadcasts with pagination."""
total = await db.scalar(select(func.count(BroadcastHistory.id))) or 0
result = await db.execute(
select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit)
)
broadcasts = result.scalars().all()
return BroadcastListResponse(
items=[_serialize_broadcast(b) for b in broadcasts],
total=int(total),
limit=limit,
offset=offset,
)
# ============ Email Broadcast Endpoints ============
@router.get('/email-filters', response_model=EmailFiltersResponse)
async def get_email_filters(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> EmailFiltersResponse:
"""Get all available email filters with user counts."""
filters = []
total_with_email = 0
for key, label in EMAIL_FILTER_LABELS.items():
try:
count = await _get_email_filter_count(db, key)
except Exception as e:
logger.warning(f'Failed to get count for email filter {key}: {e}')
count = 0
filters.append(
EmailFilterItem(
key=key,
label=label,
count=count,
group=EMAIL_FILTER_GROUPS.get(key),
)
)
# Track total with email (all_email filter)
if key == 'all_email':
total_with_email = count
return EmailFiltersResponse(
filters=filters,
total_with_email=total_with_email,
)
@router.post('/email-preview', response_model=EmailPreviewResponse)
async def preview_email_broadcast(
request: EmailPreviewRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> EmailPreviewResponse:
"""Preview email broadcast recipients count."""
if not _validate_email_target(request.target):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid email target: {request.target}',
)
try:
count = await _get_email_filter_count(db, request.target)
except Exception as e:
logger.error(f'Failed to get email count for target {request.target}: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to count email recipients',
)
return EmailPreviewResponse(target=request.target, count=count)
@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),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Create and start a combined broadcast (telegram/email/both)."""
# Get tariff IDs for target validation
result = await db.execute(select(Tariff.id))
tariff_ids = {row[0] for row in result.all()}
admin_name = admin.username or f'Admin #{admin.id}'
# Validate based on channel
if request.channel in ('telegram', 'both'):
# Validate telegram target
if not _validate_target(request.target, tariff_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid target: {request.target}',
)
# Validate telegram message
if not request.message_text or not request.message_text.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Message text is required for Telegram broadcast',
)
# Validate buttons
if not _validate_buttons(request.selected_buttons):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid button key',
)
if request.channel in ('email', 'both'):
# For email channel, target must be email filter or we use telegram target for 'both'
if request.channel == 'email' and not _validate_email_target(request.target):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid email target: {request.target}',
)
# Validate email fields
if not request.email_subject or not request.email_subject.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Email subject is required for email broadcast',
)
if not request.email_html_content or not request.email_html_content.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Email HTML content is required for email broadcast',
)
media_payload = request.media
# Create broadcast record
broadcast = BroadcastHistory(
target_type=request.target,
message_text=request.message_text.strip() if request.message_text else None,
has_media=media_payload is not None,
media_type=media_payload.type if media_payload else None,
media_file_id=media_payload.file_id if media_payload else None,
media_caption=media_payload.caption if media_payload else None,
total_count=0,
sent_count=0,
failed_count=0,
status='queued',
admin_id=admin.id,
admin_name=admin_name,
channel=request.channel,
email_subject=request.email_subject.strip() if request.email_subject else None,
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
)
db.add(broadcast)
await db.commit()
await db.refresh(broadcast)
# Start broadcasts based on channel
if request.channel in ('telegram', 'both'):
# Prepare media config
media_config = None
if media_payload:
media_config = BroadcastMediaConfig(
type=media_payload.type,
file_id=media_payload.file_id,
caption=media_payload.caption or request.message_text,
)
# Create telegram broadcast config
telegram_config = BroadcastConfig(
target=request.target,
message_text=request.message_text.strip(),
selected_buttons=request.selected_buttons,
media=media_config,
initiator_name=admin_name,
)
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
if request.channel in ('email', 'both'):
# For 'both' channel, we use 'all_email' as default email target
# since telegram target won't match email filters
email_target = request.target if request.channel == 'email' else 'all_email'
# Create email broadcast config
email_config = EmailBroadcastConfig(
target=email_target,
email_subject=request.email_subject.strip(),
email_html_content=request.email_html_content.strip(),
initiator_name=admin_name,
)
await email_broadcast_service.start_broadcast(broadcast.id, email_config)
await db.refresh(broadcast)
logger.info(f"Admin {admin.id} created {request.channel} broadcast {broadcast.id} for target '{request.target}'")
return _serialize_broadcast(broadcast)
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
async def get_broadcast(
broadcast_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Get broadcast details."""
broadcast = await db.get(BroadcastHistory, broadcast_id)
if not broadcast:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Broadcast not found',
)
return _serialize_broadcast(broadcast)
@router.post('/{broadcast_id}/stop', response_model=BroadcastResponse)
async def stop_broadcast(
broadcast_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Stop a running broadcast (telegram or email)."""
broadcast = await db.get(BroadcastHistory, broadcast_id)
if not broadcast:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Broadcast not found',
)
if broadcast.status not in {'queued', 'in_progress', 'cancelling'}:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Broadcast is not running',
)
# Try to stop both telegram and email broadcasts (one or both may be running)
channel = getattr(broadcast, 'channel', 'telegram') or 'telegram'
is_running = False
if channel in ('telegram', 'both'):
is_running = await broadcast_service.request_stop(broadcast_id) or is_running
if channel in ('email', 'both'):
is_running = await email_broadcast_service.request_stop(broadcast_id) or is_running
if is_running:
broadcast.status = 'cancelling'
else:
broadcast.status = 'cancelled'
broadcast.completed_at = datetime.utcnow()
await db.commit()
await db.refresh(broadcast)
logger.info(f'Admin {admin.id} stopped broadcast {broadcast_id}')
return _serialize_broadcast(broadcast)
+253
View File
@@ -0,0 +1,253 @@
"""Admin routes for per-section cabinet button style configuration."""
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.utils.button_styles_cache import (
ALLOWED_STYLE_VALUES,
BOT_LOCALES,
BUTTON_STYLES_KEY,
DEFAULT_BUTTON_STYLES,
SECTIONS,
load_button_styles_cache,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles'])
# ---- Schemas ---------------------------------------------------------------
class ButtonSectionConfig(BaseModel):
"""Configuration for a single button section."""
style: str = 'primary'
icon_custom_emoji_id: str = ''
enabled: bool = True
labels: dict[str, str] = {}
class ButtonStylesResponse(BaseModel):
"""Full button styles configuration (all 7 sections)."""
home: ButtonSectionConfig = ButtonSectionConfig()
subscription: ButtonSectionConfig = ButtonSectionConfig()
balance: ButtonSectionConfig = ButtonSectionConfig()
referral: ButtonSectionConfig = ButtonSectionConfig()
support: ButtonSectionConfig = ButtonSectionConfig()
info: ButtonSectionConfig = ButtonSectionConfig()
admin: ButtonSectionConfig = ButtonSectionConfig()
MAX_LABEL_LENGTH = 100
class ButtonSectionUpdate(BaseModel):
"""Partial update for a single section (None = keep current)."""
style: str | None = None
icon_custom_emoji_id: str | None = None
enabled: bool | None = None
labels: dict[str, str] | None = None
class ButtonStylesUpdate(BaseModel):
"""Partial update — only include sections you want to change."""
home: ButtonSectionUpdate | None = None
subscription: ButtonSectionUpdate | None = None
balance: ButtonSectionUpdate | None = None
referral: ButtonSectionUpdate | None = None
support: ButtonSectionUpdate | None = None
info: ButtonSectionUpdate | None = None
admin: ButtonSectionUpdate | None = None
# ---- Helpers ---------------------------------------------------------------
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def _set_setting_value(db: AsyncSession, key: str, value: str) -> None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = SystemSetting(key=key, value=value)
db.add(setting)
await db.commit()
def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
return ButtonStylesResponse(
**{section: ButtonSectionConfig(**cfg) for section, cfg in styles.items() if section in SECTIONS},
)
# ---- Routes ----------------------------------------------------------------
@router.get('', response_model=ButtonStylesResponse)
async def get_button_styles(
_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return current per-section button styles. Admin only."""
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
merged = {section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
if raw:
try:
db_data = json.loads(raw)
for section, overrides in db_data.items():
if section in merged and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
merged[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
merged[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
merged[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except (json.JSONDecodeError, TypeError):
pass
return _build_response(merged)
@router.patch('', response_model=ButtonStylesResponse)
async def update_button_styles(
payload: ButtonStylesUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Partially update per-section button styles. Admin only."""
# Load current state
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
current: dict[str, dict] = {
section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()
}
if raw:
try:
db_data = json.loads(raw)
for section, overrides in db_data.items():
if section in current and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
current[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
current[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
current[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
current[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except (json.JSONDecodeError, TypeError):
pass
# Apply updates
update_data = payload.model_dump(exclude_none=True)
changed_sections: list[str] = []
for section, updates in update_data.items():
if section not in current or not isinstance(updates, dict):
continue
if 'style' in updates:
style_val = updates['style']
if style_val not in ALLOWED_STYLE_VALUES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid style "{style_val}" for section "{section}". '
f'Allowed: {", ".join(sorted(ALLOWED_STYLE_VALUES))}',
)
current[section]['style'] = style_val
if 'icon_custom_emoji_id' in updates:
emoji_val = (updates['icon_custom_emoji_id'] or '').strip()
current[section]['icon_custom_emoji_id'] = emoji_val
if 'enabled' in updates:
current[section]['enabled'] = updates['enabled']
if 'labels' in updates:
raw_labels = updates['labels'] or {}
sanitized: dict[str, str] = {}
for locale_key, label_val in raw_labels.items():
if locale_key not in BOT_LOCALES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid locale "{locale_key}" for section "{section}". '
f'Allowed: {", ".join(BOT_LOCALES)}',
)
if not isinstance(label_val, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label value for locale "{locale_key}" must be a string.',
)
stripped = label_val.strip()
if len(stripped) > MAX_LABEL_LENGTH:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label for locale "{locale_key}" exceeds {MAX_LABEL_LENGTH} characters.',
)
# Empty string = remove custom label (use default)
if stripped:
sanitized[locale_key] = stripped
current[section]['labels'] = sanitized
changed_sections.append(section)
# Persist
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(current))
# Refresh in-process cache
await load_button_styles_cache()
logger.info('Admin %s updated button styles for sections: %s', admin.telegram_id, changed_sections)
return _build_response(current)
@router.post('/reset', response_model=ButtonStylesResponse)
async def reset_button_styles(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset all button styles to defaults. Admin only."""
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
await load_button_styles_cache()
logger.info('Admin %s reset button styles to defaults', admin.telegram_id)
return _build_response(DEFAULT_BUTTON_STYLES)
+507
View File
@@ -0,0 +1,507 @@
"""Admin routes for managing advertising campaigns in cabinet."""
import logging
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.database.crud.campaign import (
create_campaign,
delete_campaign,
get_campaign_by_id,
get_campaign_by_start_parameter,
get_campaign_statistics,
get_campaigns_count,
get_campaigns_list,
get_campaigns_overview,
update_campaign,
)
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import get_all_tariffs
from app.database.models import (
AdvertisingCampaignRegistration,
Subscription,
Tariff,
User,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.campaigns import (
CampaignCreateRequest,
CampaignDetailResponse,
CampaignListItem,
CampaignListResponse,
CampaignRegistrationItem,
CampaignRegistrationsResponse,
CampaignsOverviewResponse,
CampaignStatisticsResponse,
CampaignToggleResponse,
CampaignUpdateRequest,
ServerSquadInfo,
TariffInfo,
)
from ..schemas.tariffs import TariffListItem
logger = logging.getLogger(__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}'
@router.get('/overview', response_model=CampaignsOverviewResponse)
async def get_overview(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get campaigns overview statistics."""
overview = await get_campaigns_overview(db)
# 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
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,
)
@router.get('/available-servers', response_model=list[ServerSquadInfo])
async def get_available_servers(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available server squads for campaign subscription bonus."""
servers, _ = await get_all_server_squads(db, available_only=False)
return [
ServerSquadInfo(
id=server.id,
squad_uuid=server.squad_uuid,
display_name=server.display_name,
country_code=server.country_code,
)
for server in servers
]
@router.get('/available-tariffs', response_model=list[TariffListItem])
async def get_available_tariffs(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available tariffs for campaign tariff bonus."""
tariffs = await get_all_tariffs(db, include_inactive=False)
return [
TariffListItem(
id=tariff.id,
name=tariff.name,
description=tariff.description,
is_active=tariff.is_active,
is_trial_available=tariff.is_trial_available,
is_daily=tariff.is_daily,
daily_price_kopeks=tariff.daily_price_kopeks or 0,
allow_traffic_topup=tariff.allow_traffic_topup,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
tier_level=tariff.tier_level,
display_order=tariff.display_order,
servers_count=len(tariff.allowed_squads or []),
subscriptions_count=0,
created_at=tariff.created_at,
)
for tariff in tariffs
]
@router.get('', response_model=CampaignListResponse)
async def list_campaigns(
include_inactive: bool = True,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
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)
items = []
for campaign in campaigns:
# Get quick stats
stats = await get_campaign_statistics(db, campaign.id)
items.append(
CampaignListItem(
id=campaign.id,
name=campaign.name,
start_parameter=campaign.start_parameter,
bonus_type=campaign.bonus_type,
is_active=campaign.is_active,
registrations_count=stats['registrations'],
total_revenue_kopeks=stats['total_revenue_kopeks'],
conversion_rate=stats['conversion_rate'],
created_at=campaign.created_at,
)
)
return CampaignListResponse(campaigns=items, total=total)
@router.get('/{campaign_id}', response_model=CampaignDetailResponse)
async def get_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed campaign info."""
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',
)
tariff_info = None
if campaign.tariff:
tariff_info = TariffInfo(
id=campaign.tariff.id,
name=campaign.tariff.name,
)
return CampaignDetailResponse(
id=campaign.id,
name=campaign.name,
start_parameter=campaign.start_parameter,
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,
subscription_duration_days=campaign.subscription_duration_days,
subscription_traffic_gb=campaign.subscription_traffic_gb,
subscription_device_limit=campaign.subscription_device_limit,
subscription_squads=campaign.subscription_squads or [],
tariff_id=campaign.tariff_id,
tariff_duration_days=campaign.tariff_duration_days,
tariff=tariff_info,
created_by=campaign.created_by,
created_at=campaign.created_at,
updated_at=campaign.updated_at,
deep_link=_get_deep_link(campaign.start_parameter),
)
@router.get('/{campaign_id}/stats', response_model=CampaignStatisticsResponse)
async def get_campaign_stats(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
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',
)
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),
)
@router.get('/{campaign_id}/registrations', response_model=CampaignRegistrationsResponse)
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),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of users registered through campaign."""
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',
)
offset = (page - 1) * per_page
# Get registrations with user info
result = await db.execute(
select(AdvertisingCampaignRegistration, User)
.join(User, AdvertisingCampaignRegistration.user_id == User.id)
.where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
.order_by(AdvertisingCampaignRegistration.created_at.desc())
.offset(offset)
.limit(per_page)
)
rows = result.all()
# Count total
count_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.campaign_id == campaign_id
)
)
total = count_result.scalar() or 0
items = []
for reg, user in rows:
# Check if user has subscription
sub_result = await db.execute(
select(Subscription)
.where(
Subscription.user_id == user.id,
Subscription.status == 'active',
)
.limit(1)
)
has_sub = sub_result.scalar_one_or_none() is not None
items.append(
CampaignRegistrationItem(
id=reg.id,
user_id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
bonus_type=reg.bonus_type,
balance_bonus_kopeks=reg.balance_bonus_kopeks or 0,
subscription_duration_days=reg.subscription_duration_days,
tariff_id=reg.tariff_id,
tariff_duration_days=reg.tariff_duration_days,
created_at=reg.created_at,
user_balance_kopeks=user.balance_kopeks or 0,
has_subscription=has_sub,
has_paid=user.has_had_paid_subscription or False,
)
)
return CampaignRegistrationsResponse(
registrations=items,
total=total,
page=page,
per_page=per_page,
)
@router.post('', response_model=CampaignDetailResponse)
async def create_new_campaign(
request: CampaignCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new advertising campaign."""
# Check if start_parameter is unique
existing = await get_campaign_by_start_parameter(db, request.start_parameter)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Campaign with start parameter '{request.start_parameter}' already exists",
)
# Validate tariff exists if tariff bonus type
if request.bonus_type == 'tariff':
if not request.tariff_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Tariff ID is required for tariff bonus type',
)
tariff_result = await db.execute(select(Tariff).where(Tariff.id == request.tariff_id))
tariff = tariff_result.scalar_one_or_none()
if not tariff:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Tariff not found',
)
campaign = await create_campaign(
db,
name=request.name,
start_parameter=request.start_parameter,
bonus_type=request.bonus_type,
created_by=admin.id,
balance_bonus_kopeks=request.balance_bonus_kopeks,
subscription_duration_days=request.subscription_duration_days,
subscription_traffic_gb=request.subscription_traffic_gb,
subscription_device_limit=request.subscription_device_limit,
subscription_squads=request.subscription_squads,
tariff_id=request.tariff_id,
tariff_duration_days=request.tariff_duration_days,
is_active=request.is_active,
)
# Reload to get tariff relationship
campaign = await get_campaign_by_id(db, campaign.id)
logger.info(f'Admin {admin.id} created campaign {campaign.id}: {campaign.name}')
return await get_campaign(campaign.id, admin, db)
@router.put('/{campaign_id}', response_model=CampaignDetailResponse)
async def update_existing_campaign(
campaign_id: int,
request: CampaignUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing campaign."""
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',
)
# Check if start_parameter is unique (if changing)
if request.start_parameter and request.start_parameter != campaign.start_parameter:
existing = await get_campaign_by_start_parameter(db, request.start_parameter)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Campaign with start parameter '{request.start_parameter}' already exists",
)
# Validate tariff if changing to tariff bonus type
if request.bonus_type == 'tariff' or (campaign.bonus_type == 'tariff' and request.tariff_id):
tariff_id = request.tariff_id or campaign.tariff_id
if tariff_id:
tariff_result = await db.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = tariff_result.scalar_one_or_none()
if not tariff:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Tariff not found',
)
# Build updates
updates = {}
if request.name is not None:
updates['name'] = request.name
if request.start_parameter is not None:
updates['start_parameter'] = request.start_parameter
if request.bonus_type is not None:
updates['bonus_type'] = request.bonus_type
if request.is_active is not None:
updates['is_active'] = request.is_active
if request.balance_bonus_kopeks is not None:
updates['balance_bonus_kopeks'] = request.balance_bonus_kopeks
if request.subscription_duration_days is not None:
updates['subscription_duration_days'] = request.subscription_duration_days
if request.subscription_traffic_gb is not None:
updates['subscription_traffic_gb'] = request.subscription_traffic_gb
if request.subscription_device_limit is not None:
updates['subscription_device_limit'] = request.subscription_device_limit
if request.subscription_squads is not None:
updates['subscription_squads'] = request.subscription_squads
if request.tariff_id is not None:
updates['tariff_id'] = request.tariff_id
if request.tariff_duration_days is not None:
updates['tariff_duration_days'] = request.tariff_duration_days
if updates:
await update_campaign(db, campaign, **updates)
logger.info(f'Admin {admin.id} updated campaign {campaign_id}')
return await get_campaign(campaign_id, admin, db)
@router.delete('/{campaign_id}')
async def delete_existing_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a campaign."""
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',
)
# Check if campaign has registrations
reg_count = len(campaign.registrations) if campaign.registrations else 0
if reg_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Cannot delete campaign with {reg_count} registrations. Deactivate it instead.',
)
await delete_campaign(db, campaign)
logger.info(f'Admin {admin.id} deleted campaign {campaign_id}: {campaign.name}')
return {'message': 'Campaign deleted successfully'}
@router.post('/{campaign_id}/toggle', response_model=CampaignToggleResponse)
async def toggle_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle campaign active status."""
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',
)
new_status = not campaign.is_active
await update_campaign(db, campaign, is_active=new_status)
status_text = 'activated' if new_status else 'deactivated'
logger.info(f'Admin {admin.id} {status_text} campaign {campaign_id}')
return CampaignToggleResponse(
id=campaign_id,
is_active=new_status,
message=f'Campaign {status_text}',
)
+680
View File
@@ -0,0 +1,680 @@
"""Admin routes for managing email notification templates."""
import asyncio
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..services.email_template_overrides import (
delete_template_override,
get_all_overrides,
get_overrides_for_type,
save_template_override,
)
from ..services.email_templates import EmailNotificationTemplates
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/email-templates', tags=['Admin Email Templates'])
# ============ Template type metadata ============
TEMPLATE_TYPES = [
{
'type': 'balance_topup',
'label': {'ru': 'Пополнение баланса', 'en': 'Balance Top-up', 'zh': '余额充值', 'ua': 'Поповнення балансу'},
'description': {
'ru': 'Уведомление о пополнении баланса',
'en': 'Balance top-up notification',
'zh': '余额充值通知',
'ua': 'Сповіщення про поповнення балансу',
},
'context_vars': ['amount', 'balance'],
},
{
'type': 'balance_change',
'label': {'ru': 'Изменение баланса', 'en': 'Balance Change', 'zh': '余额变动', 'ua': 'Зміна балансу'},
'description': {
'ru': 'Уведомление об изменении баланса',
'en': 'Balance change notification',
'zh': '余额变动通知',
'ua': 'Сповіщення про зміну балансу',
},
'context_vars': ['amount', 'balance'],
},
{
'type': 'subscription_expiring',
'label': {
'ru': 'Подписка истекает',
'en': 'Subscription Expiring',
'zh': '订阅即将到期',
'ua': 'Підписка закінчується',
},
'description': {
'ru': 'Предупреждение об истечении подписки',
'en': 'Subscription expiring warning',
'zh': '订阅即将到期警告',
'ua': 'Попередження про закінчення підписки',
},
'context_vars': ['days_left', 'expires_at'],
},
{
'type': 'subscription_expired',
'label': {
'ru': 'Подписка истекла',
'en': 'Subscription Expired',
'zh': '订阅已到期',
'ua': 'Підписка закінчилась',
},
'description': {
'ru': 'Уведомление об истечении подписки',
'en': 'Subscription expired notification',
'zh': '订阅已到期通知',
'ua': 'Сповіщення про закінчення підписки',
},
'context_vars': [],
},
{
'type': 'subscription_renewed',
'label': {
'ru': 'Подписка продлена',
'en': 'Subscription Renewed',
'zh': '订阅已续期',
'ua': 'Підписка продовжена',
},
'description': {
'ru': 'Уведомление о продлении подписки',
'en': 'Subscription renewed notification',
'zh': '订阅已续期通知',
'ua': 'Сповіщення про продовження підписки',
},
'context_vars': ['new_end_date', 'tariff_name'],
},
{
'type': 'subscription_activated',
'label': {
'ru': 'Подписка активирована',
'en': 'Subscription Activated',
'zh': '订阅已激活',
'ua': 'Підписка активована',
},
'description': {
'ru': 'Уведомление об активации подписки',
'en': 'Subscription activated notification',
'zh': '订阅已激活通知',
'ua': 'Сповіщення про активацію підписки',
},
'context_vars': ['tariff_name', 'end_date'],
},
{
'type': 'autopay_success',
'label': {
'ru': 'Автоплатёж успешен',
'en': 'Autopay Success',
'zh': '自动续费成功',
'ua': 'Автоплатіж успішний',
},
'description': {
'ru': 'Уведомление об успешном автоплатеже',
'en': 'Autopay success notification',
'zh': '自动续费成功通知',
'ua': 'Сповіщення про успішний автоплатіж',
},
'context_vars': ['amount', 'balance', 'new_end_date'],
},
{
'type': 'autopay_failed',
'label': {
'ru': 'Автоплатёж не удался',
'en': 'Autopay Failed',
'zh': '自动续费失败',
'ua': 'Автоплатіж не вдався',
},
'description': {
'ru': 'Уведомление о неудачном автоплатеже',
'en': 'Autopay failed notification',
'zh': '自动续费失败通知',
'ua': 'Сповіщення про невдалий автоплатіж',
},
'context_vars': ['reason'],
},
{
'type': 'autopay_insufficient_funds',
'label': {
'ru': 'Недостаточно средств (автоплатёж)',
'en': 'Insufficient Funds (Autopay)',
'zh': '余额不足(自动续费)',
'ua': 'Недостатньо коштів (автоплатіж)',
},
'description': {
'ru': 'Уведомление о нехватке средств для автоплатежа',
'en': 'Insufficient funds for autopay notification',
'zh': '自动续费余额不足通知',
'ua': 'Сповіщення про нестачу коштів для автоплатежу',
},
'context_vars': ['required_amount', 'balance'],
},
{
'type': 'daily_debit',
'label': {'ru': 'Суточное списание', 'en': 'Daily Debit', 'zh': '每日扣费', 'ua': 'Добове списання'},
'description': {
'ru': 'Уведомление о суточном списании',
'en': 'Daily debit notification',
'zh': '每日扣费通知',
'ua': 'Сповіщення про добове списання',
},
'context_vars': ['amount', 'balance'],
},
{
'type': 'daily_insufficient_funds',
'label': {
'ru': 'Недостаточно средств (суточное)',
'en': 'Insufficient Funds (Daily)',
'zh': '余额不足(每日)',
'ua': 'Недостатньо коштів (добове)',
},
'description': {
'ru': 'Уведомление о нехватке средств для суточного списания',
'en': 'Insufficient funds for daily debit',
'zh': '每日扣费余额不足通知',
'ua': 'Сповіщення про нестачу коштів для добового списання',
},
'context_vars': ['required_amount', 'balance'],
},
{
'type': 'ban_notification',
'label': {'ru': 'Блокировка аккаунта', 'en': 'Account Banned', 'zh': '账户被封禁', 'ua': 'Блокування акаунту'},
'description': {
'ru': 'Уведомление о блокировке аккаунта',
'en': 'Account banned notification',
'zh': '账户被封禁通知',
'ua': 'Сповіщення про блокування акаунту',
},
'context_vars': ['reason'],
},
{
'type': 'unban_notification',
'label': {
'ru': 'Разблокировка аккаунта',
'en': 'Account Unbanned',
'zh': '账户已解封',
'ua': 'Розблокування акаунту',
},
'description': {
'ru': 'Уведомление о разблокировке аккаунта',
'en': 'Account unbanned notification',
'zh': '账户已解封通知',
'ua': 'Сповіщення про розблокування акаунту',
},
'context_vars': [],
},
{
'type': 'warning_notification',
'label': {'ru': 'Предупреждение', 'en': 'Warning', 'zh': '警告', 'ua': 'Попередження'},
'description': {
'ru': 'Предупреждение пользователю',
'en': 'Warning notification',
'zh': '警告通知',
'ua': 'Попередження користувачу',
},
'context_vars': ['message'],
},
{
'type': 'referral_bonus',
'label': {'ru': 'Реферальный бонус', 'en': 'Referral Bonus', 'zh': '推荐奖励', 'ua': 'Реферальний бонус'},
'description': {
'ru': 'Уведомление о начислении реферального бонуса',
'en': 'Referral bonus notification',
'zh': '推荐奖励通知',
'ua': 'Сповіщення про нарахування реферального бонусу',
},
'context_vars': ['amount', 'referral_name'],
},
{
'type': 'referral_registered',
'label': {'ru': 'Новый реферал', 'en': 'New Referral', 'zh': '新推荐用户', 'ua': 'Новий реферал'},
'description': {
'ru': 'Уведомление о регистрации реферала',
'en': 'New referral registered notification',
'zh': '新推荐用户注册通知',
'ua': 'Сповіщення про реєстрацію реферала',
},
'context_vars': ['referral_name'],
},
{
'type': 'traffic_reset',
'label': {'ru': 'Сброс трафика', 'en': 'Traffic Reset', 'zh': '流量重置', 'ua': 'Скидання трафіку'},
'description': {
'ru': 'Уведомление о сбросе трафика',
'en': 'Traffic reset notification',
'zh': '流量重置通知',
'ua': 'Сповіщення про скидання трафіку',
},
'context_vars': ['traffic_limit'],
},
{
'type': 'payment_received',
'label': {'ru': 'Платёж получен', 'en': 'Payment Received', 'zh': '收到付款', 'ua': 'Платіж отримано'},
'description': {
'ru': 'Уведомление о получении платежа',
'en': 'Payment received notification',
'zh': '收到付款通知',
'ua': 'Сповіщення про отримання платежу',
},
'context_vars': ['amount', 'payment_method'],
},
{
'type': 'email_verification',
'label': {
'ru': 'Подтверждение email',
'en': 'Email Verification',
'zh': '邮箱验证',
'ua': 'Підтвердження email',
},
'description': {
'ru': 'Письмо для подтверждения email адреса при регистрации',
'en': 'Email address verification letter sent during registration',
'zh': '注册时发送的邮箱验证邮件',
'ua': 'Лист для підтвердження email адреси при реєстрації',
},
'context_vars': ['username', 'verification_url', 'expire_hours'],
},
{
'type': 'password_reset',
'label': {'ru': 'Сброс пароля', 'en': 'Password Reset', 'zh': '重置密码', 'ua': 'Скидання пароля'},
'description': {
'ru': 'Письмо для сброса пароля',
'en': 'Password reset email',
'zh': '密码重置邮件',
'ua': 'Лист для скидання пароля',
},
'context_vars': ['username', 'reset_url', 'expire_hours'],
},
]
SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
'balance_topup': {
'formatted_amount': '500.00 ₽',
'formatted_balance': '1500.00 ₽',
'amount_rubles': 500,
'new_balance_rubles': 1500,
},
'balance_change': {
'formatted_amount': '-200.00 ₽',
'formatted_balance': '1300.00 ₽',
'amount_rubles': -200,
'new_balance_rubles': 1300,
},
'subscription_expiring': {'days_left': 3, 'expires_at': '2025-01-30'},
'subscription_expired': {},
'subscription_renewed': {'new_end_date': '2025-02-28', 'tariff_name': 'Premium'},
'subscription_activated': {'tariff_name': 'Premium', 'end_date': '2025-02-28'},
'autopay_success': {'formatted_amount': '300.00 ₽', 'formatted_balance': '200.00 ₽', 'new_end_date': '2025-02-28'},
'autopay_failed': {'reason': 'Card declined'},
'autopay_insufficient_funds': {'formatted_required': '300.00 ₽', 'formatted_balance': '50.00 ₽'},
'daily_debit': {'formatted_amount': '10.00 ₽', 'formatted_balance': '490.00 ₽'},
'daily_insufficient_funds': {'formatted_required': '10.00 ₽', 'formatted_balance': '5.00 ₽'},
'ban_notification': {'reason': 'Violation of terms of service'},
'unban_notification': {},
'warning_notification': {'message': 'Please review our terms of service'},
'referral_bonus': {'formatted_amount': '100.00 ₽', 'referral_name': 'John'},
'referral_registered': {'referral_name': 'John'},
'traffic_reset': {'traffic_limit': '100 GB'},
'payment_received': {'formatted_amount': '500.00 ₽', 'payment_method': 'YooKassa'},
'email_verification': {
'username': 'John',
'verification_url': 'https://example.com/verify?token=abc123',
'expire_hours': 24,
},
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
}
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
# ============ Schemas ============
class EmailTemplateUpdate(BaseModel):
"""Request to update an email template."""
subject: str = Field(..., min_length=1, max_length=500)
body_html: str = Field(..., min_length=1)
class EmailTemplatePreviewRequest(BaseModel):
"""Request to preview an email template."""
language: str = Field(default='ru')
subject: str = Field(default='')
body_html: str = Field(default='')
class EmailTemplateSendTestRequest(BaseModel):
"""Request to send a test email."""
language: str = Field(default='ru')
email: str = Field(default='')
# ============ Endpoints ============
@router.get('', summary='List all email template types')
async def list_template_types(
_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""List all available email template types with override status."""
overrides = await get_all_overrides(db)
# Build a map of overrides by type
override_map: dict[str, dict[str, bool]] = {}
for o in overrides:
ntype = o['notification_type']
if ntype not in override_map:
override_map[ntype] = {}
override_map[ntype][o['language']] = o['is_active']
result = []
for tpl_type in TEMPLATE_TYPES:
type_key = tpl_type['type']
languages = {}
for lang in AVAILABLE_LANGUAGES:
languages[lang] = {
'has_custom': lang in override_map.get(type_key, {}),
}
result.append(
{
**tpl_type,
'languages': languages,
}
)
return {'items': result, 'available_languages': AVAILABLE_LANGUAGES}
@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),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Get all language templates for a specific notification type."""
# Validate type
valid_types = [t['type'] for t in TEMPLATE_TYPES]
if notification_type not in valid_types:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Unknown template type: {notification_type}',
)
# Get overrides from DB
overrides = await get_overrides_for_type(notification_type, db)
override_map = {o['language']: o for o in overrides}
# Get defaults from hardcoded templates
templates_instance = EmailNotificationTemplates()
sample_context = SAMPLE_CONTEXTS.get(notification_type, {})
# Get type metadata
type_meta = next(t for t in TEMPLATE_TYPES if t['type'] == notification_type)
# Build combined result per language
languages = {}
for lang in AVAILABLE_LANGUAGES:
# Get default template
try:
from app.services.notification_delivery_service import NotificationType
ntype_enum = NotificationType(notification_type)
default_template = templates_instance.get_template(ntype_enum, lang, sample_context)
except Exception:
default_template = None
default_subject = ''
default_body_html = ''
if default_template:
default_subject = default_template.get('subject', '')
default_body_html = default_template.get('body_html', '')
# Check for override
override = override_map.get(lang)
if override:
languages[lang] = {
'subject': override['subject'],
'body_html': override['body_html'],
'is_default': False,
'default_subject': default_subject,
'default_body_html': default_body_html,
}
else:
languages[lang] = {
'subject': default_subject,
'body_html': default_body_html,
'is_default': True,
'default_subject': default_subject,
'default_body_html': default_body_html,
}
return {
'notification_type': notification_type,
'label': type_meta['label'],
'description': type_meta['description'],
'context_vars': type_meta['context_vars'],
'languages': languages,
}
@router.put('/{notification_type}/{language}', summary='Save custom template')
async def update_template(
notification_type: str,
language: str,
data: EmailTemplateUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Save a custom email template override."""
valid_types = [t['type'] for t in TEMPLATE_TYPES]
if notification_type not in valid_types:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Unknown template type: {notification_type}',
)
if language not in AVAILABLE_LANGUAGES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid language: {language}. Available: {AVAILABLE_LANGUAGES}',
)
result = await save_template_override(
notification_type=notification_type,
language=language,
subject=data.subject,
body_html=data.body_html,
db=db,
)
logger.info(
'Админ %s обновил email шаблон %s/%s',
admin.id,
notification_type,
language,
)
return {'status': 'ok', 'template': result}
@router.delete('/{notification_type}/{language}', summary='Reset template to default')
async def reset_template(
notification_type: str,
language: str,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Delete custom template override, reverting to default."""
valid_types = [t['type'] for t in TEMPLATE_TYPES]
if notification_type not in valid_types:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Unknown template type: {notification_type}',
)
deleted = await delete_template_override(notification_type, language, db)
if deleted:
logger.info(
'Админ %s сбросил email шаблон %s/%s к дефолту',
admin.id,
notification_type,
language,
)
return {'status': 'ok', 'was_custom': deleted}
@router.post('/{notification_type}/preview', summary='Preview rendered template')
async def preview_template(
notification_type: str,
data: EmailTemplatePreviewRequest,
_admin: User = Depends(get_current_admin_user),
) -> dict[str, Any]:
"""Preview a rendered email template with sample data."""
valid_types = [t['type'] for t in TEMPLATE_TYPES]
if notification_type not in valid_types:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Unknown template type: {notification_type}',
)
templates_instance = EmailNotificationTemplates()
language = data.language if data.language in AVAILABLE_LANGUAGES else 'ru'
if data.body_html:
# Preview custom content wrapped in base template
rendered_html = templates_instance._get_base_template(data.body_html, language)
subject = data.subject or notification_type
else:
# Preview default template
sample_context = SAMPLE_CONTEXTS.get(notification_type, {})
try:
from app.services.notification_delivery_service import NotificationType
ntype_enum = NotificationType(notification_type)
default_template = templates_instance.get_template(ntype_enum, language, sample_context)
except Exception:
default_template = None
if default_template:
rendered_html = default_template['body_html']
subject = default_template['subject']
else:
rendered_html = '<p>Template not found</p>'
subject = 'N/A'
return {
'subject': subject,
'body_html': rendered_html,
}
@router.post('/{notification_type}/test', summary='Send test email')
async def send_test_email(
notification_type: str,
data: EmailTemplateSendTestRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Send a test email to the admin's email address."""
from app.cabinet.services.email_service import email_service
if not email_service.is_configured():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='SMTP is not configured',
)
to_email = data.email or admin.email
if not to_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No email address provided and admin has no email',
)
valid_types = [t['type'] for t in TEMPLATE_TYPES]
if notification_type not in valid_types:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Unknown template type: {notification_type}',
)
language = data.language if data.language in AVAILABLE_LANGUAGES else 'ru'
sample_context = SAMPLE_CONTEXTS.get(notification_type, {})
templates_instance = EmailNotificationTemplates()
# Check for DB override
from ..services.email_template_overrides import get_template_override
override = await get_template_override(notification_type, language, db)
if override:
subject = override['subject']
body_html = templates_instance._get_base_template(override['body_html'], language)
else:
try:
from app.services.notification_delivery_service import NotificationType
ntype_enum = NotificationType(notification_type)
default_template = templates_instance.get_template(ntype_enum, language, sample_context)
except Exception:
default_template = None
if default_template:
subject = default_template['subject']
body_html = default_template['body_html']
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Template not found',
)
subject = f'[TEST] {subject}'
try:
success = await asyncio.to_thread(
email_service.send_email,
to_email=to_email,
subject=subject,
body_html=body_html,
)
except Exception as e:
logger.error('Ошибка отправки тестового email: %s', e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to send test email: {e!s}',
)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to send test email',
)
logger.info(
'Админ %s отправил тестовый email %s/%s на %s',
admin.id,
notification_type,
language,
to_email,
)
return {'status': 'ok', 'sent_to': to_email}
+228
View File
@@ -0,0 +1,228 @@
"""Admin routes for payment method configuration in cabinet."""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.payment_method_config_service import (
_get_method_defaults,
get_all_configs,
get_all_promo_groups,
get_config_by_method_id,
update_config,
update_sort_order,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/payment-methods', tags=['Cabinet Admin Payment Methods'])
# ============ Schemas ============
class SubOptionInfo(BaseModel):
id: str
name: str
class PaymentMethodConfigResponse(BaseModel):
method_id: str
sort_order: int
is_enabled: bool
display_name: str | None = None
default_display_name: str
sub_options: dict | None = None
available_sub_options: list[SubOptionInfo] | None = None
min_amount_kopeks: int | None = None
max_amount_kopeks: int | None = None
default_min_amount_kopeks: int
default_max_amount_kopeks: int
user_type_filter: str
first_topup_filter: str
promo_group_filter_mode: str
allowed_promo_group_ids: list[int] = Field(default_factory=list)
is_provider_configured: bool
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
class PaymentMethodConfigUpdateRequest(BaseModel):
is_enabled: bool | None = None
display_name: str | None = Field(default=None, description='Null to reset to default')
sub_options: dict | None = None
min_amount_kopeks: int | None = Field(default=None, ge=0)
max_amount_kopeks: int | None = Field(default=None, ge=0)
user_type_filter: str | None = Field(default=None, pattern='^(all|telegram|email)$')
first_topup_filter: str | None = Field(default=None, pattern='^(any|yes|no)$')
promo_group_filter_mode: str | None = Field(default=None, pattern='^(all|selected)$')
allowed_promo_group_ids: list[int] | None = None
# Allow explicitly resetting display_name to null
reset_display_name: bool = False
reset_min_amount: bool = False
reset_max_amount: bool = False
class SortOrderRequest(BaseModel):
method_ids: list[str]
class PromoGroupSimple(BaseModel):
id: int
name: str
class Config:
from_attributes = True
# ============ Helpers ============
def _enrich_config(config, defaults: dict) -> PaymentMethodConfigResponse:
"""Enrich a PaymentMethodConfig with env-var defaults."""
method_def = defaults.get(config.method_id, {})
available_sub_options = None
raw_options = method_def.get('available_sub_options')
if raw_options:
available_sub_options = [SubOptionInfo(**opt) for opt in raw_options]
return PaymentMethodConfigResponse(
method_id=config.method_id,
sort_order=config.sort_order,
is_enabled=config.is_enabled,
display_name=config.display_name,
default_display_name=method_def.get('default_display_name', config.method_id),
sub_options=config.sub_options,
available_sub_options=available_sub_options,
min_amount_kopeks=config.min_amount_kopeks,
max_amount_kopeks=config.max_amount_kopeks,
default_min_amount_kopeks=method_def.get('default_min', 1000),
default_max_amount_kopeks=method_def.get('default_max', 10000000),
user_type_filter=config.user_type_filter,
first_topup_filter=config.first_topup_filter,
promo_group_filter_mode=config.promo_group_filter_mode,
allowed_promo_group_ids=[pg.id for pg in config.allowed_promo_groups],
is_provider_configured=method_def.get('is_configured', False),
created_at=config.created_at,
updated_at=config.updated_at,
)
# ============ Routes ============
@router.get('', response_model=list[PaymentMethodConfigResponse])
async def list_payment_methods(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all payment method configurations."""
configs = await get_all_configs(db)
defaults = _get_method_defaults()
return [_enrich_config(c, defaults) for c in configs]
@router.get('/promo-groups', response_model=list[PromoGroupSimple])
async def list_promo_groups(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all promo groups for filter selector."""
groups = await get_all_promo_groups(db)
return [PromoGroupSimple(id=g.id, name=g.name) for g in groups]
@router.get('/{method_id}', response_model=PaymentMethodConfigResponse)
async def get_payment_method(
method_id: str,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get a single payment method configuration."""
config = await get_config_by_method_id(db, method_id)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Payment method not found: {method_id}',
)
defaults = _get_method_defaults()
return _enrich_config(config, defaults)
@router.put('/order')
async def update_payment_methods_order(
request: SortOrderRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Batch update sort order for payment methods."""
await update_sort_order(db, request.method_ids)
logger.info(f'Admin {admin.id} updated payment methods order: {request.method_ids}')
return {'success': True}
@router.put('/{method_id}', response_model=PaymentMethodConfigResponse)
async def update_payment_method(
method_id: str,
request: PaymentMethodConfigUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update a payment method configuration."""
# Build update data dict
data = {}
if request.is_enabled is not None:
data['is_enabled'] = request.is_enabled
if request.reset_display_name:
data['display_name'] = None
elif request.display_name is not None:
data['display_name'] = request.display_name.strip() or None
if request.sub_options is not None:
data['sub_options'] = request.sub_options
if request.reset_min_amount:
data['min_amount_kopeks'] = None
elif request.min_amount_kopeks is not None:
data['min_amount_kopeks'] = request.min_amount_kopeks
if request.reset_max_amount:
data['max_amount_kopeks'] = None
elif request.max_amount_kopeks is not None:
data['max_amount_kopeks'] = request.max_amount_kopeks
if request.user_type_filter is not None:
data['user_type_filter'] = request.user_type_filter
if request.first_topup_filter is not None:
data['first_topup_filter'] = request.first_topup_filter
if request.promo_group_filter_mode is not None:
data['promo_group_filter_mode'] = request.promo_group_filter_mode
promo_group_ids = request.allowed_promo_group_ids
config = await update_config(db, method_id, data, promo_group_ids)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Payment method not found: {method_id}',
)
logger.info(f'Admin {admin.id} updated payment method config: {method_id}')
defaults = _get_method_defaults()
return _enrich_config(config, defaults)
+420
View File
@@ -0,0 +1,420 @@
"""Admin routes for payment verification in cabinet."""
import logging
import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PaymentMethod, User
from app.services.payment_service import PaymentService
from app.services.payment_verification_service import (
SUPPORTED_MANUAL_CHECK_METHODS,
PendingPayment,
get_payment_record,
list_recent_pending_payments,
method_display_name,
run_manual_check,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/payments', tags=['Cabinet Admin Payments'])
# ============ Schemas ============
class PendingPaymentResponse(BaseModel):
"""Pending payment details."""
id: int
method: str
method_display: str
identifier: str
amount_kopeks: int
amount_rubles: float
status: str
status_emoji: str
status_text: str
is_paid: bool
is_checkable: bool
created_at: datetime
expires_at: datetime | None = None
payment_url: str | None = None
user_id: int | None = None
user_telegram_id: int | None = None
user_username: str | None = None
class Config:
from_attributes = True
class PendingPaymentListResponse(BaseModel):
"""Paginated list of pending payments."""
items: list[PendingPaymentResponse]
total: int
page: int
per_page: int
pages: int
class ManualCheckResponse(BaseModel):
"""Response after manual payment status check."""
success: bool
message: str
payment: PendingPaymentResponse | None = None
status_changed: bool = False
old_status: str | None = None
new_status: str | None = None
class PaymentsStatsResponse(BaseModel):
"""Statistics about pending payments."""
total_pending: int
by_method: dict
# ============ Helper functions ============
def _get_status_info(record: PendingPayment) -> tuple[str, str]:
"""Get status emoji and text for a pending payment."""
status_str = (record.status or '').lower()
if record.is_paid:
return '', 'Оплачено'
if record.method == PaymentMethod.PAL24:
mapping = {
'new': ('', 'Ожидает оплаты'),
'process': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'fail': ('', 'Ошибка'),
'canceled': ('', 'Отменено'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.MULENPAY:
mapping = {
'created': ('', 'Ожидает оплаты'),
'processing': ('', 'Обрабатывается'),
'hold': ('🔒', 'На удержании'),
'success': ('', 'Оплачено'),
'canceled': ('', 'Отменено'),
'error': ('', 'Ошибка'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.WATA:
mapping = {
'opened': ('', 'Ожидает оплаты'),
'pending': ('', 'Ожидает оплаты'),
'processing': ('', 'Обрабатывается'),
'paid': ('', 'Оплачено'),
'closed': ('', 'Оплачено'),
'declined': ('', 'Отклонено'),
'canceled': ('', 'Отменено'),
'expired': ('', 'Истёк'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.PLATEGA:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'inprogress': ('', 'Обрабатывается'),
'confirmed': ('', 'Оплачено'),
'failed': ('', 'Ошибка'),
'canceled': ('', 'Отменено'),
'expired': ('', 'Истёк'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.HELEKET:
if status_str in {'pending', 'created', 'waiting', 'check', 'processing'}:
return '', 'Ожидает оплаты'
if status_str in {'paid', 'paid_over'}:
return '', 'Оплачено'
if status_str in {'cancel', 'canceled', 'fail', 'failed', 'expired'}:
return '', 'Отменено'
return '', 'Неизвестно'
if record.method == PaymentMethod.YOOKASSA:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'waiting_for_capture': ('', 'Обрабатывается'),
'succeeded': ('', 'Оплачено'),
'canceled': ('', 'Отменено'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.CRYPTOBOT:
mapping = {
'active': ('', 'Ожидает оплаты'),
'paid': ('', 'Оплачено'),
'expired': ('', 'Истёк'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.CLOUDPAYMENTS:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'authorized': ('', 'Авторизовано'),
'completed': ('', 'Оплачено'),
'failed': ('', 'Ошибка'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.FREEKASSA:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'success': ('', 'Оплачено'),
'paid': ('', 'Оплачено'),
'canceled': ('', 'Отменено'),
'error': ('', 'Ошибка'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
return '', 'Неизвестно'
def _is_checkable(record: PendingPayment) -> bool:
"""Check if payment can be manually checked."""
if record.method not in SUPPORTED_MANUAL_CHECK_METHODS:
return False
if not record.is_recent():
return False
status_str = (record.status or '').lower()
if record.method == PaymentMethod.PAL24:
return status_str in {'new', 'process'}
if record.method == PaymentMethod.MULENPAY:
return status_str in {'created', 'processing', 'hold'}
if record.method == PaymentMethod.WATA:
return status_str in {'opened', 'pending', 'processing', 'inprogress', 'in_progress'}
if record.method == PaymentMethod.PLATEGA:
return status_str in {'pending', 'inprogress', 'in_progress'}
if record.method == PaymentMethod.HELEKET:
return status_str not in {'paid', 'paid_over', 'cancel', 'canceled', 'fail', 'failed', 'expired'}
if record.method == PaymentMethod.YOOKASSA:
return status_str in {'pending', 'waiting_for_capture'}
if record.method == PaymentMethod.CRYPTOBOT:
return status_str in {'active'}
if record.method == PaymentMethod.CLOUDPAYMENTS:
return status_str in {'pending', 'authorized'}
if record.method == PaymentMethod.FREEKASSA:
return status_str in {'pending', 'created', 'processing'}
return False
def _get_payment_url(record: PendingPayment) -> str | None:
"""Extract payment URL from record."""
payment = record.payment
payment_url = getattr(payment, 'payment_url', None)
if record.method == PaymentMethod.PAL24:
payment_url = getattr(payment, 'link_url', None) or getattr(payment, 'link_page_url', None) or payment_url
elif record.method == PaymentMethod.WATA:
payment_url = getattr(payment, 'url', None) or payment_url
elif record.method == PaymentMethod.YOOKASSA:
payment_url = getattr(payment, 'confirmation_url', None) or payment_url
elif record.method == PaymentMethod.CRYPTOBOT:
payment_url = (
getattr(payment, 'bot_invoice_url', None)
or getattr(payment, 'mini_app_invoice_url', None)
or getattr(payment, 'web_app_invoice_url', None)
or payment_url
)
elif record.method == PaymentMethod.PLATEGA:
payment_url = getattr(payment, 'redirect_url', None) or payment_url
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
payment_url = getattr(payment, 'payment_url', None) or payment_url
return payment_url
def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
"""Convert PendingPayment to API response."""
status_emoji, status_text = _get_status_info(record)
return PendingPaymentResponse(
id=record.local_id,
method=record.method.value,
method_display=method_display_name(record.method),
identifier=record.identifier,
amount_kopeks=record.amount_kopeks,
amount_rubles=record.amount_kopeks / 100,
status=record.status or '',
status_emoji=status_emoji,
status_text=status_text,
is_paid=record.is_paid,
is_checkable=_is_checkable(record),
created_at=record.created_at,
expires_at=record.expires_at,
payment_url=_get_payment_url(record),
user_id=record.user.id if record.user else None,
user_telegram_id=record.user.telegram_id if record.user else None,
user_username=record.user.username if record.user else None,
)
# ============ Routes ============
@router.get('', response_model=PendingPaymentListResponse)
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),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all pending payments for admin verification."""
all_pending = await list_recent_pending_payments(db)
# Apply method filter if specified
if method_filter:
try:
filter_method = PaymentMethod(method_filter)
all_pending = [p for p in all_pending if p.method == filter_method]
except ValueError:
pass
total = len(all_pending)
pages = math.ceil(total / per_page) if total > 0 else 1
# Paginate
start_idx = (page - 1) * per_page
page_payments = all_pending[start_idx : start_idx + per_page]
items = [_record_to_response(p) for p in page_payments]
return PendingPaymentListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get('/stats', response_model=PaymentsStatsResponse)
async def get_payments_stats(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get statistics about pending payments."""
all_pending = await list_recent_pending_payments(db)
by_method = {}
for p in all_pending:
method_name = method_display_name(p.method)
if method_name not in by_method:
by_method[method_name] = 0
by_method[method_name] += 1
return PaymentsStatsResponse(
total_pending=len(all_pending),
by_method=by_method,
)
@router.get('/{method}/{payment_id}', response_model=PendingPaymentResponse)
async def get_pending_payment_details(
method: str,
payment_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get details of a specific pending payment."""
try:
payment_method = PaymentMethod(method)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid payment method: {method}',
)
record = await get_payment_record(db, payment_method, payment_id)
if not record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Payment not found',
)
return _record_to_response(record)
@router.post('/{method}/{payment_id}/check', response_model=ManualCheckResponse)
async def check_payment_status(
method: str,
payment_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Manually check and update payment status."""
try:
payment_method = PaymentMethod(method)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid payment method: {method}',
)
# Get current record
record = await get_payment_record(db, payment_method, payment_id)
if not record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Payment not found',
)
# Check if manual check is available
if not _is_checkable(record):
return ManualCheckResponse(
success=False,
message='Ручная проверка недоступна для этого платежа',
payment=_record_to_response(record),
status_changed=False,
)
old_status = record.status
old_is_paid = record.is_paid
# Run manual check
payment_service = PaymentService()
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
if not updated:
return ManualCheckResponse(
success=False,
message='Не удалось проверить статус платежа',
payment=_record_to_response(record),
status_changed=False,
)
status_changed = updated.status != old_status or updated.is_paid != old_is_paid
if status_changed:
_, new_status_text = _get_status_info(updated)
message = f'Статус обновлён: {new_status_text}'
logger.info(f'Admin {admin.id} checked payment {method}/{payment_id}: {old_status} -> {updated.status}')
else:
message = 'Статус не изменился'
return ManualCheckResponse(
success=True,
message=message,
payment=_record_to_response(updated),
status_changed=status_changed,
old_status=old_status,
new_status=updated.status,
)
+397
View File
@@ -0,0 +1,397 @@
"""Admin routes for pinned messages in cabinet."""
import logging
import time
from datetime import datetime
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PinnedMessage, User
from app.services.pinned_message_service import (
broadcast_pinned_message,
deactivate_active_pinned_message,
get_active_pinned_message,
set_active_pinned_message,
unpin_active_pinned_message,
)
from app.utils.validators import sanitize_html, validate_html_tags
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.pinned_messages import (
PinnedMessageBroadcastResponse,
PinnedMessageCreateRequest,
PinnedMessageListResponse,
PinnedMessageResponse,
PinnedMessageSettingsRequest,
PinnedMessageUnpinResponse,
PinnedMessageUpdateRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/pinned-messages', tags=['Cabinet Admin Pinned Messages'])
# Broadcast cooldown: min 60 seconds between mass operations
_BROADCAST_COOLDOWN_SECONDS = 60
_last_broadcast_time: float = 0.0
def _check_broadcast_cooldown() -> None:
global _last_broadcast_time
now = time.monotonic()
elapsed = now - _last_broadcast_time
if _last_broadcast_time > 0 and elapsed < _BROADCAST_COOLDOWN_SECONDS:
remaining = int(_BROADCAST_COOLDOWN_SECONDS - elapsed)
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
f'Broadcast cooldown active. Try again in {remaining} seconds.',
)
_last_broadcast_time = now
def _serialize_pinned_message(msg: PinnedMessage) -> PinnedMessageResponse:
return PinnedMessageResponse(
id=msg.id,
content=msg.content,
media_type=msg.media_type,
media_file_id=msg.media_file_id,
send_before_menu=msg.send_before_menu,
send_on_every_start=msg.send_on_every_start,
is_active=msg.is_active,
created_by=msg.created_by,
created_at=msg.created_at,
updated_at=msg.updated_at,
)
_cached_bot: Bot | None = None
def _get_bot() -> Bot:
global _cached_bot
if _cached_bot is None:
_cached_bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
return _cached_bot
# ============ List / Get Endpoints ============
@router.get('', response_model=PinnedMessageListResponse)
async def list_pinned_messages(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
active_only: bool = Query(False),
) -> PinnedMessageListResponse:
"""Get list of pinned messages with pagination."""
query = select(PinnedMessage).order_by(PinnedMessage.created_at.desc())
count_query = select(func.count(PinnedMessage.id))
if active_only:
query = query.where(PinnedMessage.is_active.is_(True))
count_query = count_query.where(PinnedMessage.is_active.is_(True))
total = await db.scalar(count_query) or 0
result = await db.execute(query.offset(offset).limit(limit))
items = result.scalars().all()
return PinnedMessageListResponse(
items=[_serialize_pinned_message(msg) for msg in items],
total=int(total),
limit=limit,
offset=offset,
)
@router.get('/active', response_model=PinnedMessageResponse | None)
async def get_active_message(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse | None:
"""Get current active pinned message."""
msg = await get_active_pinned_message(db)
if not msg:
return None
return _serialize_pinned_message(msg)
@router.get('/{message_id}', response_model=PinnedMessageResponse)
async def get_pinned_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Get pinned message by ID."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
return _serialize_pinned_message(msg)
# ============ Create / Update Endpoints ============
@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),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""
Create a new pinned message.
Automatically deactivates previous active message.
If broadcast=true, sends to all active users immediately.
"""
# Проверяем cooldown ДО мутации в БД
if payload.broadcast:
_check_broadcast_cooldown()
content = payload.content.strip()
if not content and not payload.media:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Either content or media must be provided')
media_type = payload.media.type if payload.media else None
media_file_id = payload.media.file_id if payload.media else None
try:
msg = await set_active_pinned_message(
db=db,
content=content,
created_by=admin.id,
media_type=media_type,
media_file_id=media_file_id,
send_before_menu=payload.send_before_menu,
send_on_every_start=payload.send_on_every_start,
)
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
sent_count = 0
failed_count = 0
if payload.broadcast:
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} created pinned message #{msg.id} (broadcast={payload.broadcast})')
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@router.patch('/{message_id}', response_model=PinnedMessageResponse)
async def update_pinned_message(
message_id: int,
payload: PinnedMessageUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Update a pinned message content, media, or settings."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
if payload.content is not None:
sanitized = sanitize_html(payload.content)
is_valid, error = validate_html_tags(sanitized)
if not is_valid:
raise HTTPException(status.HTTP_400_BAD_REQUEST, error)
msg.content = sanitized
if payload.media is not None:
msg.media_type = payload.media.type
msg.media_file_id = payload.media.file_id
if payload.send_before_menu is not None:
msg.send_before_menu = payload.send_before_menu
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
logger.info(f'Admin {admin.id} updated pinned message #{message_id}')
return _serialize_pinned_message(msg)
@router.patch('/{message_id}/settings', response_model=PinnedMessageResponse)
async def update_pinned_message_settings(
message_id: int,
payload: PinnedMessageSettingsRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Update only pinned message display settings."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
if payload.send_before_menu is not None:
msg.send_before_menu = payload.send_before_menu
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
return _serialize_pinned_message(msg)
# ============ Active Message Actions (before /{message_id} POST routes) ============
@router.post('/active/deactivate', response_model=PinnedMessageResponse | None)
async def deactivate_active_message(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse | None:
"""Deactivate the current active pinned message without unpinning from users."""
msg = await deactivate_active_pinned_message(db)
if not msg:
return None
logger.info(f'Admin {admin.id} deactivated pinned message #{msg.id}')
return _serialize_pinned_message(msg)
@router.post('/active/unpin', response_model=PinnedMessageUnpinResponse)
async def unpin_active_message(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageUnpinResponse:
"""Unpin messages from all users and deactivate the active pinned message."""
_check_broadcast_cooldown()
unpinned_count, failed_count, was_active = await unpin_active_pinned_message(_get_bot(), db)
if was_active:
logger.info(f'Admin {admin.id} unpinned active message: unpinned={unpinned_count}, failed={failed_count}')
return PinnedMessageUnpinResponse(
unpinned_count=unpinned_count,
failed_count=failed_count,
was_active=was_active,
)
# ============ Per-Message Actions ============
@router.post('/{message_id}/activate', response_model=PinnedMessageBroadcastResponse)
async def activate_pinned_message(
message_id: int,
broadcast: bool = Query(False),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""
Activate a pinned message.
Deactivates the current active message and activates the specified one.
If broadcast=true, sends to all active users immediately.
"""
# Проверяем cooldown ДО мутации в БД
if broadcast:
_check_broadcast_cooldown()
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
await db.execute(
update(PinnedMessage)
.where(PinnedMessage.is_active.is_(True))
.values(is_active=False, updated_at=datetime.utcnow())
)
msg.is_active = True
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
sent_count = 0
failed_count = 0
if broadcast:
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} activated pinned message #{message_id} (broadcast={broadcast})')
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse)
async def broadcast_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""Broadcast a pinned message to all active users."""
_check_broadcast_cooldown()
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} broadcast pinned message #{message_id}: sent={sent_count}, failed={failed_count}')
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@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),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete a pinned message. Active messages must be deactivated first."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
if msg.is_active:
raise HTTPException(
status.HTTP_409_CONFLICT,
'Cannot delete active pinned message. Deactivate it first.',
)
await db.delete(msg)
await db.commit()
logger.info(f'Admin {admin.id} deleted pinned message #{message_id}')
+637
View File
@@ -0,0 +1,637 @@
"""Admin promo offers routes for cabinet."""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from typing import Any
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.discount_offer import (
count_discount_offers,
list_discount_offers,
upsert_discount_offer,
)
from app.database.crud.promo_offer_log import list_promo_offer_logs
from app.database.crud.promo_offer_template import (
ensure_default_templates,
get_promo_offer_template_by_id,
list_promo_offer_templates,
update_promo_offer_template,
)
from app.database.crud.user import get_user_by_email, get_user_by_telegram_id
from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, User
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/promo-offers', tags=['Admin Promo Offers'])
# ============== Schemas ==============
class PromoOfferUserInfo(BaseModel):
id: int
telegram_id: int | None = None # Can be None for email-only users
email: str | None = None
username: str | None = None
first_name: str | None = None
last_name: str | None = None
full_name: str | None = None
class PromoOfferResponse(BaseModel):
id: int
user_id: int
subscription_id: int | None = None
notification_type: str | None = None
discount_percent: int | None = None
bonus_amount_kopeks: int | None = None
expires_at: datetime | None = None
claimed_at: datetime | None = None
is_active: bool
effect_type: str | None = None
extra_data: dict[str, Any] = Field(default_factory=dict)
created_at: datetime | None = None
updated_at: datetime | None = None
user: PromoOfferUserInfo | None = None
class PromoOfferListResponse(BaseModel):
items: list[PromoOfferResponse]
total: int
limit: int
offset: int
class PromoOfferTemplateResponse(BaseModel):
id: int
name: str
offer_type: str
message_text: str
button_text: str
valid_hours: int
discount_percent: int
bonus_amount_kopeks: int
active_discount_hours: int | None = None
test_duration_hours: int | None = None
test_squad_uuids: list[str] = Field(default_factory=list)
is_active: bool
created_by: int | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class PromoOfferTemplateListResponse(BaseModel):
items: list[PromoOfferTemplateResponse]
class PromoOfferTemplateUpdateRequest(BaseModel):
name: str | None = None
message_text: str | None = None
button_text: str | None = None
valid_hours: int | None = Field(None, ge=1)
discount_percent: int | None = Field(None, ge=0)
bonus_amount_kopeks: int | None = Field(None, ge=0)
active_discount_hours: int | None = Field(None, ge=1)
test_duration_hours: int | None = Field(None, ge=1)
test_squad_uuids: list[str] | None = None
is_active: bool | None = None
class PromoOfferBroadcastRequest(BaseModel):
notification_type: str = Field(..., min_length=1)
valid_hours: int = Field(..., ge=1)
discount_percent: int = Field(0, ge=0)
bonus_amount_kopeks: int = Field(0, ge=0)
effect_type: str = Field('percent_discount', min_length=1)
extra_data: dict[str, Any] = Field(default_factory=dict)
target: str | None = None
user_id: int | None = None
telegram_id: int | None = None
email: str | None = Field(None, description='User email (for email-only users)')
# Telegram notification options
send_notification: bool = Field(False, description='Send Telegram notification to users')
message_text: str | None = Field(None, description='Custom message text (HTML)')
button_text: str | None = Field(None, description='Button text')
class PromoOfferBroadcastResponse(BaseModel):
created_offers: int
user_ids: list[int]
target: str | None = None
notifications_sent: int = 0
notifications_failed: int = 0
class PromoOfferLogOfferInfo(BaseModel):
id: int
notification_type: str | None = None
discount_percent: int | None = None
bonus_amount_kopeks: int | None = None
effect_type: str | None = None
expires_at: datetime | None = None
claimed_at: datetime | None = None
is_active: bool | None = None
class PromoOfferLogResponse(BaseModel):
id: int
user_id: int | None = None
offer_id: int | None = None
action: str
source: str | None = None
percent: int | None = None
effect_type: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
user: PromoOfferUserInfo | None = None
offer: PromoOfferLogOfferInfo | None = None
class PromoOfferLogListResponse(BaseModel):
items: list[PromoOfferLogResponse]
total: int
limit: int
offset: int
# ============== Helpers ==============
def _serialize_user(user: User | None) -> PromoOfferUserInfo | None:
if not user:
return None
return PromoOfferUserInfo(
id=user.id,
telegram_id=user.telegram_id,
email=user.email,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
full_name=getattr(user, 'full_name', None),
)
def _serialize_offer(offer: DiscountOffer) -> PromoOfferResponse:
return PromoOfferResponse(
id=offer.id,
user_id=offer.user_id,
subscription_id=offer.subscription_id,
notification_type=offer.notification_type,
discount_percent=offer.discount_percent,
bonus_amount_kopeks=offer.bonus_amount_kopeks,
expires_at=offer.expires_at,
claimed_at=offer.claimed_at,
is_active=offer.is_active,
effect_type=offer.effect_type,
extra_data=offer.extra_data or {},
created_at=offer.created_at,
updated_at=offer.updated_at,
user=_serialize_user(getattr(offer, 'user', None)),
)
def _serialize_template(template: PromoOfferTemplate) -> PromoOfferTemplateResponse:
return PromoOfferTemplateResponse(
id=template.id,
name=template.name,
offer_type=template.offer_type,
message_text=template.message_text,
button_text=template.button_text,
valid_hours=template.valid_hours,
discount_percent=template.discount_percent,
bonus_amount_kopeks=template.bonus_amount_kopeks,
active_discount_hours=template.active_discount_hours,
test_duration_hours=template.test_duration_hours,
test_squad_uuids=[str(uuid) for uuid in (template.test_squad_uuids or [])],
is_active=template.is_active,
created_by=template.created_by,
created_at=template.created_at,
updated_at=template.updated_at,
)
def _serialize_log(entry: PromoOfferLog) -> PromoOfferLogResponse:
user_info = _serialize_user(getattr(entry, 'user', None))
offer = getattr(entry, 'offer', None)
offer_info: PromoOfferLogOfferInfo | None = None
if offer:
offer_info = PromoOfferLogOfferInfo(
id=offer.id,
notification_type=offer.notification_type,
discount_percent=offer.discount_percent,
bonus_amount_kopeks=offer.bonus_amount_kopeks,
effect_type=offer.effect_type,
expires_at=offer.expires_at,
claimed_at=offer.claimed_at,
is_active=offer.is_active,
)
return PromoOfferLogResponse(
id=entry.id,
user_id=entry.user_id,
offer_id=entry.offer_id,
action=entry.action,
source=entry.source,
percent=entry.percent,
effect_type=entry.effect_type,
details=entry.details or {},
created_at=entry.created_at,
user=user_info,
offer=offer_info,
)
async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
normalized = target.strip().lower()
if normalized.startswith('custom_'):
criteria = normalized[len('custom_') :]
return await get_custom_users(db, criteria)
return await get_target_users(db, normalized)
# ============== Template Endpoints ==============
@router.get('/templates', response_model=PromoOfferTemplateListResponse)
async def list_templates(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateListResponse:
"""Get list of promo offer templates."""
templates = await list_promo_offer_templates(db)
# Initialize default templates if none exist
if not templates:
templates = await ensure_default_templates(db, created_by=admin.id)
return PromoOfferTemplateListResponse(items=[_serialize_template(template) for template in templates])
@router.get('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
async def get_template(
template_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateResponse:
"""Get a promo offer template."""
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found')
return _serialize_template(template)
@router.patch('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
async def update_template(
template_id: int,
payload: PromoOfferTemplateUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateResponse:
"""Update a promo offer template."""
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found')
if payload.test_squad_uuids is not None:
normalized_squads = [str(uuid).strip() for uuid in payload.test_squad_uuids if str(uuid).strip()]
else:
normalized_squads = None
updated_template = await update_promo_offer_template(
db,
template,
name=payload.name,
message_text=payload.message_text,
button_text=payload.button_text,
valid_hours=payload.valid_hours,
discount_percent=payload.discount_percent,
bonus_amount_kopeks=payload.bonus_amount_kopeks,
active_discount_hours=payload.active_discount_hours,
test_duration_hours=payload.test_duration_hours,
test_squad_uuids=normalized_squads,
is_active=payload.is_active,
)
return _serialize_template(updated_template)
# ============== Offer Endpoints ==============
@router.get('', response_model=PromoOfferListResponse)
async def list_offers(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user_id: int | None = Query(None, ge=1),
is_active: bool | None = Query(None),
) -> PromoOfferListResponse:
"""Get list of promo offers."""
offers = await list_discount_offers(
db,
offset=offset,
limit=limit,
user_id=user_id,
is_active=is_active,
)
total = await count_discount_offers(
db,
user_id=user_id,
is_active=is_active,
)
return PromoOfferListResponse(
items=[_serialize_offer(offer) for offer in offers],
total=total,
limit=limit,
offset=offset,
)
def _get_bot() -> Bot:
"""Create bot instance for sending notifications."""
return Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
def _build_default_promo_message(
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
) -> str:
"""Build default promo notification message."""
lines = ['🎁 <b>Специальное предложение для вас!</b>\n']
if discount_percent > 0:
lines.append(f'🔥 Скидка <b>{discount_percent}%</b> на подписку')
if bonus_amount_kopeks > 0:
bonus_rub = bonus_amount_kopeks / 100
lines.append(f'💰 Бонус <b>{bonus_rub:.0f}₽</b> на баланс')
lines.append(f'\n⏰ Предложение действует <b>{valid_hours} ч.</b>')
lines.append('\nНажмите кнопку ниже, чтобы активировать!')
return '\n'.join(lines)
async def _send_promo_notifications(
offers_to_notify: list[tuple[User, DiscountOffer]],
message_text: str | None,
button_text: str | None,
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
) -> tuple[int, int]:
"""Send Telegram notifications for promo offers.
Returns:
Tuple of (sent_count, failed_count)
"""
if not offers_to_notify:
return 0, 0
bot = _get_bot()
sent = 0
failed = 0
# Build message text
text = message_text or _build_default_promo_message(
discount_percent=discount_percent,
bonus_amount_kopeks=bonus_amount_kopeks,
valid_hours=valid_hours,
)
# Default button text
btn_text = button_text or '🎁 Получить'
semaphore = asyncio.Semaphore(20)
async def send_single(user: User, offer: DiscountOffer) -> bool:
# Skip email-only users (no telegram_id)
if not user.telegram_id:
logger.debug(f'Skipping promo notification for email-only user {user.id}')
return False
async with semaphore:
try:
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
build_miniapp_or_callback_button(
text=btn_text,
callback_data=f'claim_discount_{offer.id}',
)
],
[
InlineKeyboardButton(
text='❌ Закрыть',
callback_data='promo_offer_close',
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=text,
reply_markup=keyboard,
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
logger.warning(
'Failed to send promo notification to user %s: %s',
user.telegram_id,
exc,
)
return False
except Exception as exc:
logger.error(
'Error sending promo notification to user %s: %s',
user.telegram_id,
exc,
)
return False
# Send in batches
batch_size = 50
for i in range(0, len(offers_to_notify), batch_size):
batch = offers_to_notify[i : i + batch_size]
tasks = [send_single(user, offer) for user, offer in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, bool) and result:
sent += 1
else:
failed += 1
# Small delay between batches
if i + batch_size < len(offers_to_notify):
await asyncio.sleep(0.1)
# Close bot session
await bot.session.close()
return sent, failed
@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),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferBroadcastResponse:
"""Broadcast promo offer to users with optional Telegram notification."""
recipients: dict[int, User] = {}
# Resolve target segment
if payload.target:
users = await _resolve_target_users(db, payload.target)
recipients.update({user.id: user for user in users if user and user.id})
# Resolve specific user
target_user_id = payload.user_id
user: User | None = None
if payload.telegram_id is not None:
user = await get_user_by_telegram_id(db, payload.telegram_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found by telegram_id')
if target_user_id and target_user_id != user.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
'Provided user_id does not match telegram_id',
)
target_user_id = user.id
# Support email lookup for email-only users
if payload.email is not None and user is None:
user = await get_user_by_email(db, payload.email)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found by email')
if target_user_id and target_user_id != user.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
'Provided user_id does not match email',
)
target_user_id = user.id
if target_user_id is not None:
if user is None:
user = await db.get(User, target_user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
recipients[target_user_id] = user
if not recipients:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
'No recipients: specify target or user',
)
# Create offers for all recipients and collect (user, offer) pairs
created_offers = 0
offers_to_notify: list[tuple[User, DiscountOffer]] = []
for recipient in recipients.values():
offer = await upsert_discount_offer(
db,
user_id=recipient.id,
subscription_id=None,
notification_type=payload.notification_type.strip(),
discount_percent=payload.discount_percent,
bonus_amount_kopeks=payload.bonus_amount_kopeks,
valid_hours=payload.valid_hours,
effect_type=payload.effect_type,
extra_data=payload.extra_data,
)
if offer:
created_offers += 1
offers_to_notify.append((recipient, offer))
# Send Telegram notifications if requested
notifications_sent = 0
notifications_failed = 0
if payload.send_notification and offers_to_notify:
# Render placeholders in custom message text
rendered_message_text = payload.message_text
if rendered_message_text:
extra = payload.extra_data or {}
try:
rendered_message_text = rendered_message_text.format(
discount_percent=payload.discount_percent,
valid_hours=payload.valid_hours,
active_discount_hours=extra.get('active_discount_hours') or payload.valid_hours,
test_duration_hours=extra.get('test_duration_hours') or 0,
server_name=extra.get('server_name', ''),
)
except (KeyError, ValueError, IndexError):
logger.warning('Failed to render promo message placeholders')
notifications_sent, notifications_failed = await _send_promo_notifications(
offers_to_notify=offers_to_notify,
message_text=rendered_message_text,
button_text=payload.button_text,
discount_percent=payload.discount_percent,
bonus_amount_kopeks=payload.bonus_amount_kopeks,
valid_hours=payload.valid_hours,
)
return PromoOfferBroadcastResponse(
created_offers=created_offers,
user_ids=list(recipients.keys()),
target=payload.target,
notifications_sent=notifications_sent,
notifications_failed=notifications_failed,
)
# ============== Log Endpoints ==============
@router.get('/logs', response_model=PromoOfferLogListResponse)
async def get_logs(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user_id: int | None = Query(None, ge=1),
action: str | None = Query(None, min_length=1),
) -> PromoOfferLogListResponse:
"""Get promo offer logs."""
logs, total = await list_promo_offer_logs(
db,
offset=offset,
limit=limit,
user_id=user_id,
action=action,
)
return PromoOfferLogListResponse(
items=[_serialize_log(entry) for entry in logs],
total=int(total),
limit=limit,
offset=offset,
)
+660
View File
@@ -0,0 +1,660 @@
"""Admin promocodes routes for cabinet."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.promo_group import (
count_promo_group_members,
count_promo_groups,
create_promo_group,
delete_promo_group,
get_promo_group_by_id,
get_promo_groups_with_counts,
update_promo_group,
)
from app.database.crud.promocode import (
create_promocode,
delete_promocode,
get_promocode_by_code,
get_promocode_by_id,
get_promocode_statistics,
get_promocodes_count,
get_promocodes_list,
update_promocode,
)
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, PromoGroup, User
from ..dependencies import get_cabinet_db, get_current_admin_user
router = APIRouter(prefix='/admin/promocodes', tags=['Admin Promocodes'])
# ============== Schemas ==============
class PromoCodeResponse(BaseModel):
id: int
code: str
type: PromoCodeType
balance_bonus_kopeks: int
balance_bonus_rubles: float
subscription_days: int
max_uses: int
current_uses: int
uses_left: int
is_active: bool
is_valid: bool
first_purchase_only: bool
valid_from: datetime
valid_until: datetime | None = None
promo_group_id: int | None = None
created_by: int | None = None
created_at: datetime
updated_at: datetime
class PromoCodeListResponse(BaseModel):
items: list[PromoCodeResponse]
total: int
limit: int
offset: int
class PromoCodeRecentUse(BaseModel):
id: int
user_id: int
user_username: str | None = None
user_full_name: str | None = None
user_telegram_id: int | None = None
used_at: datetime
class PromoCodeDetailResponse(PromoCodeResponse):
total_uses: int
today_uses: int
recent_uses: list[PromoCodeRecentUse] = Field(default_factory=list)
class PromoCodeCreateRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=50)
type: PromoCodeType
balance_bonus_kopeks: int = 0
subscription_days: int = 0
max_uses: int = Field(default=1, ge=0)
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool = True
first_purchase_only: bool = False
promo_group_id: int | None = None
class PromoCodeUpdateRequest(BaseModel):
code: str | None = Field(default=None, min_length=1, max_length=50)
type: PromoCodeType | None = None
balance_bonus_kopeks: int | None = None
subscription_days: int | None = None
max_uses: int | None = Field(default=None, ge=0)
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool | None = None
first_purchase_only: bool | None = None
promo_group_id: int | None = None
# ============== PromoGroup Schemas ==============
class PromoGroupResponse(BaseModel):
id: int
name: str
server_discount_percent: int
traffic_discount_percent: int
device_discount_percent: int
period_discounts: dict[int, int] = Field(default_factory=dict)
auto_assign_total_spent_kopeks: int | None = None
apply_discounts_to_addons: bool
is_default: bool
members_count: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None
class PromoGroupListResponse(BaseModel):
items: list[PromoGroupResponse]
total: int
limit: int
offset: int
class PromoGroupCreateRequest(BaseModel):
name: str
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: dict[int, int] | None = None
auto_assign_total_spent_kopeks: int | None = None
apply_discounts_to_addons: bool = True
is_default: bool = False
class PromoGroupUpdateRequest(BaseModel):
name: str | None = None
server_discount_percent: int | None = None
traffic_discount_percent: int | None = None
device_discount_percent: int | None = None
period_discounts: dict[int, int] | None = None
auto_assign_total_spent_kopeks: int | None = None
apply_discounts_to_addons: bool | None = None
is_default: bool | None = None
# ============== Helpers ==============
def _normalize_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is not None and value.utcoffset() is not None:
return value.astimezone(UTC).replace(tzinfo=None)
if value.tzinfo is not None:
return value.replace(tzinfo=None)
return value
def _serialize_promocode(promocode: PromoCode) -> PromoCodeResponse:
promo_type = PromoCodeType(promocode.type)
return PromoCodeResponse(
id=promocode.id,
code=promocode.code,
type=promo_type,
balance_bonus_kopeks=promocode.balance_bonus_kopeks,
balance_bonus_rubles=round(promocode.balance_bonus_kopeks / 100, 2),
subscription_days=promocode.subscription_days,
max_uses=promocode.max_uses,
current_uses=promocode.current_uses,
uses_left=promocode.uses_left,
is_active=promocode.is_active,
is_valid=promocode.is_valid,
first_purchase_only=promocode.first_purchase_only,
valid_from=promocode.valid_from,
valid_until=promocode.valid_until,
promo_group_id=promocode.promo_group_id,
created_by=promocode.created_by,
created_at=promocode.created_at,
updated_at=promocode.updated_at,
)
def _serialize_recent_use(use: PromoCodeUse) -> PromoCodeRecentUse:
return PromoCodeRecentUse(
id=use.id,
user_id=use.user_id,
user_username=getattr(use, 'user_username', None),
user_full_name=getattr(use, 'user_full_name', None),
user_telegram_id=getattr(use, 'user_telegram_id', None),
used_at=use.used_at,
)
def _normalize_period_discounts(group: PromoGroup) -> dict[int, int]:
raw = group.period_discounts or {}
normalized: dict[int, int] = {}
if isinstance(raw, dict):
for key, value in raw.items():
try:
normalized[int(key)] = int(value)
except (TypeError, ValueError):
continue
return normalized
def _serialize_promo_group(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse:
return PromoGroupResponse(
id=group.id,
name=group.name,
server_discount_percent=group.server_discount_percent,
traffic_discount_percent=group.traffic_discount_percent,
device_discount_percent=group.device_discount_percent,
period_discounts=_normalize_period_discounts(group),
auto_assign_total_spent_kopeks=group.auto_assign_total_spent_kopeks,
apply_discounts_to_addons=group.apply_discounts_to_addons,
is_default=group.is_default,
members_count=members_count,
created_at=getattr(group, 'created_at', None),
updated_at=getattr(group, 'updated_at', None),
)
def _validate_create_payload(payload: PromoCodeCreateRequest) -> None:
code = payload.code.strip()
if not code:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Code must not be empty')
normalized_valid_from = _normalize_datetime(payload.valid_from)
normalized_valid_until = _normalize_datetime(payload.valid_until)
if payload.type == PromoCodeType.BALANCE and payload.balance_bonus_kopeks <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Balance bonus must be positive for balance promo codes')
if payload.type in {PromoCodeType.SUBSCRIPTION_DAYS, PromoCodeType.TRIAL_SUBSCRIPTION}:
if payload.subscription_days <= 0:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, 'Subscription days must be positive for this promo code type'
)
if payload.type == PromoCodeType.DISCOUNT:
if payload.balance_bonus_kopeks <= 0 or payload.balance_bonus_kopeks > 100:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount percent must be between 1 and 100')
if payload.subscription_days <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount validity hours must be positive')
if normalized_valid_from and normalized_valid_until and normalized_valid_from > normalized_valid_until:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'valid_from cannot be greater than valid_until')
def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCode) -> None:
if payload.code is not None and not payload.code.strip():
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Code must not be empty')
if payload.type is not None:
new_type = payload.type
else:
new_type = PromoCodeType(promocode.type)
balance_bonus = (
payload.balance_bonus_kopeks if payload.balance_bonus_kopeks is not None else promocode.balance_bonus_kopeks
)
subscription_days = (
payload.subscription_days if payload.subscription_days is not None else promocode.subscription_days
)
if new_type == PromoCodeType.BALANCE and balance_bonus <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Balance bonus must be positive for balance promo codes')
if new_type in {PromoCodeType.SUBSCRIPTION_DAYS, PromoCodeType.TRIAL_SUBSCRIPTION}:
if subscription_days <= 0:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, 'Subscription days must be positive for this promo code type'
)
if new_type == PromoCodeType.DISCOUNT:
if balance_bonus <= 0 or balance_bonus > 100:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount percent must be between 1 and 100')
if subscription_days <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount validity hours must be positive')
valid_from = _normalize_datetime(payload.valid_from) if payload.valid_from is not None else promocode.valid_from
valid_until = _normalize_datetime(payload.valid_until) if payload.valid_until is not None else promocode.valid_until
if valid_from and valid_until and valid_from > valid_until:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'valid_from cannot be greater than valid_until')
if payload.max_uses is not None and payload.max_uses != 0 and payload.max_uses < promocode.current_uses:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'max_uses cannot be less than current uses')
# ============== Promocode Endpoints ==============
@router.get('', response_model=PromoCodeListResponse)
async def list_promocodes(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
is_active: bool | None = Query(default=None),
) -> PromoCodeListResponse:
"""Get list of all promocodes."""
total = await get_promocodes_count(db, is_active=is_active) or 0
promocodes = await get_promocodes_list(db, offset=offset, limit=limit, is_active=is_active)
return PromoCodeListResponse(
items=[_serialize_promocode(promocode) for promocode in promocodes],
total=int(total),
limit=limit,
offset=offset,
)
@router.get('/{promocode_id}', response_model=PromoCodeDetailResponse)
async def get_promocode(
promocode_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeDetailResponse:
"""Get promocode details with usage statistics."""
promocode = await get_promocode_by_id(db, promocode_id)
if not promocode:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
stats = await get_promocode_statistics(db, promocode_id)
base = _serialize_promocode(promocode)
recent_uses = [_serialize_recent_use(use) for use in stats.get('recent_uses', [])]
return PromoCodeDetailResponse(
**base.model_dump(),
total_uses=stats.get('total_uses', 0),
today_uses=stats.get('today_uses', 0),
recent_uses=recent_uses,
)
@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),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeResponse:
"""Create a new promocode."""
_validate_create_payload(payload)
normalized_code = payload.code.strip().upper()
normalized_valid_from = _normalize_datetime(payload.valid_from)
normalized_valid_until = _normalize_datetime(payload.valid_until)
existing = await get_promocode_by_code(db, normalized_code)
if existing:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo code with this code already exists')
# 0 means unlimited — convert to large number for is_valid check (current_uses < max_uses)
effective_max_uses = 999999 if payload.max_uses == 0 else payload.max_uses
promocode = await create_promocode(
db,
code=normalized_code,
type=payload.type,
balance_bonus_kopeks=payload.balance_bonus_kopeks,
subscription_days=payload.subscription_days,
max_uses=effective_max_uses,
valid_until=normalized_valid_until,
created_by=admin.id,
)
update_fields = {}
if normalized_valid_from is not None:
update_fields['valid_from'] = normalized_valid_from
if payload.is_active is not None and payload.is_active != promocode.is_active:
update_fields['is_active'] = payload.is_active
if normalized_valid_until is not None:
update_fields['valid_until'] = normalized_valid_until
if payload.first_purchase_only:
update_fields['first_purchase_only'] = payload.first_purchase_only
if payload.promo_group_id is not None:
update_fields['promo_group_id'] = payload.promo_group_id
if update_fields:
promocode = await update_promocode(db, promocode, **update_fields)
return _serialize_promocode(promocode)
@router.patch('/{promocode_id}', response_model=PromoCodeResponse)
async def update_promocode_endpoint(
promocode_id: int,
payload: PromoCodeUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeResponse:
"""Update an existing promocode."""
promocode = await get_promocode_by_id(db, promocode_id)
if not promocode:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
_validate_update_payload(payload, promocode)
updates: dict[str, Any] = {}
if payload.code is not None:
normalized_code = payload.code.strip().upper()
if normalized_code != promocode.code:
existing = await get_promocode_by_code(db, normalized_code)
if existing and existing.id != promocode_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo code with this code already exists')
updates['code'] = normalized_code
if payload.type is not None:
updates['type'] = payload.type.value
if payload.balance_bonus_kopeks is not None:
updates['balance_bonus_kopeks'] = payload.balance_bonus_kopeks
if payload.subscription_days is not None:
updates['subscription_days'] = payload.subscription_days
if payload.max_uses is not None:
updates['max_uses'] = 999999 if payload.max_uses == 0 else payload.max_uses
if payload.valid_from is not None:
updates['valid_from'] = _normalize_datetime(payload.valid_from)
if payload.valid_until is not None:
updates['valid_until'] = _normalize_datetime(payload.valid_until)
if payload.is_active is not None:
updates['is_active'] = payload.is_active
if payload.first_purchase_only is not None:
updates['first_purchase_only'] = payload.first_purchase_only
if payload.promo_group_id is not None:
updates['promo_group_id'] = payload.promo_group_id
if not updates:
return _serialize_promocode(promocode)
promocode = await update_promocode(db, promocode, **updates)
return _serialize_promocode(promocode)
@router.delete(
'/{promocode_id}',
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
)
async def delete_promocode_endpoint(
promocode_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> Response:
"""Delete a promocode."""
promocode = await get_promocode_by_id(db, promocode_id)
if not promocode:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
success = await delete_promocode(db, promocode)
if not success:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Failed to delete promo code')
return Response(status_code=status.HTTP_204_NO_CONTENT)
class DeactivateDiscountResponse(BaseModel):
success: bool
message: str
deactivated_code: str | None = None
discount_percent: int = 0
user_id: int
@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),
db: AsyncSession = Depends(get_cabinet_db),
) -> DeactivateDiscountResponse:
"""Admin: deactivate a user's active discount promo code."""
from app.database.crud.user import get_user_by_id as get_user
target_user = await get_user(db, user_id)
if not target_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
from app.services.promocode_service import PromoCodeService
service = PromoCodeService()
result = await service.deactivate_discount_promocode(
db=db,
user_id=user_id,
admin_initiated=True,
)
if result['success']:
return DeactivateDiscountResponse(
success=True,
message=f'Discount promo code deactivated for user {user_id}',
deactivated_code=result.get('deactivated_code'),
discount_percent=result.get('discount_percent', 0),
user_id=user_id,
)
error_messages = {
'user_not_found': 'User not found',
'no_active_discount_promocode': 'User has no active discount from a promo code',
'discount_already_expired': 'Discount has already expired (cleaned up)',
'server_error': 'Server error occurred',
}
error_code = result.get('error', 'server_error')
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
)
# ============== PromoGroup Endpoints ==============
promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo Groups'])
@promo_groups_router.get('', response_model=PromoGroupListResponse)
async def list_promo_groups(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PromoGroupListResponse:
"""Get list of all promo groups."""
total = await count_promo_groups(db)
groups_with_counts = await get_promo_groups_with_counts(
db,
offset=offset,
limit=limit,
)
return PromoGroupListResponse(
items=[_serialize_promo_group(group, members_count=count) for group, count in groups_with_counts],
total=total,
limit=limit,
offset=offset,
)
@promo_groups_router.get('/{group_id}', response_model=PromoGroupResponse)
async def get_promo_group(
group_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Get promo group details."""
group = await get_promo_group_by_id(db, group_id)
if not group:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo group not found')
members_count = await count_promo_group_members(db, group_id)
return _serialize_promo_group(group, members_count=members_count)
@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),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Create a new promo group."""
from sqlalchemy.exc import IntegrityError
try:
group = await create_promo_group(
db,
name=payload.name,
server_discount_percent=payload.server_discount_percent,
traffic_discount_percent=payload.traffic_discount_percent,
device_discount_percent=payload.device_discount_percent,
period_discounts=payload.period_discounts,
auto_assign_total_spent_kopeks=payload.auto_assign_total_spent_kopeks,
apply_discounts_to_addons=payload.apply_discounts_to_addons,
is_default=payload.is_default,
)
except IntegrityError:
await db.rollback()
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
'Promo group with this name already exists',
)
return _serialize_promo_group(group, members_count=0)
@promo_groups_router.patch('/{group_id}', response_model=PromoGroupResponse)
async def update_promo_group_endpoint(
group_id: int,
payload: PromoGroupUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Update a promo group."""
from sqlalchemy.exc import IntegrityError
group = await get_promo_group_by_id(db, group_id)
if not group:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo group not found')
try:
group = await update_promo_group(
db,
group,
name=payload.name,
server_discount_percent=payload.server_discount_percent,
traffic_discount_percent=payload.traffic_discount_percent,
device_discount_percent=payload.device_discount_percent,
period_discounts=payload.period_discounts,
auto_assign_total_spent_kopeks=payload.auto_assign_total_spent_kopeks,
apply_discounts_to_addons=payload.apply_discounts_to_addons,
is_default=payload.is_default,
)
except IntegrityError:
await db.rollback()
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
'Promo group with this name already exists',
)
members_count = await count_promo_group_members(db, group_id)
return _serialize_promo_group(group, members_count=members_count)
@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),
db: AsyncSession = Depends(get_cabinet_db),
) -> Response:
"""Delete a promo group."""
group = await get_promo_group_by_id(db, group_id)
if not group:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo group not found')
success = await delete_promo_group(db, group)
if not success:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Cannot delete default promo group')
return Response(status_code=status.HTTP_204_NO_CONTENT)
+983
View File
@@ -0,0 +1,983 @@
"""Admin routes for RemnaWave management in cabinet."""
import logging
from datetime import datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.server_squad import (
count_active_users_for_squad,
get_all_server_squads,
get_server_squad_by_uuid,
sync_with_remnawave,
)
from app.database.models import User
from app.utils.cache import cache
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.remnawave import (
AutoSyncRunResponse,
# Auto Sync
AutoSyncStatus,
AutoSyncToggleRequest,
Bandwidth,
ConnectionStatus,
# Inbounds
InboundsListResponse,
# Migration
MigrationPreviewResponse,
MigrationRequest,
MigrationResponse,
MigrationStats,
NodeActionRequest,
NodeActionResponse,
# Nodes
NodeInfo,
NodesListResponse,
NodesOverview,
NodeStatisticsResponse,
NodeUsageResponse,
# Status & Connection
RemnaWaveStatusResponse,
ServerInfo,
SquadActionRequest,
SquadCreateRequest,
SquadDetailResponse,
SquadOperationResponse,
SquadsListResponse,
SquadUpdateRequest,
# Squads
SquadWithLocalInfo,
# Manual Sync
SyncMode,
SyncResponse,
# System Statistics
SystemStatsResponse,
SystemSummary,
TrafficPeriod,
TrafficPeriods,
)
try:
from app.services.remnawave_service import (
RemnaWaveConfigurationError,
RemnaWaveService,
)
except Exception:
RemnaWaveConfigurationError = None
RemnaWaveService = None
try:
from app.services.remnawave_sync_service import remnawave_sync_service
except Exception:
remnawave_sync_service = None
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/remnawave', tags=['Cabinet Admin RemnaWave'])
# ============ Helpers ============
def _get_service() -> RemnaWaveService:
"""Get RemnaWave service instance."""
if RemnaWaveService is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='RemnaWave service is not available',
)
return RemnaWaveService()
def _ensure_configured(service: RemnaWaveService) -> None:
"""Ensure RemnaWave is configured."""
if not service.is_configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=service.configuration_error or 'RemnaWave API is not configured',
)
def _parse_datetime(value: Any) -> datetime | None:
"""Parse datetime from various formats."""
if isinstance(value, datetime):
return value
if isinstance(value, str):
try:
return datetime.fromisoformat(value)
except ValueError:
return None
return None
def _serialize_node(node_data: dict[str, Any]) -> NodeInfo:
"""Serialize node data to NodeInfo model."""
return NodeInfo(
uuid=node_data.get('uuid', ''),
name=node_data.get('name', ''),
address=node_data.get('address', ''),
country_code=node_data.get('country_code'),
is_connected=bool(node_data.get('is_connected')),
is_disabled=bool(node_data.get('is_disabled')),
is_node_online=bool(node_data.get('is_node_online')),
is_xray_running=bool(node_data.get('is_xray_running')),
users_online=node_data.get('users_online'),
traffic_used_bytes=node_data.get('traffic_used_bytes'),
traffic_limit_bytes=node_data.get('traffic_limit_bytes'),
last_status_change=_parse_datetime(node_data.get('last_status_change')),
last_status_message=node_data.get('last_status_message'),
xray_uptime=node_data.get('xray_uptime'),
is_traffic_tracking_active=bool(node_data.get('is_traffic_tracking_active', False)),
traffic_reset_day=node_data.get('traffic_reset_day'),
notify_percent=node_data.get('notify_percent'),
consumption_multiplier=float(node_data.get('consumption_multiplier', 1.0)),
cpu_count=node_data.get('cpu_count'),
cpu_model=node_data.get('cpu_model'),
total_ram=node_data.get('total_ram'),
created_at=_parse_datetime(node_data.get('created_at')),
updated_at=_parse_datetime(node_data.get('updated_at')),
provider_uuid=node_data.get('provider_uuid'),
)
# ============ Status & Connection ============
@router.get('/status', response_model=RemnaWaveStatusResponse)
async def get_remnawave_status(
admin: User = Depends(get_current_admin_user),
) -> RemnaWaveStatusResponse:
"""Get RemnaWave configuration and connection status."""
service = _get_service()
connection_info: ConnectionStatus | None = None
connection_result = await service.test_api_connection()
if connection_result:
connection_info = ConnectionStatus(**connection_result)
return RemnaWaveStatusResponse(
is_configured=service.is_configured,
configuration_error=service.configuration_error,
connection=connection_info,
)
# ============ System Statistics ============
@router.get('/system', response_model=SystemStatsResponse)
async def get_system_statistics(
admin: User = Depends(get_current_admin_user),
) -> SystemStatsResponse:
"""Get full system statistics from RemnaWave."""
service = _get_service()
_ensure_configured(service)
stats = await service.get_system_statistics()
if not stats or 'system' not in stats:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Failed to get RemnaWave statistics',
)
system_data = stats.get('system', {})
server_data = stats.get('server_info', {})
bandwidth_data = stats.get('bandwidth', {})
traffic_data = stats.get('traffic_periods', {})
return SystemStatsResponse(
system=SystemSummary(
users_online=system_data.get('users_online', 0),
total_users=system_data.get('total_users', 0),
active_connections=system_data.get('active_connections', 0),
nodes_online=system_data.get('nodes_online', 0),
users_last_day=system_data.get('users_last_day', 0),
users_last_week=system_data.get('users_last_week', 0),
users_never_online=system_data.get('users_never_online', 0),
total_user_traffic=system_data.get('total_user_traffic', 0),
),
users_by_status=stats.get('users_by_status', {}),
server_info=ServerInfo(
cpu_cores=server_data.get('cpu_cores', 0),
cpu_physical_cores=server_data.get('cpu_physical_cores', 0),
memory_total=server_data.get('memory_total', 0),
memory_used=server_data.get('memory_used', 0),
memory_free=server_data.get('memory_free', 0),
memory_available=server_data.get('memory_available', 0),
uptime_seconds=server_data.get('uptime_seconds', 0),
),
bandwidth=Bandwidth(
realtime_download=bandwidth_data.get('realtime_download', 0),
realtime_upload=bandwidth_data.get('realtime_upload', 0),
realtime_total=bandwidth_data.get('realtime_total', 0),
),
traffic_periods=TrafficPeriods(
last_2_days=TrafficPeriod(**traffic_data.get('last_2_days', {'current': 0, 'previous': 0})),
last_7_days=TrafficPeriod(**traffic_data.get('last_7_days', {'current': 0, 'previous': 0})),
last_30_days=TrafficPeriod(**traffic_data.get('last_30_days', {'current': 0, 'previous': 0})),
current_month=TrafficPeriod(**traffic_data.get('current_month', {'current': 0, 'previous': 0})),
current_year=TrafficPeriod(**traffic_data.get('current_year', {'current': 0, 'previous': 0})),
),
nodes_realtime=stats.get('nodes_realtime', []),
nodes_weekly=stats.get('nodes_weekly', []),
last_updated=_parse_datetime(stats.get('last_updated')),
)
# ============ Nodes ============
@router.get('/nodes', response_model=NodesListResponse)
async def list_nodes(
admin: User = Depends(get_current_admin_user),
) -> NodesListResponse:
"""Get list of all nodes."""
service = _get_service()
_ensure_configured(service)
nodes = await service.get_all_nodes()
serialized = [_serialize_node(node) for node in nodes]
return NodesListResponse(items=serialized, total=len(serialized))
@router.get('/nodes/overview', response_model=NodesOverview)
async def get_nodes_overview(
admin: User = Depends(get_current_admin_user),
) -> NodesOverview:
"""Get nodes overview with statistics."""
service = _get_service()
_ensure_configured(service)
nodes = await service.get_all_nodes()
total = len(nodes)
online = sum(1 for n in nodes if n.get('is_connected') and not n.get('is_disabled'))
disabled = sum(1 for n in nodes if n.get('is_disabled'))
offline = total - online - disabled
total_users_online = sum(n.get('users_online', 0) or 0 for n in nodes)
return NodesOverview(
total=total,
online=online,
offline=offline,
disabled=disabled,
total_users_online=total_users_online,
nodes=[_serialize_node(n) for n in nodes],
)
@router.get('/nodes/realtime')
async def get_nodes_realtime(
admin: User = Depends(get_current_admin_user),
) -> list[dict[str, Any]]:
"""Get realtime node usage data."""
service = _get_service()
_ensure_configured(service)
return await service.get_nodes_realtime_usage()
@router.get('/nodes/{node_uuid}', response_model=NodeInfo)
async def get_node_details(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
) -> NodeInfo:
"""Get detailed information about a specific node."""
service = _get_service()
_ensure_configured(service)
node = await service.get_node_details(node_uuid)
if not node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Node not found',
)
return _serialize_node(node)
@router.get('/nodes/{node_uuid}/statistics', response_model=NodeStatisticsResponse)
async def get_node_statistics(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
) -> NodeStatisticsResponse:
"""Get node statistics with usage history."""
service = _get_service()
_ensure_configured(service)
stats = await service.get_node_statistics(node_uuid)
if not stats or not stats.get('node'):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Node not found or no statistics available',
)
return NodeStatisticsResponse(
node=_serialize_node(stats['node']),
realtime=stats.get('realtime'),
usage_history=stats.get('usage_history') or [],
last_updated=_parse_datetime(stats.get('last_updated')),
)
@router.get('/nodes/{node_uuid}/usage', response_model=NodeUsageResponse)
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),
) -> NodeUsageResponse:
"""Get node usage history for a date range."""
service = _get_service()
_ensure_configured(service)
end_dt = end or datetime.utcnow()
start_dt = start or (end_dt - timedelta(days=7))
if start_dt >= end_dt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid date range',
)
usage = await service.get_node_user_usage_by_range(node_uuid, start_dt, end_dt)
return NodeUsageResponse(items=usage or [])
@router.post('/nodes/{node_uuid}/action', response_model=NodeActionResponse)
async def perform_node_action(
node_uuid: str,
payload: NodeActionRequest,
admin: User = Depends(get_current_admin_user),
) -> NodeActionResponse:
"""Perform an action on a node (enable/disable/restart)."""
service = _get_service()
_ensure_configured(service)
# Get current node state for toggle operations
if payload.action in ('enable', 'disable'):
nodes = await service.get_all_nodes()
node = next((n for n in nodes if n.get('uuid') == node_uuid), None)
if not node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Node not found',
)
success = await service.manage_node(node_uuid, payload.action)
messages = {
'enable': 'Node enabled',
'disable': 'Node disabled',
'restart': 'Node restart initiated',
}
if success:
logger.info(f'Admin {admin.telegram_id} performed {payload.action} on node {node_uuid}')
return NodeActionResponse(
success=True,
message=messages.get(payload.action, 'Action completed'),
is_disabled=payload.action == 'disable' if payload.action in ('enable', 'disable') else None,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Failed to {payload.action} node',
)
@router.post('/nodes/restart-all', response_model=NodeActionResponse)
async def restart_all_nodes(
admin: User = Depends(get_current_admin_user),
) -> NodeActionResponse:
"""Restart all nodes."""
service = _get_service()
_ensure_configured(service)
success = await service.restart_all_nodes()
if success:
logger.info(f'Admin {admin.telegram_id} restarted all nodes')
return NodeActionResponse(success=True, message='All nodes restart initiated')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to restart all nodes',
)
# ============ Squads (Internal Squads) ============
@router.get('/squads', response_model=SquadsListResponse)
async def list_squads(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SquadsListResponse:
"""Get list of all squads with local database info."""
service = _get_service()
_ensure_configured(service)
# Get squads from RemnaWave
rw_squads = await service.get_all_squads()
# Get local squads from DB
local_squads, _ = await get_all_server_squads(db, page=1, limit=1000)
local_by_uuid = {s.squad_uuid: s for s in local_squads}
items = []
for squad in rw_squads:
local = local_by_uuid.get(squad.get('uuid'))
items.append(
SquadWithLocalInfo(
uuid=squad.get('uuid', ''),
name=squad.get('name', ''),
members_count=squad.get('members_count', 0),
inbounds_count=squad.get('inbounds_count', 0),
inbounds=squad.get('inbounds', []),
local_id=local.id if local else None,
display_name=local.display_name if local else None,
country_code=local.country_code if local else None,
is_available=local.is_available if local else None,
is_trial_eligible=local.is_trial_eligible if local else None,
price_kopeks=local.price_kopeks if local else None,
max_users=local.max_users if local else None,
current_users=local.current_users if local else None,
is_synced=local is not None,
)
)
return SquadsListResponse(items=items, total=len(items))
@router.get('/squads/{squad_uuid}', response_model=SquadDetailResponse)
async def get_squad_details(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SquadDetailResponse:
"""Get detailed information about a squad."""
service = _get_service()
_ensure_configured(service)
# Get squad from RemnaWave
squad = await service.get_squad_details(squad_uuid)
if not squad:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Squad not found',
)
# Get local info from DB
local = await get_server_squad_by_uuid(db, squad_uuid)
active_subs = await count_active_users_for_squad(db, squad_uuid) if local else 0
return SquadDetailResponse(
uuid=squad.get('uuid', ''),
name=squad.get('name', ''),
members_count=squad.get('members_count', 0),
inbounds_count=squad.get('inbounds_count', 0),
inbounds=squad.get('inbounds', []),
local_id=local.id if local else None,
display_name=local.display_name if local else None,
country_code=local.country_code if local else None,
description=local.description if local else None,
is_available=local.is_available if local else None,
is_trial_eligible=local.is_trial_eligible if local else None,
price_kopeks=local.price_kopeks if local else None,
max_users=local.max_users if local else None,
current_users=local.current_users if local else None,
sort_order=local.sort_order if local else None,
is_synced=local is not None,
active_subscriptions=active_subs,
)
@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),
) -> SquadOperationResponse:
"""Create a new squad in RemnaWave."""
service = _get_service()
_ensure_configured(service)
squad_uuid = await service.create_squad(payload.name, payload.inbound_uuids)
if squad_uuid:
logger.info(f'Admin {admin.telegram_id} created squad {payload.name} ({squad_uuid})')
return SquadOperationResponse(
success=True,
message='Squad created successfully',
data={'uuid': squad_uuid},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to create squad',
)
@router.patch('/squads/{squad_uuid}', response_model=SquadOperationResponse)
async def update_squad(
squad_uuid: str,
payload: SquadUpdateRequest,
admin: User = Depends(get_current_admin_user),
) -> SquadOperationResponse:
"""Update a squad in RemnaWave."""
service = _get_service()
_ensure_configured(service)
if payload.name is None and payload.inbound_uuids is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No update data provided',
)
success = await service.update_squad(
squad_uuid,
name=payload.name,
inbounds=payload.inbound_uuids,
)
if success:
logger.info(f'Admin {admin.telegram_id} updated squad {squad_uuid}')
return SquadOperationResponse(success=True, message='Squad updated')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to update squad',
)
@router.post('/squads/{squad_uuid}/action', response_model=SquadOperationResponse)
async def perform_squad_action(
squad_uuid: str,
payload: SquadActionRequest,
admin: User = Depends(get_current_admin_user),
) -> SquadOperationResponse:
"""Perform an action on a squad."""
service = _get_service()
_ensure_configured(service)
action = payload.action
success = False
message = 'Unknown action'
if action == 'add_all_users':
success = await service.add_all_users_to_squad(squad_uuid)
message = 'Users added' if success else 'Failed to add users'
elif action == 'remove_all_users':
success = await service.remove_all_users_from_squad(squad_uuid)
message = 'Users removed' if success else 'Failed to remove users'
elif action == 'delete':
success = await service.delete_squad(squad_uuid)
message = 'Squad deleted' if success else 'Failed to delete squad'
elif action == 'rename':
if not payload.name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Name is required for rename action',
)
success = await service.rename_squad(squad_uuid, payload.name)
message = 'Squad renamed' if success else 'Failed to rename squad'
elif action == 'update_inbounds':
if not payload.inbound_uuids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Inbound UUIDs are required',
)
success = await service.update_squad_inbounds(squad_uuid, payload.inbound_uuids)
message = 'Inbounds updated' if success else 'Failed to update inbounds'
if success:
logger.info(f'Admin {admin.telegram_id} performed {action} on squad {squad_uuid}')
return SquadOperationResponse(success=success, message=message)
@router.delete('/squads/{squad_uuid}', response_model=SquadOperationResponse)
async def delete_squad(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
) -> SquadOperationResponse:
"""Delete a squad."""
service = _get_service()
_ensure_configured(service)
success = await service.delete_squad(squad_uuid)
if success:
logger.info(f'Admin {admin.telegram_id} deleted squad {squad_uuid}')
return SquadOperationResponse(success=True, message='Squad deleted')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to delete squad',
)
# ============ Migration ============
@router.get('/squads/{squad_uuid}/migration-preview', response_model=MigrationPreviewResponse)
async def preview_migration(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> MigrationPreviewResponse:
"""Get migration preview for a squad."""
squad = await get_server_squad_by_uuid(db, squad_uuid)
if not squad:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Squad not found in local database',
)
users_to_migrate = await count_active_users_for_squad(db, squad_uuid)
return MigrationPreviewResponse(
squad_uuid=squad.squad_uuid,
squad_name=squad.display_name,
current_users=squad.current_users or 0,
max_users=squad.max_users,
users_to_migrate=users_to_migrate,
)
@router.post('/squads/migrate', response_model=MigrationResponse)
async def migrate_squad_users(
payload: MigrationRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> MigrationResponse:
"""Migrate users from one squad to another."""
service = _get_service()
_ensure_configured(service)
source_uuid = payload.source_uuid.strip()
target_uuid = payload.target_uuid.strip()
if source_uuid == target_uuid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Source and target squads must be different',
)
source = await get_server_squad_by_uuid(db, source_uuid)
if not source:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Source squad not found',
)
target = await get_server_squad_by_uuid(db, target_uuid)
if not target:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Target squad not found',
)
try:
result = await service.migrate_squad_users(
db,
source_uuid=source.squad_uuid,
target_uuid=target.squad_uuid,
)
except RemnaWaveConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
)
if not result.get('success'):
return MigrationResponse(
success=False,
message=result.get('message') or 'Migration failed',
error=result.get('error'),
)
logger.info(f'Admin {admin.telegram_id} migrated users from {source_uuid} to {target_uuid}')
return MigrationResponse(
success=True,
message=result.get('message') or 'Migration completed',
data=MigrationStats(
source_uuid=source.squad_uuid,
target_uuid=target.squad_uuid,
total=result.get('total', 0),
updated=result.get('updated', 0),
panel_updated=result.get('panel_updated', 0),
panel_failed=result.get('panel_failed', 0),
source_removed=result.get('source_removed', 0),
target_added=result.get('target_added', 0),
),
)
# ============ Inbounds ============
@router.get('/inbounds', response_model=InboundsListResponse)
async def list_inbounds(
admin: User = Depends(get_current_admin_user),
) -> InboundsListResponse:
"""Get list of all available inbounds."""
service = _get_service()
_ensure_configured(service)
inbounds = await service.get_all_inbounds()
return InboundsListResponse(items=inbounds or [], total=len(inbounds or []))
# ============ Auto Sync ============
@router.get('/sync/auto/status', response_model=AutoSyncStatus)
async def get_auto_sync_status(
admin: User = Depends(get_current_admin_user),
) -> AutoSyncStatus:
"""Get auto sync status."""
if remnawave_sync_service is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Auto sync service is not available',
)
status_obj = remnawave_sync_service.get_status()
return AutoSyncStatus(
enabled=status_obj.enabled,
times=[t.strftime('%H:%M') for t in status_obj.times] if status_obj.times else [],
next_run=status_obj.next_run,
is_running=status_obj.is_running,
last_run_started_at=status_obj.last_run_started_at,
last_run_finished_at=status_obj.last_run_finished_at,
last_run_success=status_obj.last_run_success,
last_run_reason=status_obj.last_run_reason,
last_run_error=status_obj.last_run_error,
last_user_stats=status_obj.last_user_stats,
last_server_stats=status_obj.last_server_stats,
)
@router.post('/sync/auto/toggle', response_model=SyncResponse)
async def toggle_auto_sync(
payload: AutoSyncToggleRequest,
admin: User = Depends(get_current_admin_user),
) -> SyncResponse:
"""Toggle auto sync on/off."""
if remnawave_sync_service is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Auto sync service is not available',
)
# This would need to update settings - for now just return info
# In production, this should update REMNAWAVE_AUTO_SYNC_ENABLED setting
current_status = remnawave_sync_service.get_status()
if payload.enabled and not current_status.enabled:
# Enable - would need to update settings and refresh schedule
remnawave_sync_service.schedule_refresh(run_immediately=True)
logger.info(f'Admin {admin.telegram_id} enabled auto sync')
return SyncResponse(
success=True,
message='Auto sync enabled and scheduled',
)
if not payload.enabled and current_status.enabled:
# Disable - would need to update settings and stop scheduler
logger.info(f'Admin {admin.telegram_id} disabled auto sync')
return SyncResponse(
success=True,
message='Auto sync setting change requested. Restart may be required.',
)
return SyncResponse(
success=True,
message='No change needed',
)
@router.post('/sync/auto/run', response_model=AutoSyncRunResponse)
async def run_auto_sync_now(
admin: User = Depends(get_current_admin_user),
) -> AutoSyncRunResponse:
"""Run auto sync immediately."""
if remnawave_sync_service is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Auto sync service is not available',
)
logger.info(f'Admin {admin.telegram_id} triggered manual sync')
result = await remnawave_sync_service.run_sync_now(reason='manual')
return AutoSyncRunResponse(
started=result.get('started', False),
success=result.get('success'),
error=result.get('error'),
user_stats=result.get('user_stats'),
server_stats=result.get('server_stats'),
reason='manual',
)
# ============ Manual Sync ============
@router.post('/sync/from-panel', response_model=SyncResponse)
async def sync_from_panel(
payload: SyncMode,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync users from RemnaWave panel to bot."""
service = _get_service()
_ensure_configured(service)
try:
stats = await service.sync_users_from_panel(db, payload.mode)
logger.info(f'Admin {admin.telegram_id} synced from panel (mode: {payload.mode})')
return SyncResponse(
success=True,
message='Sync from panel completed',
data=stats,
)
except RemnaWaveConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
)
@router.post('/sync/to-panel', response_model=SyncResponse)
async def sync_to_panel(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync users from bot to RemnaWave panel."""
service = _get_service()
_ensure_configured(service)
stats = await service.sync_users_to_panel(db)
logger.info(f'Admin {admin.telegram_id} synced to panel')
return SyncResponse(
success=True,
message='Sync to panel completed',
data=stats,
)
@router.post('/sync/servers', response_model=SyncResponse)
async def sync_servers(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync servers/squads from RemnaWave."""
service = _get_service()
_ensure_configured(service)
squads = await service.get_all_squads()
if not squads:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Failed to get squads from RemnaWave',
)
created, updated, removed = await sync_with_remnawave(db, squads)
try:
await cache.delete_pattern('available_countries*')
except Exception as e:
logger.warning(f'Failed to clear countries cache: {e}')
logger.info(f'Admin {admin.telegram_id} synced servers: created={created}, updated={updated}, removed={removed}')
return SyncResponse(
success=True,
message='Servers synced successfully',
data={
'created': created,
'updated': updated,
'removed': removed,
'total': len(squads),
},
)
@router.post('/sync/subscriptions/validate', response_model=SyncResponse)
async def validate_subscriptions(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Validate and fix subscriptions."""
service = _get_service()
_ensure_configured(service)
stats = await service.validate_and_fix_subscriptions(db)
logger.info(f'Admin {admin.telegram_id} validated subscriptions')
return SyncResponse(
success=True,
message='Subscriptions validated',
data=stats,
)
@router.post('/sync/subscriptions/cleanup', response_model=SyncResponse)
async def cleanup_subscriptions(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Cleanup orphaned subscriptions."""
service = _get_service()
_ensure_configured(service)
stats = await service.cleanup_orphaned_subscriptions(db)
logger.info(f'Admin {admin.telegram_id} cleaned up subscriptions')
return SyncResponse(
success=True,
message='Cleanup completed',
data=stats,
)
@router.post('/sync/subscriptions/statuses', response_model=SyncResponse)
async def sync_subscription_statuses(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync subscription statuses."""
service = _get_service()
_ensure_configured(service)
stats = await service.sync_subscription_statuses(db)
logger.info(f'Admin {admin.telegram_id} synced subscription statuses')
return SyncResponse(
success=True,
message='Subscription statuses synced',
data=stats,
)
@router.get('/sync/recommendations', response_model=SyncResponse)
async def get_sync_recommendations(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Get sync recommendations."""
service = _get_service()
_ensure_configured(service)
data = await service.get_sync_recommendations(db)
return SyncResponse(
success=True,
message='Recommendations retrieved',
data=data,
)
+330
View File
@@ -0,0 +1,330 @@
"""Admin routes for managing servers in cabinet."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import String, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.server_squad import (
count_active_users_for_squad,
get_all_server_squads,
get_server_squad_by_id,
sync_with_remnawave,
update_server_squad,
update_server_squad_promo_groups,
)
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 ..schemas.servers import (
PromoGroupInfo,
ServerDetailResponse,
ServerListItem,
ServerListResponse,
ServerStatsResponse,
ServerSyncResponse,
ServerToggleResponse,
ServerTrialToggleResponse,
ServerUpdateRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/servers', tags=['Cabinet Admin Servers'])
async def _get_server_promo_groups(db: AsyncSession, server: ServerSquad) -> list[PromoGroupInfo]:
"""Get promo group info for server."""
result = await db.execute(select(PromoGroup).order_by(PromoGroup.name))
all_groups = result.scalars().all()
selected_ids = {pg.id for pg in server.allowed_promo_groups} if server.allowed_promo_groups else set()
return [
PromoGroupInfo(
id=pg.id,
name=pg.name,
is_selected=pg.id in selected_ids,
)
for pg in all_groups
]
async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> list[str]:
"""Get list of tariff names using this server."""
# Get all tariffs and filter in Python since JSON array queries are DB-specific
result = await db.execute(select(Tariff.name, Tariff.allowed_squads))
tariff_names = []
for name, allowed_squads in result.fetchall():
if allowed_squads and squad_uuid in allowed_squads:
tariff_names.append(name)
return tariff_names
@router.get('', response_model=ServerListResponse)
async def list_servers(
include_unavailable: bool = True,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all servers."""
servers, total = await get_all_server_squads(
db,
available_only=not include_unavailable,
)
items = []
for server in servers:
items.append(
ServerListItem(
id=server.id,
squad_uuid=server.squad_uuid,
display_name=server.display_name,
original_name=server.original_name,
country_code=server.country_code,
is_available=server.is_available,
is_trial_eligible=server.is_trial_eligible,
price_kopeks=server.price_kopeks,
price_rubles=server.price_kopeks / 100,
max_users=server.max_users,
current_users=server.current_users or 0,
sort_order=server.sort_order,
is_full=server.is_full,
availability_status=server.availability_status,
created_at=server.created_at,
)
)
return ServerListResponse(servers=items, total=total)
@router.get('/{server_id}', response_model=ServerDetailResponse)
async def get_server(
server_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed server info."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Server not found',
)
promo_groups = await _get_server_promo_groups(db, server)
tariffs_using = await _get_tariffs_using_server(db, server.squad_uuid)
active_subs = await count_active_users_for_squad(db, server.squad_uuid)
return ServerDetailResponse(
id=server.id,
squad_uuid=server.squad_uuid,
display_name=server.display_name,
original_name=server.original_name,
country_code=server.country_code,
description=server.description,
is_available=server.is_available,
is_trial_eligible=server.is_trial_eligible,
price_kopeks=server.price_kopeks,
price_rubles=server.price_kopeks / 100,
max_users=server.max_users,
current_users=server.current_users or 0,
sort_order=server.sort_order,
is_full=server.is_full,
availability_status=server.availability_status,
promo_groups=promo_groups,
active_subscriptions=active_subs,
tariffs_using=tariffs_using,
created_at=server.created_at,
updated_at=server.updated_at,
)
@router.put('/{server_id}', response_model=ServerDetailResponse)
async def update_existing_server(
server_id: int,
request: ServerUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing server."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Server not found',
)
# Build updates dict
updates = {}
if request.display_name is not None:
updates['display_name'] = request.display_name
if request.description is not None:
updates['description'] = request.description
if request.country_code is not None:
updates['country_code'] = request.country_code
if request.is_available is not None:
updates['is_available'] = request.is_available
if request.is_trial_eligible is not None:
updates['is_trial_eligible'] = request.is_trial_eligible
if request.price_kopeks is not None:
updates['price_kopeks'] = request.price_kopeks
if request.max_users is not None:
updates['max_users'] = request.max_users if request.max_users > 0 else None
if request.sort_order is not None:
updates['sort_order'] = request.sort_order
if updates:
await update_server_squad(db, server_id, **updates)
# Update promo groups separately
if request.promo_group_ids is not None:
await update_server_squad_promo_groups(db, server_id, request.promo_group_ids)
logger.info(f'Admin {admin.id} updated server {server_id}')
return await get_server(server_id, admin, db)
@router.post('/{server_id}/toggle', response_model=ServerToggleResponse)
async def toggle_server(
server_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle server availability."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Server not found',
)
new_status = not server.is_available
await update_server_squad(db, server_id, is_available=new_status)
status_text = 'enabled' if new_status else 'disabled'
logger.info(f'Admin {admin.id} {status_text} server {server_id}')
return ServerToggleResponse(
id=server_id,
is_available=new_status,
message=f'Server {status_text}',
)
@router.post('/{server_id}/trial', response_model=ServerTrialToggleResponse)
async def toggle_server_trial(
server_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle server trial eligibility."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Server not found',
)
new_status = not server.is_trial_eligible
await update_server_squad(db, server_id, is_trial_eligible=new_status)
status_text = 'enabled for trial' if new_status else 'disabled for trial'
logger.info(f'Admin {admin.id} {status_text} server {server_id}')
return ServerTrialToggleResponse(
id=server_id,
is_trial_eligible=new_status,
message=f'Server {status_text}',
)
@router.get('/{server_id}/stats', response_model=ServerStatsResponse)
async def get_server_stats(
server_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get server statistics."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Server not found',
)
active_subs = await count_active_users_for_squad(db, server.squad_uuid)
# Count trial subscriptions on this server
# Use LIKE query for JSON array since .contains() is DB-specific
trial_result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.is_trial == True,
Subscription.status == 'active',
func.cast(Subscription.connected_squads, String).like(f'%"{server.squad_uuid}"%'),
)
)
trial_count = trial_result.scalar() or 0
usage_percent = None
if server.max_users and server.max_users > 0:
usage_percent = round((server.current_users or 0) / server.max_users * 100, 1)
return ServerStatsResponse(
id=server_id,
display_name=server.display_name,
squad_uuid=server.squad_uuid,
current_users=server.current_users or 0,
max_users=server.max_users,
active_subscriptions=active_subs,
trial_subscriptions=trial_count,
usage_percent=usage_percent,
)
@router.post('/sync', response_model=ServerSyncResponse)
async def sync_servers(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Sync servers with RemnaWave."""
try:
subscription_service = SubscriptionService()
if not subscription_service.is_configured:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='RemnaWave is not configured',
)
# Get squads from RemnaWave
squads = await subscription_service.get_remnawave_squads()
if squads is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to fetch squads from RemnaWave',
)
# Sync with database
created, updated, removed = await sync_with_remnawave(db, squads)
logger.info(f'Admin {admin.id} synced servers: +{created} ~{updated} -{removed}')
return ServerSyncResponse(
created=created,
updated=updated,
removed=removed,
message=f'Synced: {created} created, {updated} updated, {removed} removed',
)
except HTTPException:
raise
except Exception as e:
logger.error(f'Failed to sync servers: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Sync failed: {e!s}',
)
+274
View File
@@ -0,0 +1,274 @@
"""Admin settings routes for cabinet - system configuration management."""
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.system_settings_service import (
ReadOnlySettingError,
bot_configuration_service,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/settings', tags=['Admin Settings'])
# ============ Schemas ============
class SettingCategoryRef(BaseModel):
"""Reference to category."""
key: str
label: str
class SettingCategorySummary(BaseModel):
"""Category summary."""
key: str
label: str
description: str = ''
items: int
class SettingChoice(BaseModel):
"""Choice option for setting."""
value: Any
label: str
description: str | None = None
class SettingHint(BaseModel):
"""Setting hints and guidance."""
description: str = ''
format: str = ''
example: str = ''
warning: str = ''
class SettingDefinition(BaseModel):
"""Full setting definition with current state."""
key: str
name: str
category: SettingCategoryRef
type: str
is_optional: bool
current: Any = Field(default=None)
original: Any = Field(default=None)
has_override: bool
read_only: bool = Field(default=False)
choices: list[SettingChoice] = Field(default_factory=list)
hint: SettingHint | None = None
class SettingUpdateRequest(BaseModel):
"""Request to update setting value."""
value: Any
# ============ Helper Functions ============
def _coerce_value(key: str, value: Any) -> Any:
"""Convert and validate value for a setting."""
definition = bot_configuration_service.get_definition(key)
if value is None:
if definition.is_optional:
return None
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Value is required')
python_type = definition.python_type
try:
if python_type is bool:
if isinstance(value, bool):
normalized = value
elif isinstance(value, str):
lowered = value.strip().lower()
if lowered in {'true', '1', 'yes', 'on', 'да'}:
normalized = True
elif lowered in {'false', '0', 'no', 'off', 'нет'}:
normalized = False
else:
raise ValueError('invalid bool')
else:
raise ValueError('invalid bool')
elif python_type is int:
normalized = int(value)
elif python_type is float:
normalized = float(value)
else:
normalized = str(value)
except ValueError:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Invalid value type') from None
choices = bot_configuration_service.get_choice_options(key)
if choices:
allowed_values = {option.value for option in choices}
if normalized not in allowed_values:
readable = ', '.join(bot_configuration_service.format_value(opt.value) for opt in choices)
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f'Value must be one of: {readable}',
)
return normalized
def _serialize_definition(definition, include_choices: bool = True) -> SettingDefinition:
"""Serialize setting definition to response model."""
current = bot_configuration_service.get_current_value(definition.key)
original = bot_configuration_service.get_original_value(definition.key)
has_override = bot_configuration_service.has_override(definition.key)
choices: list[SettingChoice] = []
if include_choices:
choices = [
SettingChoice(
value=option.value,
label=option.label,
description=option.description,
)
for option in bot_configuration_service.get_choice_options(definition.key)
]
# Get setting hints
guidance = bot_configuration_service.get_setting_guidance(definition.key)
hint = SettingHint(
description=guidance.get('description', ''),
format=guidance.get('format', ''),
example=guidance.get('example', ''),
warning=guidance.get('warning', ''),
)
return SettingDefinition(
key=definition.key,
name=definition.display_name,
category=SettingCategoryRef(
key=definition.category_key,
label=definition.category_label,
),
type=definition.type_label,
is_optional=definition.is_optional,
current=current,
original=original,
has_override=has_override,
read_only=bot_configuration_service.is_read_only(definition.key),
choices=choices,
hint=hint,
)
# ============ Routes ============
@router.get('/categories', response_model=list[SettingCategorySummary])
async def list_categories(
admin: User = Depends(get_current_admin_user),
):
"""Get list of setting categories."""
categories = bot_configuration_service.get_categories()
return [
SettingCategorySummary(
key=key,
label=label,
description=bot_configuration_service.get_category_description(key),
items=count,
)
for key, label, count in categories
]
@router.get('', response_model=list[SettingDefinition])
async def list_settings(
admin: User = Depends(get_current_admin_user),
category: str | None = Query(default=None, alias='category_key'),
):
"""Get list of all settings or settings for a specific category."""
items: list[SettingDefinition] = []
if category:
definitions = bot_configuration_service.get_settings_for_category(category)
items.extend(_serialize_definition(defn) for defn in definitions)
return items
for category_key, _, _ in bot_configuration_service.get_categories():
definitions = bot_configuration_service.get_settings_for_category(category_key)
items.extend(_serialize_definition(defn) for defn in definitions)
return items
@router.get('/{key}', response_model=SettingDefinition)
async def get_setting(
key: str,
admin: User = Depends(get_current_admin_user),
):
"""Get a specific setting by key."""
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
return _serialize_definition(definition)
@router.put('/{key}', response_model=SettingDefinition)
async def update_setting(
key: str,
payload: SettingUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update a setting value."""
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
value = _coerce_value(key, payload.value)
try:
await bot_configuration_service.set_value(db, key, value)
except ReadOnlySettingError as error:
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
await db.commit()
logger.info(f'Admin {admin.telegram_id} updated setting {key} to {value}')
return _serialize_definition(definition)
@router.delete('/{key}', response_model=SettingDefinition)
async def reset_setting(
key: str,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset a setting to its default value."""
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
try:
await bot_configuration_service.reset_value(db, key)
except ReadOnlySettingError as error:
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
await db.commit()
logger.info(f'Admin {admin.telegram_id} reset setting {key}')
return _serialize_definition(definition)
File diff suppressed because it is too large Load Diff
+551
View File
@@ -0,0 +1,551 @@
"""Admin routes for managing tariffs in cabinet."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import (
create_tariff,
delete_tariff,
get_all_tariffs,
get_tariff_by_id,
get_tariff_subscriptions_count,
load_period_prices_from_db,
reorder_tariffs,
set_tariff_promo_groups,
update_tariff,
)
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.tariffs import (
PeriodPrice,
PromoGroupInfo,
ServerInfo,
ServerTrafficLimit,
TariffCreateRequest,
TariffDetailResponse,
TariffListItem,
TariffListResponse,
TariffSortOrderRequest,
TariffStatsResponse,
TariffToggleResponse,
TariffTrialResponse,
TariffUpdateRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/tariffs', tags=['Cabinet Admin Tariffs'])
async def _get_tariff_servers(
db: AsyncSession, allowed_squads: list[str], server_traffic_limits: dict = None
) -> list[ServerInfo]:
"""Get server info for tariff."""
servers, _ = await get_all_server_squads(db, available_only=False)
limits = server_traffic_limits or {}
result = []
for server in servers:
# Получаем индивидуальный лимит трафика для сервера
server_limit = None
if server.squad_uuid in limits:
limit_data = limits[server.squad_uuid]
if isinstance(limit_data, dict) and 'traffic_limit_gb' in limit_data:
server_limit = limit_data['traffic_limit_gb']
elif isinstance(limit_data, int):
server_limit = limit_data
result.append(
ServerInfo(
id=server.id,
squad_uuid=server.squad_uuid,
display_name=server.display_name,
country_code=server.country_code,
is_selected=server.squad_uuid in allowed_squads,
traffic_limit_gb=server_limit,
)
)
return result
async def _get_tariff_promo_groups(db: AsyncSession, tariff: Tariff) -> list[PromoGroupInfo]:
"""Get promo group info for tariff."""
result = await db.execute(select(PromoGroup).order_by(PromoGroup.name))
all_groups = result.scalars().all()
selected_ids = {pg.id for pg in tariff.allowed_promo_groups} if tariff.allowed_promo_groups else set()
return [
PromoGroupInfo(
id=pg.id,
name=pg.name,
is_selected=pg.id in selected_ids,
)
for pg in all_groups
]
def _period_prices_to_list(period_prices: dict) -> list[PeriodPrice]:
"""Convert period_prices dict to list."""
if not period_prices:
return []
return [
PeriodPrice(days=int(days), price_kopeks=price)
for days, price in sorted(period_prices.items(), key=lambda x: int(x[0]))
]
def _period_prices_to_dict(period_prices: list[PeriodPrice]) -> dict:
"""Convert period_prices list to dict."""
return {str(pp.days): pp.price_kopeks for pp in period_prices}
@router.get('', response_model=TariffListResponse)
async def list_tariffs(
include_inactive: bool = True,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all tariffs."""
tariffs = await get_all_tariffs(db, include_inactive=include_inactive)
items = []
for tariff in tariffs:
subs_count = await get_tariff_subscriptions_count(db, tariff.id)
items.append(
TariffListItem(
id=tariff.id,
name=tariff.name,
description=tariff.description,
is_active=tariff.is_active,
is_trial_available=tariff.is_trial_available,
is_daily=tariff.is_daily,
daily_price_kopeks=tariff.daily_price_kopeks,
allow_traffic_topup=tariff.allow_traffic_topup,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
tier_level=tariff.tier_level,
display_order=tariff.display_order,
servers_count=len(tariff.allowed_squads or []),
subscriptions_count=subs_count,
created_at=tariff.created_at,
)
)
return TariffListResponse(tariffs=items, total=len(items))
@router.get('/available-servers', response_model=list[ServerInfo])
async def get_available_servers(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all servers for tariff selection."""
servers, _ = await get_all_server_squads(db, available_only=False)
return [
ServerInfo(
id=server.id,
squad_uuid=server.squad_uuid,
display_name=server.display_name,
country_code=server.country_code,
is_selected=False,
)
for server in servers
]
@router.put('/order')
async def update_tariff_order(
request: TariffSortOrderRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update the display order of tariffs."""
await reorder_tariffs(db, request.tariff_ids)
await db.commit()
logger.info(f'Admin {admin.id} updated tariff order: {request.tariff_ids}')
return {'message': 'Tariff order updated successfully'}
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
async def get_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed tariff info."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
allowed_squads = tariff.allowed_squads or []
server_traffic_limits = tariff.server_traffic_limits or {}
servers = await _get_tariff_servers(db, allowed_squads, server_traffic_limits)
promo_groups = await _get_tariff_promo_groups(db, tariff)
subs_count = await get_tariff_subscriptions_count(db, tariff.id)
# Преобразуем server_traffic_limits в формат для схемы
server_limits_response = {}
for uuid, limit_data in server_traffic_limits.items():
if isinstance(limit_data, dict):
server_limits_response[uuid] = ServerTrafficLimit(**limit_data)
elif isinstance(limit_data, int):
server_limits_response[uuid] = ServerTrafficLimit(traffic_limit_gb=limit_data)
return TariffDetailResponse(
id=tariff.id,
name=tariff.name,
description=tariff.description,
is_active=tariff.is_active,
is_trial_available=tariff.is_trial_available,
allow_traffic_topup=tariff.allow_traffic_topup,
traffic_topup_enabled=tariff.traffic_topup_enabled,
traffic_topup_packages=tariff.traffic_topup_packages or {},
max_topup_traffic_gb=tariff.max_topup_traffic_gb,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
device_price_kopeks=tariff.device_price_kopeks,
max_device_limit=tariff.max_device_limit,
tier_level=tariff.tier_level,
display_order=tariff.display_order,
period_prices=_period_prices_to_list(tariff.period_prices),
allowed_squads=allowed_squads,
server_traffic_limits=server_limits_response,
servers=servers,
promo_groups=promo_groups,
subscriptions_count=subs_count,
# Произвольное количество дней
custom_days_enabled=tariff.custom_days_enabled,
price_per_day_kopeks=tariff.price_per_day_kopeks,
min_days=tariff.min_days,
max_days=tariff.max_days,
# Произвольный трафик при покупке
custom_traffic_enabled=tariff.custom_traffic_enabled,
traffic_price_per_gb_kopeks=tariff.traffic_price_per_gb_kopeks,
min_traffic_gb=tariff.min_traffic_gb,
max_traffic_gb=tariff.max_traffic_gb,
# Дневной тариф
is_daily=tariff.is_daily,
daily_price_kopeks=tariff.daily_price_kopeks,
# Режим сброса трафика
traffic_reset_mode=tariff.traffic_reset_mode,
created_at=tariff.created_at,
updated_at=tariff.updated_at,
)
@router.post('', response_model=TariffDetailResponse)
async def create_new_tariff(
request: TariffCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new tariff."""
period_prices_dict = _period_prices_to_dict(request.period_prices)
# Преобразуем ServerTrafficLimit в dict для хранения
server_limits_dict = (
{uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items()}
if request.server_traffic_limits
else {}
)
tariff = await create_tariff(
db=db,
name=request.name,
description=request.description,
is_active=request.is_active,
allow_traffic_topup=request.allow_traffic_topup,
traffic_topup_enabled=request.traffic_topup_enabled,
traffic_topup_packages=request.traffic_topup_packages,
max_topup_traffic_gb=request.max_topup_traffic_gb,
traffic_limit_gb=request.traffic_limit_gb,
device_limit=request.device_limit,
device_price_kopeks=request.device_price_kopeks,
max_device_limit=request.max_device_limit,
tier_level=request.tier_level,
period_prices=period_prices_dict,
allowed_squads=request.allowed_squads,
server_traffic_limits=server_limits_dict,
promo_group_ids=request.promo_group_ids if request.promo_group_ids else None,
# Произвольное количество дней
custom_days_enabled=request.custom_days_enabled,
price_per_day_kopeks=request.price_per_day_kopeks,
min_days=request.min_days,
max_days=request.max_days,
# Произвольный трафик при покупке
custom_traffic_enabled=request.custom_traffic_enabled,
traffic_price_per_gb_kopeks=request.traffic_price_per_gb_kopeks,
min_traffic_gb=request.min_traffic_gb,
max_traffic_gb=request.max_traffic_gb,
# Дневной тариф
is_daily=request.is_daily,
daily_price_kopeks=request.daily_price_kopeks,
# Режим сброса трафика
traffic_reset_mode=request.traffic_reset_mode,
)
logger.info(f'Admin {admin.id} created tariff {tariff.id}: {tariff.name}')
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
# Return full detail
return await get_tariff(tariff.id, admin, db)
@router.put('/{tariff_id}', response_model=TariffDetailResponse)
async def update_existing_tariff(
tariff_id: int,
request: TariffUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing tariff."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
# Build updates dict
updates = {}
if request.name is not None:
updates['name'] = request.name
if request.description is not None:
updates['description'] = request.description
if request.is_active is not None:
updates['is_active'] = request.is_active
if request.allow_traffic_topup is not None:
updates['allow_traffic_topup'] = request.allow_traffic_topup
if request.traffic_topup_enabled is not None:
updates['traffic_topup_enabled'] = request.traffic_topup_enabled
if request.traffic_topup_packages is not None:
updates['traffic_topup_packages'] = request.traffic_topup_packages
if request.max_topup_traffic_gb is not None:
updates['max_topup_traffic_gb'] = request.max_topup_traffic_gb
if request.traffic_limit_gb is not None:
updates['traffic_limit_gb'] = request.traffic_limit_gb
if request.device_limit is not None:
updates['device_limit'] = request.device_limit
if request.device_price_kopeks is not None:
updates['device_price_kopeks'] = request.device_price_kopeks
if request.max_device_limit is not None:
updates['max_device_limit'] = request.max_device_limit
if request.tier_level is not None:
updates['tier_level'] = request.tier_level
if request.display_order is not None:
updates['display_order'] = request.display_order
if request.period_prices is not None:
updates['period_prices'] = _period_prices_to_dict(request.period_prices)
if request.allowed_squads is not None:
updates['allowed_squads'] = request.allowed_squads
if request.server_traffic_limits is not None:
# Преобразуем ServerTrafficLimit в dict для хранения
updates['server_traffic_limits'] = {
uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items()
}
# Произвольное количество дней
if request.custom_days_enabled is not None:
updates['custom_days_enabled'] = request.custom_days_enabled
if request.price_per_day_kopeks is not None:
updates['price_per_day_kopeks'] = request.price_per_day_kopeks
if request.min_days is not None:
updates['min_days'] = request.min_days
if request.max_days is not None:
updates['max_days'] = request.max_days
# Произвольный трафик при покупке
if request.custom_traffic_enabled is not None:
updates['custom_traffic_enabled'] = request.custom_traffic_enabled
if request.traffic_price_per_gb_kopeks is not None:
updates['traffic_price_per_gb_kopeks'] = request.traffic_price_per_gb_kopeks
if request.min_traffic_gb is not None:
updates['min_traffic_gb'] = request.min_traffic_gb
if request.max_traffic_gb is not None:
updates['max_traffic_gb'] = request.max_traffic_gb
# Дневной тариф
if request.is_daily is not None:
updates['is_daily'] = request.is_daily
if request.daily_price_kopeks is not None:
updates['daily_price_kopeks'] = request.daily_price_kopeks
# Режим сброса трафика (None допускается как значение для сброса к глобальной настройке)
if 'traffic_reset_mode' in request.model_fields_set:
updates['traffic_reset_mode'] = request.traffic_reset_mode
if updates:
await update_tariff(db, tariff, **updates)
# Update promo groups separately
if request.promo_group_ids is not None:
await set_tariff_promo_groups(db, tariff, request.promo_group_ids)
logger.info(f'Admin {admin.id} updated tariff {tariff_id}')
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
return await get_tariff(tariff_id, admin, db)
@router.delete('/{tariff_id}')
async def delete_existing_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a tariff."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
await delete_tariff(db, tariff)
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name} (affected subscriptions: {subs_count})')
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
return {'message': 'Tariff deleted successfully', 'affected_subscriptions': subs_count}
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
async def toggle_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle tariff active status."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
new_status = not tariff.is_active
await update_tariff(db, tariff, is_active=new_status)
status_text = 'activated' if new_status else 'deactivated'
logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}')
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
return TariffToggleResponse(
id=tariff_id,
is_active=new_status,
message=f'Tariff {status_text}',
)
@router.post('/{tariff_id}/trial', response_model=TariffTrialResponse)
async def toggle_trial_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle tariff trial availability.
When enabling trial on a tariff, removes trial flag from all other tariffs
(only one tariff can be the trial tariff at a time).
"""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
new_status = not tariff.is_trial_available
if new_status:
# При включении триала - снимаем флаг со ВСЕХ тарифов, затем ставим на текущий
# Это гарантирует, что триальным будет только один тариф
await db.execute(Tariff.__table__.update().values(is_trial_available=False))
await db.commit()
# Обновляем объект тарифа после массового обновления
await db.refresh(tariff)
await update_tariff(db, tariff, is_trial_available=new_status)
status_text = 'set as trial' if new_status else 'removed from trial'
logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}')
return TariffTrialResponse(
id=tariff_id,
is_trial_available=new_status,
message=f'Tariff {status_text}',
)
@router.get('/{tariff_id}/stats', response_model=TariffStatsResponse)
async def get_tariff_stats(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get tariff statistics."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
# Count subscriptions
total_result = await db.execute(select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id))
total_count = total_result.scalar() or 0
# Count active subscriptions
active_result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.tariff_id == tariff_id,
Subscription.status == 'active',
)
)
active_count = active_result.scalar() or 0
# Count trial subscriptions
trial_result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.tariff_id == tariff_id,
Subscription.is_trial == True,
)
)
trial_count = trial_result.scalar() or 0
# Calculate revenue from subscription payments for users on this tariff
revenue_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.join(Subscription, Transaction.user_id == Subscription.user_id)
.where(
Subscription.tariff_id == tariff_id,
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed == True,
)
)
revenue_kopeks = revenue_result.scalar() or 0
return TariffStatsResponse(
id=tariff_id,
name=tariff.name,
subscriptions_count=total_count,
active_subscriptions=active_count,
trial_subscriptions=trial_count,
revenue_kopeks=revenue_kopeks,
revenue_rubles=revenue_kopeks / 100,
)
+607
View File
@@ -0,0 +1,607 @@
"""Admin tickets routes for cabinet."""
import logging
import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.cabinet.routes.websocket import notify_user_ticket_reply
from app.config import settings
from app.database.crud.ticket import TicketCRUD
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.database.models import Ticket, TicketMessage, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.tickets import TicketMessageResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/tickets', tags=['Cabinet Admin Tickets'])
# Admin-specific schemas
class AdminTicketUserInfo(BaseModel):
"""User info for admin view."""
id: int
telegram_id: int | None = None # Can be None for email-only users
email: str | None = None
username: str | None = None
first_name: str | None = None
last_name: str | None = None
class Config:
from_attributes = True
class AdminTicketResponse(BaseModel):
"""Ticket data for admin."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: datetime | None = None
messages_count: int = 0
user: AdminTicketUserInfo | None = None
last_message: TicketMessageResponse | None = None
class Config:
from_attributes = True
class AdminTicketDetailResponse(BaseModel):
"""Ticket with all messages for admin."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: datetime | None = None
is_reply_blocked: bool = False
user: AdminTicketUserInfo | None = None
messages: list[TicketMessageResponse] = []
class Config:
from_attributes = True
class AdminTicketListResponse(BaseModel):
"""Paginated ticket list for admin."""
items: list[AdminTicketResponse]
total: int
page: int
per_page: int
pages: int
class AdminReplyRequest(BaseModel):
"""Admin reply to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
class AdminStatusUpdateRequest(BaseModel):
"""Update ticket status."""
status: str = Field(..., description='New status: open, answered, pending, closed')
class AdminPriorityUpdateRequest(BaseModel):
"""Update ticket priority."""
priority: str = Field(..., description='New priority: low, normal, high, urgent')
class AdminStatsResponse(BaseModel):
"""Ticket statistics for admin."""
total: int
open: int
pending: int
answered: int
closed: int
class TicketSettingsResponse(BaseModel):
"""Ticket system settings."""
sla_enabled: bool
sla_minutes: int
sla_check_interval_seconds: int
sla_reminder_cooldown_minutes: int
support_system_mode: str # tickets, contact, both
# Cabinet notifications settings
cabinet_user_notifications_enabled: bool = True
cabinet_admin_notifications_enabled: bool = True
class TicketSettingsUpdateRequest(BaseModel):
"""Update ticket settings."""
sla_enabled: bool | None = None
sla_minutes: int | None = Field(None, ge=1, le=1440, description='SLA time in minutes (1-1440)')
sla_check_interval_seconds: int | None = Field(None, ge=30, le=600, description='Check interval (30-600 seconds)')
sla_reminder_cooldown_minutes: int | None = Field(
None, ge=1, le=120, description='Reminder cooldown (1-120 minutes)'
)
support_system_mode: str | None = Field(None, description='Support mode: tickets, contact, both')
# Cabinet notifications settings
cabinet_user_notifications_enabled: bool | None = Field(None, description='Enable user notifications in cabinet')
cabinet_admin_notifications_enabled: bool | None = Field(None, description='Enable admin notifications in cabinet')
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
"""Convert TicketMessage to response."""
return TicketMessageResponse(
id=message.id,
message_text=message.message_text or '',
is_from_admin=message.is_from_admin,
has_media=bool(message.media_file_id),
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
created_at=message.created_at,
)
def _user_to_info(user: User) -> AdminTicketUserInfo:
"""Convert User to admin info."""
return AdminTicketUserInfo(
id=user.id,
telegram_id=user.telegram_id,
email=user.email,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
)
def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) -> AdminTicketResponse:
"""Convert Ticket to admin response."""
last_message = None
messages_count = len(ticket.messages) if ticket.messages else 0
if ticket.messages:
last_msg = max(ticket.messages, key=lambda m: m.created_at)
last_message = _message_to_response(last_msg)
user_info = None
if hasattr(ticket, 'user') and ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketResponse(
id=ticket.id,
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
messages_count=messages_count,
user=user_info,
last_message=last_message,
)
@router.get('/stats', response_model=AdminStatsResponse)
async def get_ticket_stats(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket statistics."""
# Total count
total_result = await db.execute(select(func.count()).select_from(Ticket))
total = total_result.scalar() or 0
# Count by status
statuses = {}
for status_name in ['open', 'pending', 'answered', 'closed']:
result = await db.execute(select(func.count()).select_from(Ticket).where(Ticket.status == status_name))
statuses[status_name] = result.scalar() or 0
return AdminStatsResponse(
total=total,
open=statuses.get('open', 0),
pending=statuses.get('pending', 0),
answered=statuses.get('answered', 0),
closed=statuses.get('closed', 0),
)
@router.get('/settings', response_model=TicketSettingsResponse)
async def get_ticket_settings(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket system settings."""
from app.services.support_settings_service import SupportSettingsService
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
@router.patch('/settings', response_model=TicketSettingsResponse)
async def update_ticket_settings(
request: TicketSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket system settings."""
from pathlib import Path
from app.services.support_settings_service import SupportSettingsService
# Validate support_system_mode
if request.support_system_mode is not None:
mode = request.support_system_mode.strip().lower()
if mode not in {'tickets', 'contact', 'both'}:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid support_system_mode. Must be: tickets, contact, or both',
)
# Update in-memory settings
if request.sla_enabled is not None:
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
if request.sla_minutes is not None:
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
if request.sla_check_interval_seconds is not None:
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
if request.sla_reminder_cooldown_minutes is not None:
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
if request.support_system_mode is not None:
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
# Update cabinet notification settings
if request.cabinet_user_notifications_enabled is not None:
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
if request.cabinet_admin_notifications_enabled is not None:
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
# Try to persist to .env file
try:
env_file = Path('.env')
if env_file.exists():
lines = env_file.read_text().splitlines()
updates = {}
if request.sla_enabled is not None:
updates['SUPPORT_TICKET_SLA_ENABLED'] = str(request.sla_enabled).lower()
if request.sla_minutes is not None:
updates['SUPPORT_TICKET_SLA_MINUTES'] = str(request.sla_minutes)
if request.sla_check_interval_seconds is not None:
updates['SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS'] = str(request.sla_check_interval_seconds)
if request.sla_reminder_cooldown_minutes is not None:
updates['SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES'] = str(request.sla_reminder_cooldown_minutes)
if request.support_system_mode is not None:
updates['SUPPORT_SYSTEM_MODE'] = request.support_system_mode.strip().lower()
new_lines = []
updated_keys = set()
for line in lines:
updated = False
for key, value in updates.items():
if line.startswith(f'{key}='):
new_lines.append(f'{key}={value}')
updated_keys.add(key)
updated = True
break
if not updated:
new_lines.append(line)
# Add any keys that weren't found
for key, value in updates.items():
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n')
logger.info('Updated ticket settings in .env file')
except Exception as e:
logger.warning(f'Failed to update .env file: {e}')
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
@router.get('', response_model=AdminTicketListResponse)
async def get_all_tickets(
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
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),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all tickets for admin."""
# Base query with user relationship
query = select(Ticket).options(selectinload(Ticket.messages), selectinload(Ticket.user))
# Build count query
count_query = select(func.count()).select_from(Ticket)
# Apply filters
if status_filter:
query = query.where(Ticket.status == status_filter)
count_query = count_query.where(Ticket.status == status_filter)
if priority_filter:
query = query.where(Ticket.priority == priority_filter)
count_query = count_query.where(Ticket.priority == priority_filter)
if user_id:
query = query.where(Ticket.user_id == user_id)
count_query = count_query.where(Ticket.user_id == user_id)
# Get total count
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate - order by updated_at desc (newest first)
offset = (page - 1) * per_page
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(per_page)
result = await db.execute(query)
tickets = result.scalars().all()
items = [_ticket_to_admin_response(t) for t in tickets]
pages = math.ceil(total / per_page) if total > 0 else 1
return AdminTicketListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get('/{ticket_id}', response_model=AdminTicketDetailResponse)
async def get_ticket_detail(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket with all messages for admin."""
query = (
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Ticket not found',
)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
user_info = None
if ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
user=user_info,
messages=messages_response,
)
@router.post('/{ticket_id}/reply', response_model=TicketMessageResponse)
async def reply_to_ticket(
ticket_id: int,
request: AdminReplyRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reply to a ticket as admin."""
# Get ticket
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Ticket not found',
)
# Create admin message
message = TicketMessage(
ticket_id=ticket.id,
user_id=ticket.user_id,
message_text=request.message,
is_from_admin=True,
created_at=datetime.utcnow(),
)
db.add(message)
# Update ticket status to answered
ticket.status = 'answered'
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
# Try to notify user via Telegram
try:
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
from app.handlers.admin.tickets import notify_user_about_ticket_reply
await notify_user_about_ticket_reply(bot, ticket, request.message, db)
except Exception as e:
logger.warning(f'Failed to notify user about ticket reply: {e}')
finally:
await bot.session.close()
except Exception as e:
logger.warning(f'Failed to send Telegram notification: {e}')
# Уведомить пользователя в кабинете
try:
notification = await TicketNotificationCRUD.create_user_notification_for_admin_reply(
db, ticket, request.message
)
if notification:
# Отправить WebSocket уведомление
await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or '')[:100])
except Exception as e:
logger.warning(f'Failed to create cabinet notification for admin reply: {e}')
return _message_to_response(message)
@router.post('/{ticket_id}/status', response_model=AdminTicketDetailResponse)
async def update_ticket_status(
ticket_id: int,
request: AdminStatusUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket status."""
allowed_statuses = {'open', 'pending', 'answered', 'closed'}
if request.status not in allowed_statuses:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid status. Allowed: {", ".join(allowed_statuses)}',
)
query = (
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Ticket not found',
)
ticket.status = request.status
ticket.updated_at = datetime.utcnow()
if request.status == 'closed':
ticket.closed_at = datetime.utcnow()
else:
ticket.closed_at = None
await db.commit()
await db.refresh(ticket)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
user_info = None
if ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
user=user_info,
messages=messages_response,
)
@router.post('/{ticket_id}/priority', response_model=AdminTicketDetailResponse)
async def update_ticket_priority(
ticket_id: int,
request: AdminPriorityUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket priority."""
allowed_priorities = {'low', 'normal', 'high', 'urgent'}
if request.priority not in allowed_priorities:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid priority. Allowed: {", ".join(allowed_priorities)}',
)
query = (
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Ticket not found',
)
ticket.priority = request.priority
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(ticket)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
user_info = None
if ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
user=user_info,
messages=messages_response,
)
+694
View File
@@ -0,0 +1,694 @@
"""Admin routes for traffic usage statistics."""
import asyncio
import csv
import io
import logging
import time
from datetime import UTC, datetime, timedelta
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.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 ..schemas.traffic import (
ExportCsvRequest,
ExportCsvResponse,
TrafficEnrichmentResponse,
TrafficNodeInfo,
TrafficUsageResponse,
UserTrafficEnrichment,
UserTrafficItem,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/traffic', tags=['Admin Traffic'])
_ALLOWED_PERIODS = frozenset({1, 3, 7, 14, 30})
_CONCURRENCY_LIMIT = 5 # Max parallel API calls to avoid rate limiting
# In-memory cache: {(start_str, end_str): (timestamp, aggregated_data, nodes_info)}
_traffic_cache: dict[tuple[str, str], tuple[float, dict[str, dict[str, int]], list[TrafficNodeInfo]]] = {}
_CACHE_TTL = 300 # 5 minutes
_cache_lock = asyncio.Lock()
# Valid sort fields for the GET endpoint
_SORT_FIELDS = frozenset({'total_bytes', 'full_name', 'tariff_name', 'device_limit', 'traffic_limit_gb'})
_ENRICHMENT_SORT_FIELDS = frozenset({'connected', 'total_spent', 'sub_start', 'sub_end', 'last_node'})
def _get_status(sub) -> str | None:
"""Get subscription status via actual_status property."""
return sub.actual_status
def _validate_period(period: int) -> None:
if period not in _ALLOWED_PERIODS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Period must be one of: {sorted(_ALLOWED_PERIODS)}',
)
async def _aggregate_traffic(
start_str: str, end_str: str, user_uuids: list[str]
) -> tuple[dict[str, dict[str, int]], list[TrafficNodeInfo]]:
"""Aggregate per-user traffic across all nodes for a given date range.
Uses legacy per-node endpoint to fetch all users' traffic per node —
O(nodes) API calls instead of O(users). The legacy endpoint returns
{userUuid, nodeUuid, total} per entry (non-legacy only returns topUsers
without userUuid).
Returns (user_traffic, nodes_info) where:
user_traffic = {remnawave_uuid: {node_uuid: total_bytes, ...}}
nodes_info = [TrafficNodeInfo, ...]
"""
cache_key = (start_str, end_str)
# Quick check without lock
now = time.time()
cached = _traffic_cache.get(cache_key)
if cached and (now - cached[0]) < _CACHE_TTL:
return cached[1], cached[2]
# Acquire lock for the slow path
async with _cache_lock:
# Re-check after acquiring lock
now = time.time()
cached = _traffic_cache.get(cache_key)
if cached and (now - cached[0]) < _CACHE_TTL:
return cached[1], cached[2]
service = RemnaWaveService()
if not service.is_configured:
return {}, []
user_uuids_set = set(user_uuids)
async with service.get_api_client() as api:
nodes = await api.get_all_nodes()
# Fetch per-node user stats — O(nodes) calls instead of O(users)
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
async def fetch_node_users(node):
async with semaphore:
try:
stats = await api.get_bandwidth_stats_node_users_legacy(node.uuid, start_str, end_str)
return node.uuid, stats
except Exception:
logger.warning('Failed to get traffic for node %s', node.name, exc_info=True)
return node.uuid, None
results = await asyncio.gather(*(fetch_node_users(n) for n in nodes))
nodes_info: list[TrafficNodeInfo] = [
TrafficNodeInfo(node_uuid=node.uuid, node_name=node.name, country_code=node.country_code) for node in nodes
]
nodes_info.sort(key=lambda n: n.node_name)
# Legacy response: [{userUuid, username, nodeUuid, total, date}, ...]
user_traffic: dict[str, dict[str, int]] = {}
for node_uuid, entries in results:
if not isinstance(entries, list):
continue
for entry in entries:
uid = entry.get('userUuid', '')
total = int(entry.get('total', 0))
if uid and total > 0 and uid in user_uuids_set:
user_traffic.setdefault(uid, {})[node_uuid] = user_traffic.get(uid, {}).get(node_uuid, 0) + total
_traffic_cache[cache_key] = (now, user_traffic, nodes_info)
# Evict expired entries to prevent unbounded growth
expired = [k for k, (ts, _, _) in _traffic_cache.items() if (now - ts) >= _CACHE_TTL]
for k in expired:
del _traffic_cache[k]
return user_traffic, nodes_info
def _compute_date_range(period_days: int) -> tuple[str, str]:
"""Compute ISO date-time range from period days.
Truncates to 5-minute intervals for stable cache keys.
"""
end_dt = datetime.now(UTC).replace(second=0, microsecond=0)
end_dt = end_dt.replace(minute=(end_dt.minute // 5) * 5)
start_dt = end_dt - timedelta(days=period_days)
return start_dt.strftime('%Y-%m-%dT%H:%M:%SZ'), end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
async def _load_user_map(db: AsyncSession) -> dict[str, User]:
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff."""
stmt = (
select(User)
.where(User.remnawave_uuid.isnot(None))
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
)
result = await db.execute(stmt)
users = result.scalars().all()
return {u.remnawave_uuid: u for u in users if u.remnawave_uuid}
def _build_traffic_items(
user_traffic: dict[str, dict[str, int]],
user_map: dict[str, User],
nodes_info: list[TrafficNodeInfo],
search: str = '',
sort_by: str = 'total_bytes',
sort_desc: bool = True,
tariff_filter: set[str] | None = None,
status_filter: set[str] | None = None,
node_filter: set[str] | None = None,
) -> list[UserTrafficItem]:
"""Merge traffic data with user data, apply search/tariff/status/node filters, return sorted list."""
items: list[UserTrafficItem] = []
search_lower = search.lower().strip()
all_uuids = set(user_traffic.keys()) | set(user_map.keys())
for uuid in all_uuids:
user = user_map.get(uuid)
if not user:
continue
traffic = user_traffic.get(uuid, {})
full_name = user.full_name
username = user.username
email = user.email
if search_lower:
if (
search_lower not in (full_name or '').lower()
and search_lower not in (username or '').lower()
and search_lower not in (email or '').lower()
):
continue
sub = user.subscription
tariff_name = None
subscription_status = None
traffic_limit_gb = 0.0
device_limit = 1
if sub:
subscription_status = _get_status(sub)
traffic_limit_gb = float(sub.traffic_limit_gb or 0)
device_limit = sub.device_limit or 1
if sub.tariff:
tariff_name = sub.tariff.name
if tariff_filter is not None:
if (tariff_name or '') not in tariff_filter:
continue
if status_filter is not None:
if (subscription_status or '') not in status_filter:
continue
# Apply node filter: keep only selected nodes, recalculate total
if node_filter is not None:
traffic = {k: v for k, v in traffic.items() if k in node_filter}
total_bytes = sum(traffic.values())
items.append(
UserTrafficItem(
user_id=user.id,
telegram_id=user.telegram_id,
username=username,
email=email,
full_name=full_name,
tariff_name=tariff_name,
subscription_status=subscription_status,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
node_traffic=traffic,
total_bytes=total_bytes,
)
)
# Sort by the requested field; node columns use 'node_<uuid>' prefix
if sort_by.startswith('node_'):
node_uuid = sort_by[5:]
items.sort(key=lambda x: x.node_traffic.get(node_uuid, 0), reverse=sort_desc)
elif sort_by in ('full_name', 'tariff_name'):
items.sort(key=lambda x: (getattr(x, sort_by, None) or '').lower(), reverse=sort_desc)
else:
items.sort(key=lambda x: getattr(x, sort_by, 0) or 0, reverse=sort_desc)
return items
@router.get('', response_model=TrafficUsageResponse)
async def get_traffic_usage(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
period: int = Query(30, ge=1, le=30),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
search: str = Query('', max_length=100),
sort_by: str = Query('total_bytes', max_length=100),
sort_desc: bool = Query(True),
tariffs: str = Query('', max_length=500),
statuses: str = Query('', max_length=500),
nodes: str = Query('', max_length=2000),
start_date: str = Query('', max_length=10),
end_date: str = Query('', max_length=10),
):
"""Get paginated per-user traffic usage by node."""
# Determine date range: custom dates or period-based
if start_date.strip() and end_date.strip():
try:
start_dt = datetime.strptime(start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
end_dt = datetime.strptime(end_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC, hour=23, minute=59, second=59)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
now = datetime.now(UTC)
end_dt = min(end_dt, now)
if start_dt > end_dt:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
if (end_dt - start_dt).days > 31:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
effective_period = (end_dt - start_dt).days or 1
else:
_validate_period(period)
start_str, end_str = _compute_date_range(period)
effective_period = period
user_map = await _load_user_map(db)
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
# Collect all available tariff names (before filtering)
available_tariffs = sorted(
{
u.subscription.tariff.name
for u in user_map.values()
if u.subscription and u.subscription.tariff and u.subscription.tariff.name
}
)
# Collect all available statuses (before filtering)
available_statuses = sorted(
{_get_status(sub) for u in user_map.values() if (sub := u.subscription) and _get_status(sub)}
)
# Parse tariff filter
tariff_filter: set[str] | None = None
if tariffs.strip():
tariff_filter = {t.strip() for t in tariffs.split(',') if t.strip()}
# Parse status filter
status_filter: set[str] | None = None
if statuses.strip():
status_filter = {s.strip() for s in statuses.split(',') if s.strip()}
# Parse node filter
node_filter: set[str] | None = None
all_node_uuids = {n.node_uuid for n in nodes_info}
if nodes.strip():
node_filter = {n.strip() for n in nodes.split(',') if n.strip()} & all_node_uuids
if not node_filter:
node_filter = None # No valid nodes matched, treat as "all nodes"
# Validate sort_by: allow known fields + enrichment fields + 'node_<uuid>'
is_node_sort = sort_by.startswith('node_') and sort_by[5:] in all_node_uuids
is_enrichment_sort = sort_by in _ENRICHMENT_SORT_FIELDS
if sort_by not in _SORT_FIELDS and not is_node_sort and not is_enrichment_sort:
sort_by = 'total_bytes'
# For enrichment sort, build items unsorted then sort by enrichment field
effective_sort = 'total_bytes' if is_enrichment_sort else sort_by
items = _build_traffic_items(
user_traffic, user_map, nodes_info, search, effective_sort, sort_desc, tariff_filter, status_filter, node_filter
)
if is_enrichment_sort:
enrichment_data = await _build_enrichment(db, user_map)
enr_key_map = {
'connected': lambda e: e.devices_connected,
'total_spent': lambda e: e.total_spent_kopeks,
'sub_start': lambda e: e.subscription_start_date or '',
'sub_end': lambda e: e.subscription_end_date or '',
'last_node': lambda e: e.last_node_name or '',
}
key_fn = enr_key_map[sort_by]
empty = UserTrafficEnrichment()
items.sort(key=lambda x: key_fn(enrichment_data.get(x.user_id, empty)), reverse=sort_desc)
total = len(items)
paginated = items[offset : offset + limit]
return TrafficUsageResponse(
items=paginated,
nodes=nodes_info,
total=total,
offset=offset,
limit=limit,
period_days=effective_period,
available_tariffs=available_tariffs,
available_statuses=available_statuses,
)
# ============== Enrichment endpoint ==============
_enrichment_cache: dict[str, tuple[float, dict[int, UserTrafficEnrichment]]] = {}
_ENRICHMENT_CACHE_TTL = 300 # 5 minutes
_enrichment_lock = asyncio.Lock()
async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int, int]:
"""Get total spent kopeks for multiple users in a single query."""
if not user_ids:
return {}
result = await db.execute(
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.user_id.in_(user_ids),
Transaction.is_completed.is_(True),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
)
)
.group_by(Transaction.user_id)
)
return {row[0]: int(row[1]) for row in result.all()}
async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict[int, UserTrafficEnrichment]:
"""Build enrichment data for all users: devices, spending, dates, last node."""
uuid_to_user_id: dict[str, int] = {}
for uuid, user in user_map.items():
uuid_to_user_id[uuid] = user.id
service = RemnaWaveService()
devices_by_user: dict[int, int] = {}
last_node_uuid_by_user: dict[int, str] = {}
node_uuid_to_name: dict[str, str] = {}
if service.is_configured:
async with service.get_api_client() as api:
# 3 bulk calls: nodes + users (paginated) + devices
try:
nodes_list = await api.get_all_nodes()
except Exception:
logger.warning('Failed to fetch nodes for enrichment', exc_info=True)
nodes_list = []
for node in nodes_list:
node_uuid_to_name[node.uuid] = node.name
# Fetch all panel users (paginated) for last connected node
panel_users = []
try:
first_page = await api.get_all_users(start=0, size=500)
panel_users.extend(first_page['users'])
total_panel = first_page['total']
if total_panel > 500:
remaining_tasks = [
api.get_all_users(start=offset, size=500) for offset in range(500, total_panel, 500)
]
pages = await asyncio.gather(*remaining_tasks, return_exceptions=True)
for page in pages:
if isinstance(page, dict):
panel_users.extend(page['users'])
except Exception:
logger.warning('Failed to fetch panel users for enrichment', exc_info=True)
for pu in panel_users:
uid = uuid_to_user_id.get(pu.uuid)
if uid is None:
continue
if pu.user_traffic and pu.user_traffic.last_connected_node_uuid:
last_node_uuid_by_user[uid] = pu.user_traffic.last_connected_node_uuid
# Bulk device fetch — single API call (paginated with start/size)
try:
devices_data = await api.get_all_hwid_devices()
for device in devices_data.get('devices', []):
user_uuid = device.get('userUuid', '')
uid = uuid_to_user_id.get(user_uuid)
if uid is not None:
devices_by_user[uid] = devices_by_user.get(uid, 0) + 1
except Exception:
logger.warning('Failed to fetch bulk devices for enrichment', exc_info=True)
# Bulk spending stats
all_user_ids = [u.id for u in user_map.values()]
spending_map = await _get_bulk_spending(db, all_user_ids)
# Build enrichment data
enrichment: dict[int, UserTrafficEnrichment] = {}
for uuid, user in user_map.items():
uid = user.id
sub = user.subscription
start_date = None
end_date = None
if sub:
if sub.start_date:
start_date = sub.start_date.isoformat()
if sub.end_date:
end_date = sub.end_date.isoformat()
last_node_name = None
last_uuid = last_node_uuid_by_user.get(uid)
if last_uuid:
last_node_name = node_uuid_to_name.get(last_uuid)
enrichment[uid] = UserTrafficEnrichment(
devices_connected=devices_by_user.get(uid, 0),
total_spent_kopeks=spending_map.get(uid, 0),
subscription_start_date=start_date,
subscription_end_date=end_date,
last_node_name=last_node_name,
)
return enrichment
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
async def get_traffic_enrichment(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return enrichment data: device counts, spending, dates, last node."""
cache_key = 'enrichment'
now = time.time()
cached = _enrichment_cache.get(cache_key)
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
return TrafficEnrichmentResponse(data=cached[1])
async with _enrichment_lock:
now = time.time()
cached = _enrichment_cache.get(cache_key)
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
return TrafficEnrichmentResponse(data=cached[1])
user_map = await _load_user_map(db)
enrichment = await _build_enrichment(db, user_map)
_enrichment_cache[cache_key] = (now, enrichment)
# Evict expired
expired = [k for k, (ts, _) in _enrichment_cache.items() if (now - ts) >= _ENRICHMENT_CACHE_TTL]
for k in expired:
del _enrichment_cache[k]
return TrafficEnrichmentResponse(data=enrichment)
@router.post('/export-csv', response_model=ExportCsvResponse)
async def export_traffic_csv(
request: ExportCsvRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Generate CSV with traffic usage and send to admin's Telegram DM."""
if not admin.telegram_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Admin has no Telegram ID configured',
)
# Determine date range: custom dates or period-based
if request.start_date and request.end_date:
try:
start_dt = datetime.strptime(request.start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
end_dt = datetime.strptime(request.end_date.strip(), '%Y-%m-%d').replace(
tzinfo=UTC, hour=23, minute=59, second=59
)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
now = datetime.now(UTC)
end_dt = min(end_dt, now)
if start_dt > end_dt:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
if (end_dt - start_dt).days > 31:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
period_label = f'{request.start_date}_{request.end_date}'
else:
_validate_period(request.period)
start_str, end_str = _compute_date_range(request.period)
period_label = f'{request.period}d'
user_map = await _load_user_map(db)
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
enrichment = await _build_enrichment(db, user_map)
# Parse filters
tariff_filter: set[str] | None = None
if request.tariffs and request.tariffs.strip():
tariff_filter = {t.strip() for t in request.tariffs.split(',') if t.strip()}
status_filter: set[str] | None = None
if request.statuses and request.statuses.strip():
status_filter = {s.strip() for s in request.statuses.split(',') if s.strip()}
node_filter: set[str] | None = None
all_node_uuids = {n.node_uuid for n in nodes_info}
if request.nodes and request.nodes.strip():
node_filter = {n.strip() for n in request.nodes.split(',') if n.strip()} & all_node_uuids
if not node_filter:
node_filter = None
items = _build_traffic_items(
user_traffic,
user_map,
nodes_info,
sort_by='total_bytes',
sort_desc=True,
tariff_filter=tariff_filter,
status_filter=status_filter,
node_filter=node_filter,
)
# Determine which nodes to include in CSV columns
csv_nodes = [n for n in nodes_info if n.node_uuid in node_filter] if node_filter else nodes_info
# Compute period days for risk calculation
if request.start_date and request.end_date:
period_days = max((end_dt - start_dt).days, 1)
else:
period_days = request.period
total_thr = request.total_threshold_gb or 0
node_thr = request.node_threshold_gb or 0
has_risk = total_thr > 0 or node_thr > 0
# Build CSV rows
rows: list[dict] = []
for item in items:
row: dict = {
'User ID': item.user_id,
'Telegram ID': item.telegram_id or '',
'Username': item.username or '',
'Email': item.email or '',
'Full Name': item.full_name,
'Tariff': item.tariff_name or '',
'Status': item.subscription_status or '',
'Traffic Limit (GB)': item.traffic_limit_gb,
'Device Limit': item.device_limit,
}
# Enrichment columns
enr = enrichment.get(item.user_id)
row['Connected Devices'] = enr.devices_connected if enr else 0
row['Total Spent (RUB)'] = round(enr.total_spent_kopeks / 100, 2) if enr else 0
row['Sub Start'] = enr.subscription_start_date or '' if enr else ''
row['Sub End'] = enr.subscription_end_date or '' if enr else ''
row['Last Node'] = enr.last_node_name or '' if enr else ''
for node in csv_nodes:
row[f'{node.node_name} (bytes)'] = item.node_traffic.get(node.node_uuid, 0)
row['Total (bytes)'] = item.total_bytes
row['Total (GB)'] = round(item.total_bytes / (1024**3), 2) if item.total_bytes else 0
if has_risk:
daily_total = item.total_bytes / period_days / (1024**3) if period_days > 0 else 0
row['Total GB/day'] = round(daily_total, 4)
total_ratio = daily_total / total_thr if total_thr > 0 else 0
max_node_ratio = 0.0
worst_node_daily = 0.0
for node_bytes in item.node_traffic.values():
if node_bytes > 0 and node_thr > 0:
daily_node = node_bytes / period_days / (1024**3) if period_days > 0 else 0
ratio = daily_node / node_thr
if ratio > max_node_ratio:
max_node_ratio = ratio
worst_node_daily = daily_node
ratio = max(total_ratio, max_node_ratio)
if ratio < 0.5:
risk_level = 'low'
elif ratio < 0.8:
risk_level = 'medium'
elif ratio < 1.2:
risk_level = 'high'
else:
risk_level = 'critical'
row['Risk Level'] = risk_level
row['Risk Ratio'] = round(ratio, 3)
row['Risk GB/day'] = round(daily_total if total_ratio >= max_node_ratio else worst_node_daily, 4)
rows.append(row)
# Generate CSV
output = io.StringIO()
if rows:
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
csv_bytes = output.getvalue().encode('utf-8-sig')
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
try:
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
async with bot:
await bot.send_document(
chat_id=admin.telegram_id,
document=BufferedInputFile(csv_bytes, filename=filename),
caption=f'Traffic usage report ({period_label})\nUsers: {len(rows)}',
)
except Exception:
logger.error('Failed to send CSV to admin %s', admin.telegram_id, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to send CSV report. Please try again later.',
)
return ExportCsvResponse(success=True, message=f'CSV sent ({len(rows)} users)')
+139
View File
@@ -0,0 +1,139 @@
"""Admin routes for version and release information."""
import logging
from datetime import datetime, timedelta
import aiohttp
from fastapi import APIRouter, Depends
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/updates', tags=['Cabinet Admin Updates'])
# ============ Schemas ============
class ReleaseItem(BaseModel):
tag_name: str
name: str
body: str
published_at: str
prerelease: bool
class ProjectReleasesInfo(BaseModel):
current_version: str
has_updates: bool
releases: list[ReleaseItem]
repo_url: str
class ReleasesResponse(BaseModel):
bot: ProjectReleasesInfo
cabinet: ProjectReleasesInfo
# ============ Cabinet releases cache ============
CABINET_REPO = 'BEDOLAGA-DEV/bedolaga-cabinet'
_cabinet_cache: dict = {}
_cabinet_last_check: datetime | None = None
_CACHE_TTL = 3600
async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
global _cabinet_last_check
if not force and _cabinet_cache.get('releases') and _cabinet_last_check:
if datetime.now() - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
return _cabinet_cache['releases']
url = f'https://api.github.com/repos/{CABINET_REPO}/releases'
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response:
if response.status == 200:
data = await response.json()
releases = []
for item in data[:20]:
releases.append(
{
'tag_name': item['tag_name'],
'name': item.get('name') or item['tag_name'],
'body': item.get('body') or '',
'published_at': item['published_at'],
'prerelease': item.get('prerelease', False),
}
)
_cabinet_cache['releases'] = releases
_cabinet_last_check = datetime.now()
logger.info('Fetched %d cabinet releases from GitHub', len(releases))
return releases
logger.warning('GitHub API returned status %d for cabinet releases', response.status)
return _cabinet_cache.get('releases', [])
except TimeoutError:
logger.warning('Timeout fetching cabinet releases from GitHub')
return _cabinet_cache.get('releases', [])
except Exception as e:
logger.error('Error fetching cabinet releases: %s', e)
return _cabinet_cache.get('releases', [])
# ============ Routes ============
@router.get('/releases', response_model=ReleasesResponse)
async def get_releases(
current_user: User = Depends(get_current_admin_user),
) -> ReleasesResponse:
"""Get release information for bot and cabinet."""
# Bot releases
bot_releases_raw = await version_service._fetch_releases()
has_updates, _ = await version_service.check_for_updates()
bot_releases = [
ReleaseItem(
tag_name=r.tag_name,
name=r.name,
body=r.full_description,
published_at=r.published_at.isoformat(),
prerelease=r.prerelease,
)
for r in bot_releases_raw[:10]
]
bot_info = ProjectReleasesInfo(
current_version=version_service.current_version,
has_updates=has_updates,
releases=bot_releases,
repo_url=f'https://github.com/{version_service.repo}',
)
# Cabinet releases
cabinet_releases_raw = await _fetch_cabinet_releases()
cabinet_releases = [ReleaseItem(**r) for r in cabinet_releases_raw[:10]]
# Current version = latest non-prerelease tag
cabinet_current = ''
for r in cabinet_releases_raw:
if not r.get('prerelease', False):
cabinet_current = r['tag_name']
break
cabinet_info = ProjectReleasesInfo(
current_version=cabinet_current,
has_updates=False,
releases=cabinet_releases,
repo_url=f'https://github.com/{CABINET_REPO}',
)
return ReleasesResponse(bot=bot_info, cabinet=cabinet_info)
File diff suppressed because it is too large Load Diff
+387
View File
@@ -0,0 +1,387 @@
"""
API роуты колеса удачи для администраторов.
"""
import logging
import math
from datetime import datetime
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.schemas.wheel import (
AdminSpinItem,
AdminSpinsResponse,
AdminWheelConfigResponse,
CreatePrizeRequest,
ReorderPrizesRequest,
UpdatePrizeRequest,
UpdateWheelConfigRequest,
WheelPrizeAdminResponse,
WheelStatisticsResponse,
)
from app.database.crud.wheel import (
create_wheel_prize,
delete_wheel_prize,
get_all_spins,
get_or_create_wheel_config,
get_wheel_prizes,
reorder_wheel_prizes,
update_wheel_config,
update_wheel_prize,
)
from app.database.models import User
from app.services.wheel_service import wheel_service
logger = logging.getLogger(__name__)
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),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить полную конфигурацию колеса."""
config = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=False)
prizes_response = [
WheelPrizeAdminResponse(
id=p.id,
config_id=p.config_id,
prize_type=p.prize_type,
prize_value=p.prize_value,
display_name=p.display_name,
emoji=p.emoji,
color=p.color,
prize_value_kopeks=p.prize_value_kopeks,
sort_order=p.sort_order,
manual_probability=p.manual_probability,
is_active=p.is_active,
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
promo_subscription_days=p.promo_subscription_days or 0,
promo_traffic_gb=p.promo_traffic_gb or 0,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in prizes
]
return AdminWheelConfigResponse(
id=config.id,
is_enabled=config.is_enabled,
name=config.name,
spin_cost_stars=config.spin_cost_stars,
spin_cost_days=config.spin_cost_days,
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
spin_cost_days_enabled=config.spin_cost_days_enabled,
rtp_percent=config.rtp_percent,
daily_spin_limit=config.daily_spin_limit,
min_subscription_days_for_day_payment=config.min_subscription_days_for_day_payment,
promo_prefix=config.promo_prefix,
promo_validity_days=config.promo_validity_days,
prizes=prizes_response,
created_at=config.created_at,
updated_at=config.updated_at,
)
@router.put('/config', response_model=AdminWheelConfigResponse)
async def update_admin_wheel_config(
request: UpdateWheelConfigRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Обновить конфигурацию колеса."""
update_data = request.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No fields to update',
)
config = await update_wheel_config(db, **update_data)
logger.info(f'🎡 Admin {admin.telegram_id} updated wheel config: {update_data}')
# Возвращаем полную конфигурацию
prizes = await get_wheel_prizes(db, config.id, active_only=False)
prizes_response = [
WheelPrizeAdminResponse(
id=p.id,
config_id=p.config_id,
prize_type=p.prize_type,
prize_value=p.prize_value,
display_name=p.display_name,
emoji=p.emoji,
color=p.color,
prize_value_kopeks=p.prize_value_kopeks,
sort_order=p.sort_order,
manual_probability=p.manual_probability,
is_active=p.is_active,
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
promo_subscription_days=p.promo_subscription_days or 0,
promo_traffic_gb=p.promo_traffic_gb or 0,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in prizes
]
return AdminWheelConfigResponse(
id=config.id,
is_enabled=config.is_enabled,
name=config.name,
spin_cost_stars=config.spin_cost_stars,
spin_cost_days=config.spin_cost_days,
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
spin_cost_days_enabled=config.spin_cost_days_enabled,
rtp_percent=config.rtp_percent,
daily_spin_limit=config.daily_spin_limit,
min_subscription_days_for_day_payment=config.min_subscription_days_for_day_payment,
promo_prefix=config.promo_prefix,
promo_validity_days=config.promo_validity_days,
prizes=prizes_response,
created_at=config.created_at,
updated_at=config.updated_at,
)
@router.get('/prizes', response_model=list[WheelPrizeAdminResponse])
async def get_prizes(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить список призов."""
config = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=False)
return [
WheelPrizeAdminResponse(
id=p.id,
config_id=p.config_id,
prize_type=p.prize_type,
prize_value=p.prize_value,
display_name=p.display_name,
emoji=p.emoji,
color=p.color,
prize_value_kopeks=p.prize_value_kopeks,
sort_order=p.sort_order,
manual_probability=p.manual_probability,
is_active=p.is_active,
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
promo_subscription_days=p.promo_subscription_days or 0,
promo_traffic_gb=p.promo_traffic_gb or 0,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in 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),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Создать новый приз."""
config = await get_or_create_wheel_config(db)
prize = await create_wheel_prize(
db=db,
config_id=config.id,
prize_type=request.prize_type.value,
prize_value=request.prize_value,
display_name=request.display_name,
prize_value_kopeks=request.prize_value_kopeks,
emoji=request.emoji,
color=request.color,
sort_order=request.sort_order,
manual_probability=request.manual_probability,
is_active=request.is_active,
promo_balance_bonus_kopeks=request.promo_balance_bonus_kopeks,
promo_subscription_days=request.promo_subscription_days,
promo_traffic_gb=request.promo_traffic_gb,
)
logger.info(f'🎁 Admin {admin.telegram_id} created prize: {prize.display_name}')
return WheelPrizeAdminResponse(
id=prize.id,
config_id=prize.config_id,
prize_type=prize.prize_type,
prize_value=prize.prize_value,
display_name=prize.display_name,
emoji=prize.emoji,
color=prize.color,
prize_value_kopeks=prize.prize_value_kopeks,
sort_order=prize.sort_order,
manual_probability=prize.manual_probability,
is_active=prize.is_active,
promo_balance_bonus_kopeks=prize.promo_balance_bonus_kopeks or 0,
promo_subscription_days=prize.promo_subscription_days or 0,
promo_traffic_gb=prize.promo_traffic_gb or 0,
created_at=prize.created_at,
updated_at=prize.updated_at,
)
@router.put('/prizes/{prize_id}', response_model=WheelPrizeAdminResponse)
async def update_prize(
prize_id: int,
request: UpdatePrizeRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Обновить приз."""
update_data = request.model_dump(exclude_unset=True)
# Конвертируем enum в строку если есть
if update_data.get('prize_type'):
update_data['prize_type'] = update_data['prize_type'].value
if not update_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No fields to update',
)
prize = await update_wheel_prize(db, prize_id, **update_data)
if not prize:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Prize not found',
)
logger.info(f'🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}')
return WheelPrizeAdminResponse(
id=prize.id,
config_id=prize.config_id,
prize_type=prize.prize_type,
prize_value=prize.prize_value,
display_name=prize.display_name,
emoji=prize.emoji,
color=prize.color,
prize_value_kopeks=prize.prize_value_kopeks,
sort_order=prize.sort_order,
manual_probability=prize.manual_probability,
is_active=prize.is_active,
promo_balance_bonus_kopeks=prize.promo_balance_bonus_kopeks or 0,
promo_subscription_days=prize.promo_subscription_days or 0,
promo_traffic_gb=prize.promo_traffic_gb or 0,
created_at=prize.created_at,
updated_at=prize.updated_at,
)
@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),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Удалить приз."""
success = await delete_wheel_prize(db, prize_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Prize not found',
)
logger.info(f'🗑️ Admin {admin.telegram_id} deleted prize {prize_id}')
@router.post('/prizes/reorder', status_code=status.HTTP_200_OK)
async def reorder_prizes(
request: ReorderPrizesRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Переупорядочить призы."""
await reorder_wheel_prizes(db, request.prize_ids)
logger.info(f'🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}')
return {'success': True}
@router.get('/statistics', response_model=WheelStatisticsResponse)
async def get_statistics(
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить статистику колеса."""
stats = await wheel_service.get_statistics(db, date_from, date_to)
return WheelStatisticsResponse(
total_spins=stats['total_spins'],
total_revenue_kopeks=stats['total_revenue_kopeks'],
total_payout_kopeks=stats['total_payout_kopeks'],
actual_rtp_percent=stats['actual_rtp_percent'],
configured_rtp_percent=stats['configured_rtp_percent'],
spins_by_payment_type=stats['spins_by_payment_type'],
prizes_distribution=stats['prizes_distribution'],
top_wins=stats['top_wins'],
period_from=stats['period_from'],
period_to=stats['period_to'],
)
@router.get('/spins', response_model=AdminSpinsResponse)
async def get_all_spins_endpoint(
user_id: int | None = Query(None),
date_from: datetime | None = Query(None),
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),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить все спины с фильтрами."""
offset = (page - 1) * per_page
spins, total = await get_all_spins(
db,
user_id=user_id,
date_from=date_from,
date_to=date_to,
limit=per_page,
offset=offset,
)
items = [
AdminSpinItem(
id=spin.id,
user_id=spin.user_id,
username=spin.user.username if spin.user else None,
payment_type=spin.payment_type,
payment_amount=spin.payment_amount,
payment_value_kopeks=spin.payment_value_kopeks,
prize_type=spin.prize_type,
prize_value=spin.prize_value,
prize_display_name=spin.prize_display_name,
prize_value_kopeks=spin.prize_value_kopeks,
is_applied=spin.is_applied,
created_at=spin.created_at,
)
for spin in spins
]
pages = math.ceil(total / per_page) if total > 0 else 1
return AdminSpinsResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+769
View File
@@ -0,0 +1,769 @@
"""Branding routes for cabinet - logo, project name, and theme colors management."""
import json
import logging
import os
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/branding', tags=['Branding'])
# Directory for storing branding assets
BRANDING_DIR = Path('data/branding')
LOGO_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.svg']
# Settings keys
BRANDING_NAME_KEY = 'CABINET_BRANDING_NAME'
BRANDING_LOGO_KEY = 'CABINET_BRANDING_LOGO' # Stores "custom" or "default"
THEME_COLORS_KEY = 'CABINET_THEME_COLORS' # Stores JSON with theme colors
ENABLED_THEMES_KEY = 'CABINET_ENABLED_THEMES' # Stores JSON with enabled themes {"dark": true, "light": false}
ANIMATION_ENABLED_KEY = 'CABINET_ANIMATION_ENABLED' # Stores "true" or "false"
FULLSCREEN_ENABLED_KEY = 'CABINET_FULLSCREEN_ENABLED' # Stores "true" or "false"
EMAIL_AUTH_ENABLED_KEY = 'CABINET_EMAIL_AUTH_ENABLED' # Stores "true" or "false"
YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeric string)
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"
# Allowed image types
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB for larger logos
# ============ Schemas ============
class BrandingResponse(BaseModel):
"""Current branding settings."""
name: str
logo_url: str | None = None
logo_letter: str
has_custom_logo: bool
class BrandingNameUpdate(BaseModel):
"""Request to update branding name."""
name: str
class ThemeColorsResponse(BaseModel):
"""Theme colors settings."""
accent: str = '#3b82f6'
darkBackground: str = '#0a0f1a'
darkSurface: str = '#0f172a'
darkText: str = '#f1f5f9'
darkTextSecondary: str = '#94a3b8'
lightBackground: str = '#F7E7CE'
lightSurface: str = '#FEF9F0'
lightText: str = '#1F1A12'
lightTextSecondary: str = '#7D6B48'
success: str = '#22c55e'
warning: str = '#f59e0b'
error: str = '#ef4444'
class ThemeColorsUpdate(BaseModel):
"""Request to update theme colors (partial update allowed)."""
accent: str | None = None
darkBackground: str | None = None
darkSurface: str | None = None
darkText: str | None = None
darkTextSecondary: str | None = None
lightBackground: str | None = None
lightSurface: str | None = None
lightText: str | None = None
lightTextSecondary: str | None = None
success: str | None = None
warning: str | None = None
error: str | None = None
class EnabledThemesResponse(BaseModel):
"""Enabled themes settings."""
dark: bool = True
light: bool = True
class EnabledThemesUpdate(BaseModel):
"""Request to update enabled themes."""
dark: bool | None = None
light: bool | None = None
class AnimationEnabledResponse(BaseModel):
"""Animation enabled setting."""
enabled: bool = True
class AnimationEnabledUpdate(BaseModel):
"""Request to update animation setting."""
enabled: bool
class FullscreenEnabledResponse(BaseModel):
"""Fullscreen enabled setting."""
enabled: bool = False
class FullscreenEnabledUpdate(BaseModel):
"""Request to update fullscreen setting."""
enabled: bool
class EmailAuthEnabledResponse(BaseModel):
"""Email auth enabled setting."""
enabled: bool = True
class EmailAuthEnabledUpdate(BaseModel):
"""Request to update email auth setting."""
enabled: bool
class LiteModeEnabledResponse(BaseModel):
"""Lite mode enabled setting."""
enabled: bool = False
class LiteModeEnabledUpdate(BaseModel):
"""Request to update lite mode setting."""
enabled: bool
class AnalyticsCountersResponse(BaseModel):
"""Analytics counter settings."""
yandex_metrika_id: str = ''
google_ads_id: str = ''
google_ads_label: str = ''
class AnalyticsCountersUpdate(BaseModel):
"""Request to update analytics counters (partial update allowed)."""
yandex_metrika_id: str | None = None
google_ads_id: str | None = None
google_ads_label: str | None = None
# Default theme colors
DEFAULT_THEME_COLORS = {
'accent': '#3b82f6',
'darkBackground': '#0a0f1a',
'darkSurface': '#0f172a',
'darkText': '#f1f5f9',
'darkTextSecondary': '#94a3b8',
'lightBackground': '#F7E7CE',
'lightSurface': '#FEF9F0',
'lightText': '#1F1A12',
'lightTextSecondary': '#7D6B48',
'success': '#22c55e',
'warning': '#f59e0b',
'error': '#ef4444',
}
# ============ Helper Functions ============
def ensure_branding_dir():
"""Ensure branding directory exists."""
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
async def get_setting_value(db: AsyncSession, key: str) -> str | None:
"""Get a setting value from database."""
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def set_setting_value(db: AsyncSession, key: str, value: str):
"""Set a setting value in database."""
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = SystemSetting(key=key, value=value)
db.add(setting)
await db.commit()
def get_logo_path() -> Path | None:
"""Get the path to the custom logo file (any supported format)."""
if not BRANDING_DIR.exists():
return None
# Search for logo file with any supported extension
for ext in LOGO_EXTENSIONS:
logo_path = BRANDING_DIR / f'logo{ext}'
if logo_path.exists():
return logo_path
return None
def has_custom_logo() -> bool:
"""Check if a custom logo exists."""
return get_logo_path() is not None
# ============ Routes ============
@router.get('', response_model=BrandingResponse)
async def get_branding(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get current branding settings.
This is a public endpoint - no authentication required.
"""
# Get name from database or use default from env/settings
name = await get_setting_value(db, BRANDING_NAME_KEY)
if name is None: # Only use fallback if not set at all (empty string is valid)
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
# Check for custom logo
custom_logo = has_custom_logo()
# Get first letter for logo fallback (use "V" if name is empty)
logo_letter = name[0].upper() if name else 'V'
return BrandingResponse(
name=name,
logo_url='/cabinet/branding/logo' if custom_logo else None,
logo_letter=logo_letter,
has_custom_logo=custom_logo,
)
@router.get('/logo')
async def get_logo():
"""
Get the custom logo image.
Returns 404 if no custom logo is set.
"""
logo_path = get_logo_path()
if logo_path is None or not logo_path.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='No custom logo set')
# Determine media type from file extension
suffix = logo_path.suffix.lower()
media_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
}
media_type = media_types.get(suffix, 'image/png')
return FileResponse(logo_path, media_type=media_type, headers={'Cache-Control': 'public, max-age=3600'})
@router.put('/name', response_model=BrandingResponse)
async def update_branding_name(
payload: BrandingNameUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update the project name. Admin only. Empty name allowed (logo only mode)."""
name = payload.name.strip() if payload.name else ''
if len(name) > 50:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Name too long (max 50 characters)')
await set_setting_value(db, BRANDING_NAME_KEY, name)
logger.info(f'Admin {admin.telegram_id} updated branding name to: {name}')
# Return updated branding
custom_logo = has_custom_logo()
logo_letter = name[0].upper() if name else 'C'
return BrandingResponse(
name=name,
logo_url='/cabinet/branding/logo' if custom_logo else None,
logo_letter=logo_letter,
has_custom_logo=custom_logo,
)
@router.post('/logo', response_model=BrandingResponse)
async def upload_logo(
file: UploadFile = File(...),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Upload a custom logo. Admin only."""
# Validate content type
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid file type. Allowed: PNG, JPEG, WebP, SVG'
)
# Read file content
content = await file.read()
# Validate file size
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB',
)
# Ensure directory exists
ensure_branding_dir()
# Determine file extension from content type
ext_map = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/webp': '.webp',
'image/svg+xml': '.svg',
}
extension = ext_map.get(file.content_type, '.png')
# Remove old logo files with any extension
for old_file in BRANDING_DIR.glob('logo.*'):
old_file.unlink()
# Save new logo
logo_path = BRANDING_DIR / f'logo{extension}'
logo_path.write_bytes(content)
# Mark that we have a custom logo
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
logger.info(f'Admin {admin.telegram_id} uploaded new logo: {logo_path}')
# Get current name for response
name = await get_setting_value(db, BRANDING_NAME_KEY)
if name is None: # Only use fallback if not set at all (empty string is valid)
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
logo_letter = name[0].upper() if name else 'C'
return BrandingResponse(
name=name,
logo_url='/cabinet/branding/logo',
logo_letter=logo_letter,
has_custom_logo=True,
)
@router.delete('/logo', response_model=BrandingResponse)
async def delete_logo(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete custom logo and revert to letter. Admin only."""
# Remove logo files
for old_file in BRANDING_DIR.glob('logo.*'):
old_file.unlink()
# Update setting
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
logger.info(f'Admin {admin.telegram_id} deleted custom logo')
# Get current name for response
name = await get_setting_value(db, BRANDING_NAME_KEY)
if name is None: # Only use fallback if not set at all (empty string is valid)
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
logo_letter = name[0].upper() if name else 'C'
return BrandingResponse(
name=name,
logo_url=None,
logo_letter=logo_letter,
has_custom_logo=False,
)
# ============ Theme Colors Routes ============
def validate_hex_color(color: str) -> bool:
"""Validate hex color format."""
if not color or not isinstance(color, str):
return False
if not color.startswith('#'):
return False
hex_part = color[1:]
if len(hex_part) not in (3, 6):
return False
try:
int(hex_part, 16)
return True
except ValueError:
return False
@router.get('/colors', response_model=ThemeColorsResponse)
async def get_theme_colors(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get current theme colors.
This is a public endpoint - no authentication required.
"""
colors_json = await get_setting_value(db, THEME_COLORS_KEY)
if colors_json:
try:
colors = json.loads(colors_json)
# Merge with defaults to ensure all fields exist
merged = {**DEFAULT_THEME_COLORS, **colors}
return ThemeColorsResponse(**merged)
except (json.JSONDecodeError, TypeError):
pass
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
@router.patch('/colors', response_model=ThemeColorsResponse)
async def update_theme_colors(
payload: ThemeColorsUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update theme colors. Admin only. Partial update supported."""
# Get current colors
colors_json = await get_setting_value(db, THEME_COLORS_KEY)
current_colors = DEFAULT_THEME_COLORS.copy()
if colors_json:
try:
current_colors.update(json.loads(colors_json))
except (json.JSONDecodeError, TypeError):
pass
# Update with new values (only non-None fields)
update_data = payload.model_dump(exclude_none=True)
# Validate hex colors
for key, value in update_data.items():
if not validate_hex_color(value):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'Invalid hex color for {key}: {value}')
current_colors.update(update_data)
# Save to database
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(current_colors))
logger.info(f'Admin {admin.telegram_id} updated theme colors: {list(update_data.keys())}')
return ThemeColorsResponse(**current_colors)
@router.post('/colors/reset', response_model=ThemeColorsResponse)
async def reset_theme_colors(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset theme colors to defaults. Admin only."""
# Save default colors
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(DEFAULT_THEME_COLORS))
logger.info(f'Admin {admin.telegram_id} reset theme colors to defaults')
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
# ============ Enabled Themes Routes ============
DEFAULT_ENABLED_THEMES = {'dark': True, 'light': True}
@router.get('/themes', response_model=EnabledThemesResponse)
async def get_enabled_themes(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get which themes are enabled.
This is a public endpoint - no authentication required.
"""
themes_json = await get_setting_value(db, ENABLED_THEMES_KEY)
if themes_json:
try:
themes = json.loads(themes_json)
return EnabledThemesResponse(**themes)
except (json.JSONDecodeError, TypeError):
pass
return EnabledThemesResponse(**DEFAULT_ENABLED_THEMES)
@router.patch('/themes', response_model=EnabledThemesResponse)
async def update_enabled_themes(
payload: EnabledThemesUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update which themes are enabled. Admin only. At least one theme must be enabled."""
# Get current settings
themes_json = await get_setting_value(db, ENABLED_THEMES_KEY)
current_themes = DEFAULT_ENABLED_THEMES.copy()
if themes_json:
try:
current_themes.update(json.loads(themes_json))
except (json.JSONDecodeError, TypeError):
pass
# Update with new values
update_data = payload.model_dump(exclude_none=True)
current_themes.update(update_data)
# Ensure at least one theme is enabled
if not current_themes.get('dark') and not current_themes.get('light'):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='At least one theme must be enabled')
# Save to database
await set_setting_value(db, ENABLED_THEMES_KEY, json.dumps(current_themes))
logger.info(f'Admin {admin.telegram_id} updated enabled themes: {current_themes}')
return EnabledThemesResponse(**current_themes)
# ============ Animation Routes ============
@router.get('/animation', response_model=AnimationEnabledResponse)
async def get_animation_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get animation enabled setting.
This is a public endpoint - no authentication required.
"""
animation_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
if animation_value is not None:
enabled = animation_value.lower() == 'true'
return AnimationEnabledResponse(enabled=enabled)
# Default: enabled
return AnimationEnabledResponse(enabled=True)
@router.patch('/animation', response_model=AnimationEnabledResponse)
async def update_animation_enabled(
payload: AnimationEnabledUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update animation enabled setting. Admin only."""
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set animation enabled: {payload.enabled}')
return AnimationEnabledResponse(enabled=payload.enabled)
# ============ Fullscreen Routes ============
@router.get('/fullscreen', response_model=FullscreenEnabledResponse)
async def get_fullscreen_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get fullscreen enabled setting.
This is a public endpoint - no authentication required.
"""
fullscreen_value = await get_setting_value(db, FULLSCREEN_ENABLED_KEY)
if fullscreen_value is not None:
enabled = fullscreen_value.lower() == 'true'
return FullscreenEnabledResponse(enabled=enabled)
# Default: disabled
return FullscreenEnabledResponse(enabled=False)
@router.patch('/fullscreen', response_model=FullscreenEnabledResponse)
async def update_fullscreen_enabled(
payload: FullscreenEnabledUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update fullscreen enabled setting. Admin only."""
await set_setting_value(db, FULLSCREEN_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set fullscreen enabled: {payload.enabled}')
return FullscreenEnabledResponse(enabled=payload.enabled)
# ============ Email Auth Routes ============
@router.get('/email-auth', response_model=EmailAuthEnabledResponse)
async def get_email_auth_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get email auth enabled setting.
This is a public endpoint - no authentication required.
Controls whether email registration/login is available.
"""
email_auth_value = await get_setting_value(db, EMAIL_AUTH_ENABLED_KEY)
if email_auth_value is not None:
enabled = email_auth_value.lower() == 'true'
return EmailAuthEnabledResponse(enabled=enabled)
# Default: check config setting
return EmailAuthEnabledResponse(enabled=settings.is_cabinet_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),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update email auth enabled setting. Admin only."""
await set_setting_value(db, EMAIL_AUTH_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set email auth enabled: {payload.enabled}')
return EmailAuthEnabledResponse(enabled=payload.enabled)
# ============ Analytics Counters Routes ============
@router.get('/analytics', response_model=AnalyticsCountersResponse)
async def get_analytics_counters(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get analytics counter settings.
This is a public endpoint - no authentication required.
"""
yandex_id = await get_setting_value(db, YANDEX_METRIKA_ID_KEY) or ''
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
return AnalyticsCountersResponse(
yandex_metrika_id=yandex_id,
google_ads_id=google_id,
google_ads_label=google_label,
)
@router.patch('/analytics', response_model=AnalyticsCountersResponse)
async def update_analytics_counters(
payload: AnalyticsCountersUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update analytics counter settings. Admin only. Partial update supported."""
if payload.yandex_metrika_id is not None:
value = payload.yandex_metrika_id.strip()
if value and not value.isdigit():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Yandex Metrika counter ID must be numeric',
)
await set_setting_value(db, YANDEX_METRIKA_ID_KEY, value)
if payload.google_ads_id is not None:
value = payload.google_ads_id.strip()
if value and not value.startswith('AW-'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Google Ads conversion ID must start with AW-',
)
await set_setting_value(db, GOOGLE_ADS_ID_KEY, value)
if payload.google_ads_label is not None:
await set_setting_value(db, GOOGLE_ADS_LABEL_KEY, payload.google_ads_label.strip())
logger.info(f'Admin {admin.telegram_id} updated analytics counters')
# Return current state
yandex_id = await get_setting_value(db, YANDEX_METRIKA_ID_KEY) or ''
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
return AnalyticsCountersResponse(
yandex_metrika_id=yandex_id,
google_ads_id=google_id,
google_ads_label=google_label,
)
# ============ Lite Mode Routes ============
@router.get('/lite-mode', response_model=LiteModeEnabledResponse)
async def get_lite_mode_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get lite mode enabled setting.
This is a public endpoint - no authentication required.
When enabled, shows simplified dashboard with minimal features.
"""
lite_mode_value = await get_setting_value(db, LITE_MODE_ENABLED_KEY)
if lite_mode_value is not None:
enabled = lite_mode_value.lower() == 'true'
return LiteModeEnabledResponse(enabled=enabled)
# Default: disabled
return LiteModeEnabledResponse(enabled=False)
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
async def update_lite_mode_enabled(
payload: LiteModeEnabledUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update lite mode enabled setting. Admin only."""
await set_setting_value(db, LITE_MODE_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set lite mode enabled: {payload.enabled}')
return LiteModeEnabledResponse(enabled=payload.enabled)
+423
View File
@@ -0,0 +1,423 @@
"""Contests routes for cabinet - user participation in games/contests."""
import logging
import random
from datetime import datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.contest import (
create_attempt,
get_active_rounds,
get_attempt,
increment_winner_count,
)
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.models import SubscriptionStatus, User
from app.services.contest_rotation_service import (
GAME_ANAGRAM,
GAME_BLITZ,
GAME_CIPHER,
GAME_EMOJI,
GAME_LOCKS,
GAME_QUEST,
GAME_SERVER,
)
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/contests', tags=['Cabinet Contests'])
# ============ Schemas ============
class ContestInfo(BaseModel):
"""Contest/game info."""
id: int
slug: str
name: str
description: str | None = None
prize_type: str
prize_value: str
is_available: bool
already_played: bool = False
class ContestGameData(BaseModel):
"""Data for playing a contest game."""
round_id: int
game_type: str
game_data: dict[str, Any]
instructions: str
class ContestAnswerRequest(BaseModel):
"""Request to submit contest answer."""
round_id: int
answer: str
class ContestResult(BaseModel):
"""Result of contest attempt."""
is_winner: bool
message: str
prize_type: str | None = None
prize_value: str | None = None
# ============ Helpers ============
def _user_allowed(subscription) -> bool:
"""Check if user is allowed to participate in contests."""
if not subscription:
return False
return subscription.status in {
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
}
async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_value: str) -> str:
"""Award prize to winner."""
if prize_type == 'days':
try:
days = int(prize_value)
except ValueError:
return 'Error: invalid prize value'
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return 'Error: subscription not found'
subscription.end_date = subscription.end_date + timedelta(days=days)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f'🎁 Extended subscription for user {user_id} by {days} days (contest prize)')
return f'Subscription extended by {days} days'
if prize_type == 'balance':
from app.database.crud.user import get_user_by_id
try:
amount = float(prize_value)
except ValueError:
return 'Error: invalid prize value'
user = await get_user_by_id(db, user_id)
if not user:
return 'Error: user not found'
user.balance += amount
await db.commit()
await db.refresh(user)
logger.info(f'🎁 Added {amount} to balance for user {user_id} (contest prize)')
return f'Balance increased by {amount}'
logger.warning(f'Unknown prize type: {prize_type}')
return f"Prize type '{prize_type}' not supported"
# ============ Routes ============
class ContestsCountResponse(BaseModel):
"""Count of available contests."""
count: int
@router.get('/count', response_model=ContestsCountResponse)
async def get_contests_count(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get count of contests available for the user."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
return ContestsCountResponse(count=0)
active_rounds = await get_active_rounds(db)
# Count unique available contests (not yet played)
count = 0
seen_templates = set()
for rnd in active_rounds:
if not rnd.template or not rnd.template.is_enabled:
continue
tpl_slug = rnd.template.slug if rnd.template else ''
if tpl_slug in seen_templates:
continue
seen_templates.add(tpl_slug)
# Check if user already played this round
attempt = await get_attempt(db, rnd.id, user.id)
if not attempt:
count += 1
return ContestsCountResponse(count=count)
@router.get('', response_model=list[ContestInfo])
async def get_contests(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available contests/games."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Contests are only available for users with active or trial subscriptions',
)
active_rounds = await get_active_rounds(db)
# Group by template to avoid duplicates
unique_templates = {}
for rnd in active_rounds:
if not rnd.template or not rnd.template.is_enabled:
continue
tpl_slug = rnd.template.slug if rnd.template else ''
if tpl_slug not in unique_templates:
unique_templates[tpl_slug] = rnd
contests = []
for tpl_slug, rnd in unique_templates.items():
# Check if user already played this round
attempt = await get_attempt(db, rnd.id, user.id)
contests.append(
ContestInfo(
id=rnd.id,
slug=tpl_slug,
name=rnd.template.name if rnd.template else tpl_slug,
description=rnd.template.description if rnd.template else None,
prize_type=rnd.template.prize_type if rnd.template else 'days',
prize_value=rnd.template.prize_value if rnd.template else '1',
is_available=True,
already_played=attempt is not None,
)
)
return contests
@router.get('/{round_id}', response_model=ContestGameData)
async def get_contest_game(
round_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get game data for a specific contest round."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Contests are only available for users with active or trial subscriptions',
)
active_rounds = await get_active_rounds(db)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Contest round not found or already finished',
)
if not round_obj.template or not round_obj.template.is_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This contest is disabled',
)
# Check if already played
attempt = await get_attempt(db, round_id, user.id)
if attempt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='You have already played this round',
)
tpl = round_obj.template
game_type = tpl.slug
game_data = {}
instructions = ''
if game_type == GAME_QUEST:
rows = round_obj.payload.get('rows', 3)
cols = round_obj.payload.get('cols', 3)
secret = random.randint(0, rows * cols - 1)
game_data = {
'rows': rows,
'cols': cols,
'secret': secret,
'grid_size': rows * cols,
}
instructions = 'Select one of the nodes in the grid. Find the hidden server!'
elif game_type == GAME_LOCKS:
total = round_obj.payload.get('total', 20)
secret = random.randint(0, total - 1)
game_data = {
'total': total,
'secret': secret,
}
instructions = 'Find the unlocked button among the locks!'
elif game_type == GAME_SERVER:
flags = round_obj.payload.get('flags') or []
shuffled_flags = flags.copy()
random.shuffle(shuffled_flags)
game_data = {
'flags': shuffled_flags,
}
instructions = 'Choose a server by clicking on a flag!'
elif game_type == GAME_CIPHER:
question = round_obj.payload.get('question', '')
game_data = {
'question': question,
'input_type': 'text',
}
instructions = 'Decrypt the cipher and enter the answer!'
elif game_type == GAME_EMOJI:
question = round_obj.payload.get('question', '🤔')
emoji_list = question.split()
random.shuffle(emoji_list)
game_data = {
'question': ' '.join(emoji_list),
'input_type': 'text',
}
instructions = 'Guess the service by emojis!'
elif game_type == GAME_ANAGRAM:
letters = round_obj.payload.get('letters', '')
game_data = {
'letters': letters,
'input_type': 'text',
}
instructions = 'Make a word from the given letters!'
elif game_type == GAME_BLITZ:
game_data = {
'button_text': "I'm here!",
}
instructions = 'Click the button as fast as you can!'
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Unknown contest type',
)
return ContestGameData(
round_id=round_id,
game_type=game_type,
game_data=game_data,
instructions=instructions,
)
@router.post('/{round_id}/answer', response_model=ContestResult)
async def submit_contest_answer(
round_id: int,
request: ContestAnswerRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Submit answer for a contest round."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Contests are only available for users with active or trial subscriptions',
)
active_rounds = await get_active_rounds(db)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Contest round not found or already finished',
)
# Check if already played
attempt = await get_attempt(db, round_id, user.id)
if attempt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='You have already played this round',
)
tpl = round_obj.template
answer = request.answer
is_winner = False
# Determine if winner based on game type
if tpl.slug == GAME_SERVER:
flags = round_obj.payload.get('flags') or []
secret_idx = round_obj.payload.get('secret_idx')
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ''
is_winner = answer == correct_flag
elif tpl.slug in {GAME_QUEST, GAME_LOCKS}:
try:
parts = answer.split('_')
if len(parts) >= 2:
idx = int(parts[0])
secret = int(parts[1])
is_winner = idx == secret
except (ValueError, IndexError):
is_winner = False
elif tpl.slug == GAME_BLITZ:
is_winner = answer.lower() == 'blitz'
elif tpl.slug in {GAME_CIPHER, GAME_EMOJI, GAME_ANAGRAM}:
correct = (round_obj.payload.get('answer') or '').upper()
is_winner = correct and answer.upper() == correct
# Record attempt
await create_attempt(db, round_id=round_obj.id, user_id=user.id, answer=str(answer), is_winner=is_winner)
if is_winner:
await increment_winner_count(db, round_obj)
prize_text = await _award_prize(db, user.id, tpl.prize_type, tpl.prize_value)
return ContestResult(
is_winner=True,
message=f'🎉 Congratulations! You won! {prize_text}',
prize_type=tpl.prize_type,
prize_value=tpl.prize_value,
)
lose_messages = {
GAME_QUEST: ['Empty node', 'Wrong server', 'Try another'],
GAME_LOCKS: ['Locked', 'No access', 'Try again'],
GAME_SERVER: ['Server overloaded', 'No response', 'Try tomorrow'],
}
messages = lose_messages.get(tpl.slug, ['Incorrect', 'Try again next round'])
return ContestResult(
is_winner=False,
message=random.choice(messages),
)
+310
View File
@@ -0,0 +1,310 @@
"""Info pages routes for cabinet - FAQ, rules, privacy policy, etc."""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.rules import get_current_rules_content, get_rules_by_language
from app.database.models import User
from app.services.faq_service import FaqService
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/info', tags=['Cabinet Info'])
_LANGUAGE_META: dict[str, tuple[str, str]] = {
'ru': ('Русский', '🇷🇺'),
'en': ('English', '🇬🇧'),
'ua': ('Українська', '🇺🇦'),
'zh': ('中文', '🇨🇳'),
'fa': ('فارسی', '🇮🇷'),
}
def _normalize_language_code(value: str | None) -> str:
return (value or '').strip().lower().split('-', 1)[0]
def _get_available_language_codes() -> list[str]:
codes: list[str] = []
seen: set[str] = set()
for code in settings.get_available_languages():
normalized = _normalize_language_code(code)
if not normalized or normalized in seen:
continue
seen.add(normalized)
codes.append(normalized)
return codes
# ============ Schemas ============
class FaqPageResponse(BaseModel):
"""FAQ page."""
id: int
title: str
content: str
order: int
class RulesResponse(BaseModel):
"""Service rules."""
content: str
updated_at: str | None = None
class PrivacyPolicyResponse(BaseModel):
"""Privacy policy."""
content: str
updated_at: str | None = None
class PublicOfferResponse(BaseModel):
"""Public offer."""
content: str
updated_at: str | None = None
class ServiceInfoResponse(BaseModel):
"""General service info."""
name: str
description: str | None = None
support_email: str | None = None
support_telegram: str | None = None
website: str | None = None
class SupportConfigResponse(BaseModel):
"""Support/tickets configuration for miniapp."""
tickets_enabled: bool
support_type: str # "tickets", "profile", "url"
support_url: str | None = None
support_username: str | None = None
# ============ Routes ============
@router.get('/faq', response_model=list[FaqPageResponse])
async def get_faq_pages(
language: str = Query('ru', min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of FAQ pages."""
requested_lang = FaqService.normalize_language(language)
pages = await FaqService.get_pages(
db,
requested_lang,
include_inactive=False, # Only active pages for cabinet
fallback=True,
)
return [
FaqPageResponse(
id=page.id,
title=page.title,
content=page.content or '',
order=page.display_order or 0,
)
for page in pages
]
@router.get('/faq/{page_id}', response_model=FaqPageResponse)
async def get_faq_page(
page_id: int,
language: str = Query('ru', min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get a specific FAQ page by ID."""
requested_lang = FaqService.normalize_language(language)
page = await FaqService.get_page(
db,
page_id,
requested_lang,
include_inactive=False,
fallback=True,
)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='FAQ page not found',
)
return FaqPageResponse(
id=page.id,
title=page.title,
content=page.content or '',
order=page.display_order or 0,
)
@router.get('/rules', response_model=RulesResponse)
async def get_rules(
language: str = Query('ru', min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get service rules - uses same function as bot."""
requested_lang = language.split('-')[0].lower()
# Use the same function as bot to ensure consistent content
content = await get_current_rules_content(db, requested_lang)
# Try to get updated_at from DB record
rules = await get_rules_by_language(db, requested_lang)
updated_at = None
if rules and rules.updated_at:
updated_at = rules.updated_at.isoformat()
return RulesResponse(content=content, updated_at=updated_at)
@router.get('/privacy-policy', response_model=PrivacyPolicyResponse)
async def get_privacy_policy(
language: str = Query('ru', min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get privacy policy."""
requested_lang = PrivacyPolicyService.normalize_language(language)
policy = await PrivacyPolicyService.get_policy(db, requested_lang, fallback=True)
if policy and policy.content:
updated_at = policy.updated_at.isoformat() if policy.updated_at else None
return PrivacyPolicyResponse(content=policy.content, updated_at=updated_at)
# Return default policy if none found
return PrivacyPolicyResponse(
content="""# Политика конфиденциальности
Мы уважаем вашу конфиденциальность и защищаем ваши персональные данные.
""",
updated_at=None,
)
@router.get('/public-offer', response_model=PublicOfferResponse)
async def get_public_offer(
language: str = Query('ru', min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get public offer."""
requested_lang = PublicOfferService.normalize_language(language)
offer = await PublicOfferService.get_offer(db, requested_lang, fallback=True)
if offer and offer.content:
updated_at = offer.updated_at.isoformat() if offer.updated_at else None
return PublicOfferResponse(content=offer.content, updated_at=updated_at)
# Return default offer if none found
return PublicOfferResponse(
content="""# Публичная оферта
Условия использования сервиса.
""",
updated_at=None,
)
@router.get('/service', response_model=ServiceInfoResponse)
async def get_service_info():
"""Get general service information."""
return ServiceInfoResponse(
name=getattr(settings, 'SERVICE_NAME', None) or getattr(settings, 'BOT_NAME', 'VPN Service'),
description=getattr(settings, 'SERVICE_DESCRIPTION', None),
support_email=getattr(settings, 'SUPPORT_EMAIL', None),
support_telegram=getattr(settings, 'SUPPORT_USERNAME', None) or getattr(settings, 'SUPPORT_TELEGRAM', None),
website=getattr(settings, 'WEBSITE_URL', None),
)
@router.get('/languages')
async def get_available_languages():
"""Get list of available languages."""
codes = _get_available_language_codes()
default_language = _normalize_language_code(getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru')
return {
'languages': [
{
'code': code,
'name': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[0],
'flag': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[1],
}
for code in codes
],
'default': default_language,
}
@router.get('/user/language')
async def get_user_language(
user: User = Depends(get_current_cabinet_user),
):
"""Get current user's language."""
return {'language': user.language or 'ru'}
@router.patch('/user/language')
async def update_user_language(
request: dict[str, str],
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user's language preference."""
requested_language = _normalize_language_code(request.get('language', 'ru'))
available_languages = _get_available_language_codes()
if requested_language not in available_languages:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid language. Supported: {", ".join(available_languages)}',
)
user.language = requested_language
await db.commit()
await db.refresh(user)
return {'language': user.language}
@router.get('/support-config', response_model=SupportConfigResponse)
async def get_support_config():
"""Get support/tickets configuration for cabinet."""
# Use SUPPORT_SYSTEM_MODE setting (configurable from admin panel)
support_mode = settings.get_support_system_mode() # returns: tickets, contact, or both
# Map support mode to support type for frontend
# - "tickets" mode -> tickets only, no contact
# - "contact" mode -> contact only (profile), no tickets
# - "both" mode -> tickets enabled, contact available as fallback
if support_mode == 'tickets':
tickets_enabled = True
support_type = 'tickets'
elif support_mode == 'contact':
tickets_enabled = False
support_type = 'profile'
else: # both
tickets_enabled = True
support_type = 'tickets'
return SupportConfigResponse(
tickets_enabled=tickets_enabled,
support_type=support_type,
support_url=None, # Cabinet doesn't use custom URLs
support_username=settings.SUPPORT_USERNAME, # Always return for fallback
)
+196
View File
@@ -0,0 +1,196 @@
"""Media upload/download routes for cabinet tickets."""
import logging
import mimetypes
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
from pydantic import BaseModel
from app.config import settings
from app.database.models import User
from ..dependencies import get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/media', tags=['Cabinet Media'])
ALLOWED_MEDIA_TYPES = {'photo', 'video', 'document'}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
class MediaUploadResponse(BaseModel):
"""Response after successful media upload."""
media_type: str
file_id: str
file_unique_id: str | None = None
media_url: str
def _resolve_target_chat_id() -> int:
"""Get chat ID for uploading files (notification channel or first admin)."""
chat_id = settings.get_admin_notifications_chat_id()
if chat_id is not None:
return chat_id
admin_ids = settings.get_admin_ids()
if admin_ids:
return admin_ids[0]
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='No chat configured for file uploads',
)
def _build_media_url(request: Request, file_id: str) -> str:
"""Build URL for downloading media."""
return str(request.url_for('cabinet_download_media', file_id=file_id))
@router.post('/upload', response_model=MediaUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_media(
request: Request,
user: User = Depends(get_current_cabinet_user),
file: UploadFile = File(...),
media_type: str = Form('photo', description='File type: photo, video, or document'),
):
"""
Upload media file for use in ticket messages.
Returns file_id that can be used when creating ticket or adding message.
"""
media_type_normalized = (media_type or '').strip().lower()
if media_type_normalized not in ALLOWED_MEDIA_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Unsupported media type. Allowed: {", ".join(ALLOWED_MEDIA_TYPES)}',
)
# Read and validate file
file_bytes = await file.read()
if not file_bytes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='File is empty',
)
if len(file_bytes) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB',
)
# Validate content type for photos
if media_type_normalized == 'photo':
allowed_image_types = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'}
if file.content_type and file.content_type not in allowed_image_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid image type. Allowed: JPEG, PNG, GIF, WebP',
)
target_chat_id = _resolve_target_chat_id()
upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload')
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
if media_type_normalized == 'photo':
message = await bot.send_photo(
chat_id=target_chat_id,
photo=upload,
)
media = message.photo[-1]
elif media_type_normalized == 'video':
message = await bot.send_video(
chat_id=target_chat_id,
video=upload,
)
media = message.video
else:
message = await bot.send_document(
chat_id=target_chat_id,
document=upload,
)
media = message.document
media_url = _build_media_url(request, media.file_id)
logger.info(f'User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}')
return MediaUploadResponse(
media_type=media_type_normalized,
file_id=media.file_id,
file_unique_id=getattr(media, 'file_unique_id', None),
media_url=media_url,
)
except HTTPException:
raise
except Exception as error:
logger.error(f'Failed to upload media for user {user.telegram_id}: {error}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to upload media',
) from error
finally:
await bot.session.close()
@router.get('/{file_id}', name='cabinet_download_media')
async def download_media(
file_id: str,
) -> Response:
"""
Download media file by file_id.
Used to display images/documents in ticket messages.
"""
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
file = await bot.get_file(file_id)
if not file.file_path:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Media file not found',
)
buffer = await bot.download_file(file.file_path)
if hasattr(buffer, 'seek'):
buffer.seek(0)
content = buffer.read() if hasattr(buffer, 'read') else bytes(buffer)
filename = file.file_path.split('/')[-1]
media_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
return Response(
content=content,
media_type=media_type,
headers={
'Content-Disposition': f'inline; filename={filename}',
'Cache-Control': 'public, max-age=86400', # Cache for 24 hours
},
)
except HTTPException:
raise
except Exception as error:
logger.error(f'Failed to download media {file_id}: {error}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to download media',
) from error
finally:
await bot.session.close()
+151
View File
@@ -0,0 +1,151 @@
"""Notification settings routes for cabinet."""
import logging
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/notifications', tags=['Cabinet Notifications'])
# ============ Schemas ============
class NotificationSettingsResponse(BaseModel):
"""User notification settings."""
subscription_expiry_enabled: bool = True
subscription_expiry_days: int = 3
traffic_warning_enabled: bool = True
traffic_warning_percent: int = 80
balance_low_enabled: bool = True
balance_low_threshold: int = 100 # kopeks
news_enabled: bool = True
promo_offers_enabled: bool = True
class NotificationSettingsUpdate(BaseModel):
"""Update notification settings."""
subscription_expiry_enabled: bool | None = None
subscription_expiry_days: int | None = Field(None, ge=1, le=30)
traffic_warning_enabled: bool | None = None
traffic_warning_percent: int | None = Field(None, ge=50, le=99)
balance_low_enabled: bool | None = None
balance_low_threshold: int | None = Field(None, ge=0)
news_enabled: bool | None = None
promo_offers_enabled: bool | None = None
# ============ Helpers ============
def _get_notification_settings(user: User) -> dict[str, Any]:
"""Get notification settings from user object."""
# Try to get from user's settings field or use defaults
settings_data = getattr(user, 'notification_settings', None) or {}
return {
'subscription_expiry_enabled': settings_data.get('subscription_expiry_enabled', True),
'subscription_expiry_days': settings_data.get('subscription_expiry_days', 3),
'traffic_warning_enabled': settings_data.get('traffic_warning_enabled', True),
'traffic_warning_percent': settings_data.get('traffic_warning_percent', 80),
'balance_low_enabled': settings_data.get('balance_low_enabled', True),
'balance_low_threshold': settings_data.get('balance_low_threshold', 100),
'news_enabled': settings_data.get('news_enabled', True),
'promo_offers_enabled': settings_data.get('promo_offers_enabled', True),
}
def _update_notification_settings(user: User, updates: dict[str, Any]) -> dict[str, Any]:
"""Update notification settings on user object."""
current_settings = _get_notification_settings(user)
for key, value in updates.items():
if value is not None:
current_settings[key] = value
return current_settings
# ============ Routes ============
@router.get('', response_model=NotificationSettingsResponse)
async def get_notification_settings(
user: User = Depends(get_current_cabinet_user),
):
"""Get user's notification settings."""
settings = _get_notification_settings(user)
return NotificationSettingsResponse(**settings)
@router.patch('', response_model=NotificationSettingsResponse)
async def update_notification_settings(
request: NotificationSettingsUpdate,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user's notification settings."""
updates = request.model_dump(exclude_unset=True)
if not updates:
# No updates provided, return current settings
settings = _get_notification_settings(user)
return NotificationSettingsResponse(**settings)
# Update settings
new_settings = _update_notification_settings(user, updates)
# Store in user object
if not hasattr(user, 'notification_settings') or user.notification_settings is None:
user.notification_settings = {}
user.notification_settings = new_settings
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
return NotificationSettingsResponse(**new_settings)
@router.post('/test')
async def send_test_notification(
user: User = Depends(get_current_cabinet_user),
):
"""Send a test notification to the user."""
# This would typically trigger a notification via Telegram bot
# For now, just return success
return {
'success': True,
'message': 'Test notification request received. You will receive a test message shortly.',
}
@router.get('/history')
async def get_notification_history(
limit: int = 20,
offset: int = 0,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's notification history."""
# For now, return empty list - notification history can be implemented later
# when there's a notification log table
return {
'notifications': [],
'total': 0,
'limit': limit,
'offset': offset,
}
+165
View File
@@ -0,0 +1,165 @@
"""OAuth 2.0 authentication routes for cabinet."""
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.user import (
create_user_by_oauth,
get_user_by_email,
get_user_by_oauth_provider,
set_user_oauth_provider_id,
)
from app.database.models import User
from ..auth.oauth_providers import (
OAuthUserInfo,
generate_oauth_state,
get_provider,
validate_oauth_state,
)
from ..dependencies import get_cabinet_db
from ..schemas.auth import AuthResponse
from .auth import _create_auth_response, _store_refresh_token
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
async def _finalize_oauth_login(db: AsyncSession, user: User, provider: str) -> AuthResponse:
"""Update last login, create tokens, store refresh token."""
user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None)
await db.commit()
auth_response = _create_auth_response(user)
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
return auth_response
# --- Schemas ---
class OAuthProviderInfo(BaseModel):
name: str
display_name: str
class OAuthProvidersResponse(BaseModel):
providers: list[OAuthProviderInfo]
class OAuthAuthorizeResponse(BaseModel):
authorize_url: str
state: str
class OAuthCallbackRequest(BaseModel):
code: str = Field(..., description='Authorization code from provider')
state: str = Field(..., description='CSRF state token')
# --- Endpoints ---
@router.get('/providers', response_model=OAuthProvidersResponse)
async def get_oauth_providers():
"""Get list of enabled OAuth providers."""
providers_config = settings.get_oauth_providers_config()
providers = [
OAuthProviderInfo(name=name, display_name=cfg['display_name'])
for name, cfg in providers_config.items()
if cfg['enabled']
]
return OAuthProvidersResponse(providers=providers)
@router.get('/{provider}/authorize', response_model=OAuthAuthorizeResponse)
async def get_oauth_authorize_url(provider: str):
"""Get authorization URL for an OAuth provider."""
oauth_provider = get_provider(provider)
if not oauth_provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'OAuth provider "{provider}" is not enabled',
)
state = await generate_oauth_state(provider)
authorize_url = oauth_provider.get_authorization_url(state)
return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state)
@router.post('/{provider}/callback', response_model=AuthResponse)
async def oauth_callback(
provider: str,
request: OAuthCallbackRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Handle OAuth callback: exchange code, find/create user, return JWT."""
# 1. Validate CSRF state
if not await validate_oauth_state(request.state, provider):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid or expired OAuth state',
)
# 2. Get provider instance
oauth_provider = get_provider(provider)
if not oauth_provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'OAuth provider "{provider}" is not enabled',
)
# 3. Exchange code for tokens
try:
token_data = await oauth_provider.exchange_code(request.code)
except Exception as exc:
logger.error('OAuth code exchange failed for %s: %s', provider, exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to exchange authorization code',
) from exc
# 4. Fetch user info from provider
try:
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
except Exception as exc:
logger.error('OAuth user info fetch failed for %s: %s', provider, exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to fetch user information from provider',
) from exc
# 5. Find user by provider ID
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
if user:
logger.info('OAuth login via %s for existing user %s', provider, user.id)
return await _finalize_oauth_login(db, user, provider)
# 6. Find user by email (if verified) and link provider
if user_info.email and user_info.email_verified:
user = await get_user_by_email(db, user_info.email)
if user:
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
logger.info('OAuth login via %s linked to existing email user %s', provider, user.id)
return await _finalize_oauth_login(db, user, provider)
# 7. Create new user
user = await create_user_by_oauth(
db=db,
provider=provider,
provider_id=user_info.provider_id,
email=user_info.email if user_info.email_verified else None,
email_verified=user_info.email_verified,
first_name=user_info.first_name,
last_name=user_info.last_name,
username=user_info.username,
)
logger.info('OAuth new user created via %s with id=%s', provider, user.id)
return await _finalize_oauth_login(db, user, provider)
+365
View File
@@ -0,0 +1,365 @@
"""Polls routes for cabinet - user participation in polls/surveys."""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.poll import (
get_poll_response_by_id,
record_poll_answer,
)
from app.database.models import Poll, PollQuestion, PollResponse, User
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/polls', tags=['Cabinet Polls'])
# ============ Schemas ============
class PollOptionResponse(BaseModel):
"""Poll option."""
id: int
text: str
order: int
class PollQuestionResponse(BaseModel):
"""Poll question with options."""
id: int
text: str
order: int
options: list[PollOptionResponse]
class PollInfo(BaseModel):
"""Poll info for user."""
id: int
response_id: int
title: str
description: str | None = None
total_questions: int
answered_questions: int
is_completed: bool
reward_amount: int | None = None
class PollStartResponse(BaseModel):
"""Response when starting a poll."""
response_id: int
current_question_index: int
total_questions: int
question: PollQuestionResponse
class AnswerRequest(BaseModel):
"""Request to answer a poll question."""
option_id: int
class AnswerResponse(BaseModel):
"""Response after answering."""
success: bool
is_completed: bool
next_question: PollQuestionResponse | None = None
current_question_index: int | None = None
total_questions: int
reward_granted: int | None = None
message: str | None = None
# ============ Helpers ============
def _question_to_response(question: PollQuestion) -> PollQuestionResponse:
"""Convert question model to response."""
options = [
PollOptionResponse(
id=opt.id,
text=opt.text,
order=opt.order,
)
for opt in sorted(question.options, key=lambda o: o.order)
]
return PollQuestionResponse(
id=question.id,
text=question.text,
order=question.order,
options=options,
)
# ============ Routes ============
class PollsCountResponse(BaseModel):
"""Count of available polls."""
count: int
@router.get('/count', response_model=PollsCountResponse)
async def get_polls_count(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get count of polls available for the user."""
result = await db.execute(
select(PollResponse)
.where(PollResponse.user_id == user.id)
.where(PollResponse.completed_at.is_(None)) # Only incomplete polls
)
responses = result.scalars().all()
return PollsCountResponse(count=len(responses))
@router.get('', response_model=list[PollInfo])
async def get_available_polls(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of polls available for the user."""
# Get user's poll responses with eager loading of relationships
result = await db.execute(
select(PollResponse)
.where(PollResponse.user_id == user.id)
.options(
selectinload(PollResponse.poll).selectinload(Poll.questions),
selectinload(PollResponse.answers),
)
.order_by(PollResponse.created_at.desc())
)
responses = result.scalars().all()
polls = []
for response in responses:
if not response.poll:
continue
answered_count = len(response.answers) if response.answers else 0
total_questions = len(response.poll.questions) if response.poll.questions else 0
# Convert kopeks to rubles for display
reward_amount = None
if response.poll.reward_amount_kopeks:
reward_amount = response.poll.reward_amount_kopeks // 100
polls.append(
PollInfo(
id=response.poll.id,
response_id=response.id,
title=response.poll.title,
description=response.poll.description,
total_questions=total_questions,
answered_questions=answered_count,
is_completed=response.completed_at is not None,
reward_amount=reward_amount,
)
)
return polls
@router.get('/{response_id}', response_model=PollInfo)
async def get_poll_details(
response_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get details of a specific poll response."""
response = await get_poll_response_by_id(db, response_id)
if not response or response.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Poll not found',
)
if not response.poll:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Poll data not available',
)
answered_count = len(response.answers) if response.answers else 0
total_questions = len(response.poll.questions) if response.poll.questions else 0
# Convert kopeks to rubles for display
reward_amount = None
if response.poll.reward_amount_kopeks:
reward_amount = response.poll.reward_amount_kopeks // 100
return PollInfo(
id=response.poll.id,
response_id=response.id,
title=response.poll.title,
description=response.poll.description,
total_questions=total_questions,
answered_questions=answered_count,
is_completed=response.completed_at is not None,
reward_amount=reward_amount,
)
@router.post('/{response_id}/start', response_model=PollStartResponse)
async def start_poll(
response_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Start or continue a poll."""
response = await get_poll_response_by_id(db, response_id)
if not response or response.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Poll not found',
)
if response.completed_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This poll has already been completed',
)
if not response.poll or not response.poll.questions:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Poll is not available',
)
# Mark as started if not already
if not response.started_at:
response.started_at = datetime.utcnow()
await db.commit()
# Get next unanswered question
index, question = await get_next_question(response)
if not question:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No questions available',
)
return PollStartResponse(
response_id=response.id,
current_question_index=index,
total_questions=len(response.poll.questions),
question=_question_to_response(question),
)
@router.post('/{response_id}/questions/{question_id}/answer', response_model=AnswerResponse)
async def answer_question(
response_id: int,
question_id: int,
request: AnswerRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Submit answer for a poll question."""
response = await get_poll_response_by_id(db, response_id)
if not response or response.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Poll not found',
)
if response.completed_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This poll has already been completed',
)
if not response.poll:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Poll is not available',
)
# Find the question
question = next((q for q in response.poll.questions if q.id == question_id), None)
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Question not found',
)
# Validate option
option = await get_question_option(question, request.option_id)
if not option:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid answer option',
)
# Record the answer
await record_poll_answer(
db,
response_id=response.id,
question_id=question.id,
option_id=option.id,
)
# Refresh to get updated answers
try:
await db.refresh(response, attribute_names=['answers'])
except Exception:
response = await get_poll_response_by_id(db, response_id)
if not response:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to process answer',
)
# Get next question
index, next_question = await get_next_question(response)
total_questions = len(response.poll.questions)
if next_question:
# More questions to answer
return AnswerResponse(
success=True,
is_completed=False,
next_question=_question_to_response(next_question),
current_question_index=index,
total_questions=total_questions,
)
# Poll completed
response.completed_at = datetime.utcnow()
await db.commit()
# Award reward if any
reward_amount = await reward_user_for_poll(db, response)
message = 'Thank you for completing the poll!'
if reward_amount:
message += f' Reward of {settings.format_price(reward_amount)} has been added to your balance.'
return AnswerResponse(
success=True,
is_completed=True,
total_questions=total_questions,
reward_granted=reward_amount,
message=message,
)
+415
View File
@@ -0,0 +1,415 @@
"""Promo offers routes for cabinet - personal discounts and offers."""
import logging
from datetime import datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_group import get_auto_assign_promo_groups
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.database.models import DiscountOffer, User
from app.services.promo_offer_service import promo_offer_service
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/promo', tags=['Cabinet Promo'])
# ============ Schemas ============
class PromoOfferInfo(BaseModel):
"""Promo offer info."""
id: int
notification_type: str
discount_percent: int | None = None
effect_type: str
expires_at: datetime
is_active: bool
is_claimed: bool
claimed_at: datetime | None = None
extra_data: dict[str, Any] | None = None
class ActiveDiscountInfo(BaseModel):
"""User's active discount info."""
discount_percent: int
source: str | None = None
expires_at: datetime | None = None
is_active: bool
class ClaimOfferRequest(BaseModel):
"""Request to claim an offer."""
offer_id: int
class ClaimOfferResponse(BaseModel):
"""Response after claiming offer."""
success: bool
message: str
discount_percent: int | None = None
expires_at: datetime | None = None
class PromoGroupDiscounts(BaseModel):
"""User's promo group discounts."""
group_name: str | None = None
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: dict[str, int] = {}
class LoyaltyTierInfo(BaseModel):
"""Info about a single loyalty tier (promo group)."""
id: int
name: str
threshold_rubles: float
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: dict[str, int] = {}
is_current: bool = False
is_achieved: bool = False
class LoyaltyTiersResponse(BaseModel):
"""Response with all loyalty tiers and user progress."""
tiers: list[LoyaltyTierInfo]
current_spent_rubles: float
current_tier_name: str | None = None
next_tier_name: str | None = None
next_tier_threshold_rubles: float | None = None
progress_percent: float = 0
# ============ Routes ============
@router.get('/offers', response_model=list[PromoOfferInfo])
async def get_promo_offers(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available promo offers for the user."""
now = datetime.utcnow()
result = await db.execute(
select(DiscountOffer)
.where(
and_(
DiscountOffer.user_id == user.id,
DiscountOffer.expires_at > now,
)
)
.order_by(DiscountOffer.created_at.desc())
)
offers = result.scalars().all()
return [
PromoOfferInfo(
id=offer.id,
notification_type=offer.notification_type or '',
discount_percent=offer.discount_percent,
effect_type=offer.effect_type or 'percent_discount',
expires_at=offer.expires_at,
is_active=offer.is_active and offer.claimed_at is None,
is_claimed=offer.claimed_at is not None,
claimed_at=offer.claimed_at,
extra_data=offer.extra_data,
)
for offer in offers
]
@router.get('/active-discount', response_model=ActiveDiscountInfo)
async def get_active_discount(
user: User = Depends(get_current_cabinet_user),
):
"""Get user's currently active discount."""
discount_percent = user.promo_offer_discount_percent or 0
expires_at = user.promo_offer_discount_expires_at
source = user.promo_offer_discount_source
now = datetime.utcnow()
is_active = discount_percent > 0 and (expires_at is None or expires_at > now)
return ActiveDiscountInfo(
discount_percent=discount_percent if is_active else 0,
source=source,
expires_at=expires_at,
is_active=is_active,
)
@router.get('/group-discounts', response_model=PromoGroupDiscounts)
async def get_promo_group_discounts(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's promo group discounts."""
await db.refresh(user, ['promo_group', 'user_promo_groups'])
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if not promo_group:
return PromoGroupDiscounts()
# Get period discounts
period_discounts = {}
raw_period_discounts = getattr(promo_group, 'period_discounts', None)
if isinstance(raw_period_discounts, dict):
for key, value in raw_period_discounts.items():
try:
period_discounts[str(key)] = int(value)
except (TypeError, ValueError):
continue
return PromoGroupDiscounts(
group_name=promo_group.name,
server_discount_percent=promo_group.server_discount_percent or 0,
traffic_discount_percent=promo_group.traffic_discount_percent or 0,
device_discount_percent=promo_group.device_discount_percent or 0,
period_discounts=period_discounts,
)
@router.get('/loyalty-tiers', response_model=LoyaltyTiersResponse)
async def get_loyalty_tiers(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all loyalty tiers (promo groups with auto-assign thresholds) and user's progress."""
# Get user's total spent
total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id)
total_spent_rubles = total_spent_kopeks / 100
# Get user's current promo group
await db.refresh(user, ['promo_group', 'user_promo_groups'])
current_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
current_tier_name = current_promo_group.name if current_promo_group else None
# Get all auto-assign promo groups (sorted by threshold ascending)
auto_groups = await get_auto_assign_promo_groups(db)
tiers: list[LoyaltyTierInfo] = []
next_tier_name: str | None = None
next_tier_threshold: float | None = None
for group in auto_groups:
threshold_kopeks = group.auto_assign_total_spent_kopeks or 0
threshold_rubles = threshold_kopeks / 100
is_achieved = total_spent_kopeks >= threshold_kopeks
is_current = current_promo_group and current_promo_group.id == group.id
# Get period discounts
period_discounts = {}
raw_period_discounts = getattr(group, 'period_discounts', None)
if isinstance(raw_period_discounts, dict):
for key, value in raw_period_discounts.items():
try:
period_discounts[str(key)] = int(value)
except (TypeError, ValueError):
continue
tiers.append(
LoyaltyTierInfo(
id=group.id,
name=group.name,
threshold_rubles=threshold_rubles,
server_discount_percent=group.server_discount_percent or 0,
traffic_discount_percent=group.traffic_discount_percent or 0,
device_discount_percent=group.device_discount_percent or 0,
period_discounts=period_discounts,
is_current=is_current,
is_achieved=is_achieved,
)
)
# Find next tier (first not achieved)
if not is_achieved and next_tier_name is None:
next_tier_name = group.name
next_tier_threshold = threshold_rubles
# Calculate progress to next tier
progress_percent = 0.0
if next_tier_threshold and next_tier_threshold > 0:
progress_percent = min(100.0, (total_spent_rubles / next_tier_threshold) * 100)
elif tiers and all(t.is_achieved for t in tiers):
# All tiers achieved
progress_percent = 100.0
return LoyaltyTiersResponse(
tiers=tiers,
current_spent_rubles=total_spent_rubles,
current_tier_name=current_tier_name,
next_tier_name=next_tier_name,
next_tier_threshold_rubles=next_tier_threshold,
progress_percent=progress_percent,
)
@router.post('/claim', response_model=ClaimOfferResponse)
async def claim_promo_offer(
request: ClaimOfferRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Claim a promo offer."""
offer = await get_offer_by_id(db, request.offer_id)
if not offer or offer.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Offer not found',
)
now = datetime.utcnow()
if offer.claimed_at is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This offer has already been claimed',
)
if not offer.is_active or offer.expires_at <= now:
offer.is_active = False
await db.commit()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This offer has expired',
)
effect_type = (offer.effect_type or 'percent_discount').lower()
# Handle test access offers
if effect_type == 'test_access':
await db.refresh(user, ['subscription'])
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
db,
user,
offer,
)
if not success:
error_messages = {
'subscription_missing': 'Active subscription required for this offer',
'squads_missing': 'Could not determine servers for test access',
'already_connected': 'These servers are already connected',
'remnawave_sync_failed': 'Failed to connect servers. Please try again later',
}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_messages.get(error_code, 'Failed to activate offer'),
)
await mark_offer_claimed(
db,
offer,
details={
'context': 'test_access_claim',
'new_squads': newly_added,
'expires_at': expires_at.isoformat() if expires_at else None,
},
)
return ClaimOfferResponse(
success=True,
message=f'Test access activated until {expires_at.strftime("%Y-%m-%d %H:%M") if expires_at else "unlimited"}',
expires_at=expires_at,
)
# Handle discount offers
discount_percent = int(offer.discount_percent or 0)
if discount_percent <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid offer',
)
user.promo_offer_discount_percent = discount_percent
user.promo_offer_discount_source = offer.notification_type
user.updated_at = now
# Calculate expiration
extra_data = offer.extra_data or {}
raw_duration = extra_data.get('active_discount_hours')
template_id = extra_data.get('template_id')
if raw_duration in (None, '') and template_id:
try:
template = await get_promo_offer_template_by_id(db, int(template_id))
except (ValueError, TypeError):
template = None
if template and template.active_discount_hours:
raw_duration = template.active_discount_hours
try:
duration_hours = int(raw_duration) if raw_duration is not None else None
except (TypeError, ValueError):
duration_hours = None
if duration_hours and duration_hours > 0:
discount_expires_at = now + timedelta(hours=duration_hours)
else:
discount_expires_at = None
user.promo_offer_discount_expires_at = discount_expires_at
await mark_offer_claimed(
db,
offer,
details={
'context': 'discount_claim',
'discount_percent': discount_percent,
'discount_expires_at': discount_expires_at.isoformat() if discount_expires_at else None,
},
)
await db.refresh(user)
expires_text = ''
if discount_expires_at:
expires_text = f' Valid until {discount_expires_at.strftime("%Y-%m-%d %H:%M")}'
return ClaimOfferResponse(
success=True,
message=f'Discount of {discount_percent}% activated!{expires_text}',
discount_percent=discount_percent,
expires_at=discount_expires_at,
)
@router.delete('/active-discount')
async def clear_active_discount(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Clear user's active discount."""
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
await db.commit()
return {'message': 'Active discount cleared'}
+124
View File
@@ -0,0 +1,124 @@
"""Promo code routes for cabinet."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.promocode_service import PromoCodeService
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/promocode', tags=['Cabinet Promocode'])
class PromocodeActivateRequest(BaseModel):
"""Request to activate a promo code."""
code: str = Field(..., min_length=1, max_length=50, description='Promo code to activate')
class PromocodeActivateResponse(BaseModel):
"""Response after activating a promo code."""
success: bool
message: str
balance_before: float = 0
balance_after: float = 0
bonus_description: str | None = None
class PromocodeDeactivateResponse(BaseModel):
"""Response after deactivating a discount promo code."""
success: bool
message: str
deactivated_code: str | None = None
discount_percent: int = 0
@router.post('/activate', response_model=PromocodeActivateResponse)
async def activate_promocode(
request: PromocodeActivateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Activate a promo code for the current user."""
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db=db, user_id=user.id, code=request.code.strip())
if result['success']:
balance_before_rubles = result.get('balance_before_kopeks', 0) / 100
balance_after_rubles = result.get('balance_after_kopeks', 0) / 100
return PromocodeActivateResponse(
success=True,
message='Promo code activated successfully',
balance_before=balance_before_rubles,
balance_after=balance_after_rubles,
bonus_description=result.get('description'),
)
# Map error codes to messages
error_messages = {
'not_found': 'Promo code not found',
'expired': 'Promo code has expired',
'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',
'not_first_purchase': 'This promo code is only available for first purchase',
'user_not_found': 'User not found',
'server_error': 'Server error occurred',
}
error_code = result.get('error', 'server_error')
error_message = error_messages.get(error_code, 'Failed to activate promo code')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
)
@router.post('/deactivate-discount', response_model=PromocodeDeactivateResponse)
async def deactivate_discount_promocode(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromocodeDeactivateResponse:
"""Deactivate the currently active discount promo code for the current user."""
promocode_service = PromoCodeService()
result = await promocode_service.deactivate_discount_promocode(
db=db,
user_id=user.id,
admin_initiated=False,
)
if result['success']:
return PromocodeDeactivateResponse(
success=True,
message='Discount promo code deactivated successfully',
deactivated_code=result.get('deactivated_code'),
discount_percent=result.get('discount_percent', 0),
)
error_messages = {
'user_not_found': 'User not found',
'no_active_discount_promocode': 'No active discount promo code found',
'discount_already_expired': 'Discount has already expired',
'server_error': 'Server error occurred',
}
error_code = result.get('error', 'server_error')
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
)
+197
View File
@@ -0,0 +1,197 @@
"""Referral program routes for cabinet."""
import logging
import math
from fastapi import APIRouter, Depends, Query
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import ReferralEarning, User
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.referral import (
ReferralEarningResponse,
ReferralEarningsListResponse,
ReferralInfoResponse,
ReferralItemResponse,
ReferralListResponse,
ReferralTermsResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/referral', tags=['Cabinet Referral'])
@router.get('', response_model=ReferralInfoResponse)
async def get_referral_info(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get referral program info for current user."""
# Get total referrals count
total_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id)
total_result = await db.execute(total_query)
total_referrals = total_result.scalar() or 0
# Get active referrals (with subscription)
active_query = (
select(func.count())
.select_from(User)
.where(User.referred_by_id == user.id)
.where(User.has_had_paid_subscription == True)
)
active_result = await db.execute(active_query)
active_referrals = active_result.scalar() or 0
# Get total earnings
earnings_query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == user.id
)
earnings_result = await db.execute(earnings_query)
total_earnings = earnings_result.scalar() or 0
# Get user's commission percent
commission_percent = user.referral_commission_percent
if commission_percent is None:
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
# Build referral link
bot_username = settings.get_bot_username() or 'bot'
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
return ReferralInfoResponse(
referral_code=user.referral_code or '',
referral_link=referral_link,
total_referrals=total_referrals,
active_referrals=active_referrals,
total_earnings_kopeks=total_earnings,
total_earnings_rubles=total_earnings / 100,
commission_percent=commission_percent,
)
@router.get('/list', response_model=ReferralListResponse)
async def get_referral_list(
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of invited users."""
# Base query with eager loading of subscription relationship
query = select(User).options(selectinload(User.subscription)).where(User.referred_by_id == user.id)
# Get total count
count_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate
offset = (page - 1) * per_page
query = query.order_by(desc(User.created_at)).offset(offset).limit(per_page)
result = await db.execute(query)
referrals = result.scalars().all()
items = [
ReferralItemResponse(
id=r.id,
username=r.username,
first_name=r.first_name,
created_at=r.created_at,
has_subscription=r.subscription is not None,
has_paid=r.has_had_paid_subscription,
)
for r in referrals
]
pages = math.ceil(total / per_page) if total > 0 else 1
return ReferralListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get('/earnings', response_model=ReferralEarningsListResponse)
async def get_referral_earnings(
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get referral earnings history."""
# Base query
query = select(ReferralEarning).where(ReferralEarning.user_id == user.id)
# Get total count and sum
count_query = select(func.count()).select_from(ReferralEarning).where(ReferralEarning.user_id == user.id)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
sum_query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == user.id
)
sum_result = await db.execute(sum_query)
total_amount = sum_result.scalar() or 0
# Paginate
offset = (page - 1) * per_page
query = query.order_by(desc(ReferralEarning.created_at)).offset(offset).limit(per_page)
result = await db.execute(query)
earnings = result.scalars().all()
items = []
for e in earnings:
# Get referral user info
referral_query = select(User).where(User.id == e.referral_id)
referral_result = await db.execute(referral_query)
referral_user = referral_result.scalar_one_or_none()
items.append(
ReferralEarningResponse(
id=e.id,
amount_kopeks=e.amount_kopeks,
amount_rubles=e.amount_kopeks / 100,
reason=e.reason or 'Referral commission',
referral_username=referral_user.username if referral_user else None,
referral_first_name=referral_user.first_name if referral_user else None,
created_at=e.created_at,
)
)
pages = math.ceil(total / per_page) if total > 0 else 1
return ReferralEarningsListResponse(
items=items,
total=total,
total_amount_kopeks=total_amount,
total_amount_rubles=total_amount / 100,
page=page,
per_page=per_page,
pages=pages,
)
@router.get('/terms', response_model=ReferralTermsResponse)
async def get_referral_terms():
"""Get referral program terms."""
return ReferralTermsResponse(
is_enabled=settings.is_referral_program_enabled(),
commission_percent=settings.REFERRAL_COMMISSION_PERCENT,
minimum_topup_kopeks=settings.REFERRAL_MINIMUM_TOPUP_KOPEKS,
minimum_topup_rubles=settings.REFERRAL_MINIMUM_TOPUP_KOPEKS / 100,
first_topup_bonus_kopeks=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
)
File diff suppressed because it is too large Load Diff
+204
View File
@@ -0,0 +1,204 @@
"""Ticket notifications routes for cabinet."""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/tickets/notifications', tags=['Cabinet Ticket Notifications'])
admin_router = APIRouter(prefix='/admin/tickets/notifications', tags=['Cabinet Admin Ticket Notifications'])
# Schemas
class TicketNotificationResponse(BaseModel):
"""Single ticket notification."""
id: int
ticket_id: int
notification_type: str
message: str | None = None
is_read: bool
created_at: datetime
read_at: datetime | None = None
class Config:
from_attributes = True
class TicketNotificationListResponse(BaseModel):
"""List of ticket notifications."""
items: list[TicketNotificationResponse]
unread_count: int
class UnreadCountResponse(BaseModel):
"""Unread notifications count."""
unread_count: int
# User endpoints
@router.get('', response_model=TicketNotificationListResponse)
async def get_user_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),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket notifications for current user."""
notifications = await TicketNotificationCRUD.get_user_notifications(
db, user.id, unread_only=unread_only, limit=limit, offset=offset
)
unread_count = await TicketNotificationCRUD.count_unread_user(db, user.id)
return TicketNotificationListResponse(
items=[TicketNotificationResponse.model_validate(n) for n in notifications],
unread_count=unread_count,
)
@router.get('/unread-count', response_model=UnreadCountResponse)
async def get_user_unread_count(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get unread notifications count for current user."""
count = await TicketNotificationCRUD.count_unread_user(db, user.id)
return UnreadCountResponse(unread_count=count)
@router.post('/{notification_id}/read')
async def mark_notification_as_read(
notification_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark a notification as read."""
# Security: Verify notification belongs to current user and is not an admin notification
notification = await TicketNotificationCRUD.get_by_id(db, notification_id)
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Notification not found',
)
# Check ownership: notification must belong to user and not be an admin notification
if notification.user_id != user.id or notification.is_for_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to mark this notification as read",
)
await TicketNotificationCRUD.mark_as_read(db, notification_id)
return {'success': True}
@router.post('/read-all')
async def mark_all_notifications_as_read(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all notifications as read for current user."""
count = await TicketNotificationCRUD.mark_all_as_read_user(db, user.id)
return {'success': True, 'marked_count': count}
@router.post('/ticket/{ticket_id}/read')
async def mark_ticket_notifications_as_read(
ticket_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all notifications for a specific ticket as read."""
count = await TicketNotificationCRUD.mark_ticket_notifications_as_read(db, ticket_id, user.id, is_admin=False)
return {'success': True, 'marked_count': count}
# Admin endpoints
@admin_router.get('', response_model=TicketNotificationListResponse)
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),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket notifications for admins."""
notifications = await TicketNotificationCRUD.get_admin_notifications(
db, unread_only=unread_only, limit=limit, offset=offset
)
unread_count = await TicketNotificationCRUD.count_unread_admin(db)
return TicketNotificationListResponse(
items=[TicketNotificationResponse.model_validate(n) for n in notifications],
unread_count=unread_count,
)
@admin_router.get('/unread-count', response_model=UnreadCountResponse)
async def get_admin_unread_count(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get unread notifications count for admins."""
count = await TicketNotificationCRUD.count_unread_admin(db)
return UnreadCountResponse(unread_count=count)
@admin_router.post('/{notification_id}/read')
async def mark_admin_notification_as_read(
notification_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark an admin notification as read."""
# Security: Verify notification exists and is an admin notification
notification = await TicketNotificationCRUD.get_by_id(db, notification_id)
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Notification not found',
)
# Check that this is actually an admin notification
if not notification.is_for_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='This is not an admin notification',
)
await TicketNotificationCRUD.mark_as_read(db, notification_id)
return {'success': True}
@admin_router.post('/read-all')
async def mark_all_admin_notifications_as_read(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all admin notifications as read."""
count = await TicketNotificationCRUD.mark_all_as_read_admin(db)
return {'success': True, 'marked_count': count}
@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),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all admin notifications for a specific ticket as read."""
count = await TicketNotificationCRUD.mark_ticket_notifications_as_read(db, ticket_id, admin.id, is_admin=True)
return {'success': True, 'marked_count': count}
+300
View File
@@ -0,0 +1,300 @@
"""Support tickets routes for cabinet."""
import logging
import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.cabinet.routes.websocket import notify_admins_new_ticket, notify_admins_ticket_reply
from app.config import settings
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.database.models import Ticket, TicketMessage, User
from app.handlers.tickets import notify_admins_about_new_ticket, notify_admins_about_ticket_reply
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.tickets import (
TicketCreateRequest,
TicketDetailResponse,
TicketListResponse,
TicketMessageCreateRequest,
TicketMessageResponse,
TicketResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/tickets', tags=['Cabinet Tickets'])
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
"""Convert TicketMessage to response."""
return TicketMessageResponse(
id=message.id,
message_text=message.message_text or '',
is_from_admin=message.is_from_admin,
has_media=bool(message.media_file_id),
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
created_at=message.created_at,
)
def _ticket_to_response(ticket: Ticket, include_last_message: bool = True) -> TicketResponse:
"""Convert Ticket to response."""
last_message = None
messages_count = len(ticket.messages) if ticket.messages else 0
if include_last_message and ticket.messages:
last_msg = max(ticket.messages, key=lambda m: m.created_at)
last_message = _message_to_response(last_msg)
return TicketResponse(
id=ticket.id,
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
messages_count=messages_count,
last_message=last_message,
)
@router.get('', response_model=TicketListResponse)
async def get_tickets(
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
status_filter: str | None = Query(None, alias='status', description='Filter by status'),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's support tickets."""
# Check if tickets are enabled
if not settings.is_support_tickets_enabled():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Support tickets are disabled',
)
# Base query
query = select(Ticket).where(Ticket.user_id == user.id).options(selectinload(Ticket.messages))
# Filter by status
if status_filter:
query = query.where(Ticket.status == status_filter)
# Get total count
count_query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user.id)
if status_filter:
count_query = count_query.where(Ticket.status == status_filter)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate
offset = (page - 1) * per_page
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(per_page)
result = await db.execute(query)
tickets = result.scalars().all()
items = [_ticket_to_response(t) for t in tickets]
pages = math.ceil(total / per_page) if total > 0 else 1
return TicketListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.post('', response_model=TicketDetailResponse)
async def create_ticket(
request: TicketCreateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new support ticket."""
# Check if tickets are enabled
if not settings.is_support_tickets_enabled():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Support tickets are disabled',
)
# Create ticket
ticket = Ticket(
user_id=user.id,
title=request.title,
status='open',
priority='normal',
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(ticket)
await db.flush()
# Create initial message with optional media
message = TicketMessage(
ticket_id=ticket.id,
user_id=user.id,
message_text=request.message,
is_from_admin=False,
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
created_at=datetime.utcnow(),
)
db.add(message)
await db.commit()
# Refresh to get relationships
await db.refresh(ticket, ['messages'])
# Уведомить админов о новом тикете (Telegram)
try:
await notify_admins_about_new_ticket(ticket, db)
except Exception as e:
logger.error(f'Error notifying admins about new ticket from cabinet: {e}')
# Уведомить админов в кабинете
try:
notification = await TicketNotificationCRUD.create_admin_notification_for_new_ticket(db, ticket)
if notification:
# Отправить WebSocket уведомление
await notify_admins_new_ticket(ticket.id, ticket.title, user.id)
except Exception as e:
logger.error(f'Error creating cabinet notification for new ticket: {e}')
messages = [_message_to_response(m) for m in ticket.messages]
return TicketDetailResponse(
id=ticket.id,
title=ticket.title,
status=ticket.status,
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
messages=messages,
)
@router.get('/{ticket_id}', response_model=TicketDetailResponse)
async def get_ticket(
ticket_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket with all messages."""
query = (
select(Ticket).where(Ticket.id == ticket_id, Ticket.user_id == user.id).options(selectinload(Ticket.messages))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Ticket not found',
)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
return TicketDetailResponse(
id=ticket.id,
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
messages=messages_response,
)
@router.post('/{ticket_id}/messages', response_model=TicketMessageResponse)
async def add_ticket_message(
ticket_id: int,
request: TicketMessageCreateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Add message to existing ticket."""
# Get ticket
query = select(Ticket).where(Ticket.id == ticket_id, Ticket.user_id == user.id)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Ticket not found',
)
# Check if ticket is closed
if ticket.status == 'closed':
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot add message to closed ticket',
)
# Check if replies are blocked
if hasattr(ticket, 'is_reply_blocked') and ticket.is_reply_blocked:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Replies to this ticket are blocked',
)
# Create message with optional media
message = TicketMessage(
ticket_id=ticket.id,
user_id=user.id,
message_text=request.message,
is_from_admin=False,
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
created_at=datetime.utcnow(),
)
db.add(message)
# Update ticket status and timestamp
if ticket.status == 'answered':
ticket.status = 'pending'
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
# Уведомить админов об ответе пользователя (Telegram)
try:
await notify_admins_about_ticket_reply(ticket, request.message, db)
except Exception as e:
logger.error(f'Error notifying admins about ticket reply from cabinet: {e}')
# Уведомить админов в кабинете
try:
notification = await TicketNotificationCRUD.create_admin_notification_for_user_reply(
db, ticket, request.message
)
if notification:
# Отправить WebSocket уведомление
await notify_admins_ticket_reply(ticket.id, (request.message or '')[:100], user.id)
except Exception as e:
logger.error(f'Error creating cabinet notification for user reply: {e}')
return _message_to_response(message)
+589
View File
@@ -0,0 +1,589 @@
"""WebSocket endpoint for cabinet real-time notifications."""
from __future__ import annotations
import asyncio
import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.cabinet.auth.jwt_handler import get_token_payload
from app.config import settings
from app.database.crud.user import get_user_by_id
from app.database.database import AsyncSessionLocal
logger = logging.getLogger(__name__)
router = APIRouter()
class CabinetConnectionManager:
"""Менеджер WebSocket подключений для кабинета."""
def __init__(self):
# user_id -> set of websocket connections
self._user_connections: dict[int, set[WebSocket]] = {}
# admin user_ids -> set of websocket connections
self._admin_connections: dict[int, set[WebSocket]] = {}
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, user_id: int, is_admin: bool) -> None:
"""Зарегистрировать подключение."""
async with self._lock:
if user_id not in self._user_connections:
self._user_connections[user_id] = set()
self._user_connections[user_id].add(websocket)
if is_admin:
if user_id not in self._admin_connections:
self._admin_connections[user_id] = set()
self._admin_connections[user_id].add(websocket)
logger.debug(
'Cabinet WS connected: user_id=%d, is_admin=%s, total_users=%d',
user_id,
is_admin,
len(self._user_connections),
)
async def disconnect(self, websocket: WebSocket, user_id: int) -> None:
"""Отменить регистрацию подключения."""
async with self._lock:
if user_id in self._user_connections:
self._user_connections[user_id].discard(websocket)
if not self._user_connections[user_id]:
del self._user_connections[user_id]
if user_id in self._admin_connections:
self._admin_connections[user_id].discard(websocket)
if not self._admin_connections[user_id]:
del self._admin_connections[user_id]
logger.debug('Cabinet WS disconnected: user_id=%d', user_id)
async def send_to_user(self, user_id: int, message: dict) -> None:
"""Отправить сообщение конкретному пользователю."""
# Snapshot connections under the lock to avoid mutation during iteration
async with self._lock:
connections = list(self._user_connections.get(user_id, set()))
if not connections:
return
disconnected = set()
data = json.dumps(message, default=str, ensure_ascii=False)
for ws in connections:
try:
await ws.send_text(data)
except Exception as e:
logger.warning('Failed to send to user %d: %s', user_id, e)
disconnected.add(ws)
# Cleanup disconnected
if disconnected:
async with self._lock:
for ws in disconnected:
self._user_connections.get(user_id, set()).discard(ws)
async def send_to_admins(self, message: dict) -> None:
"""Отправить сообщение всем админам."""
# Snapshot connections under the lock to avoid mutation during iteration
async with self._lock:
if not self._admin_connections:
return
# Create a snapshot: list of (user_id, list of websockets)
admin_snapshot = [(user_id, list(connections)) for user_id, connections in self._admin_connections.items()]
data = json.dumps(message, default=str, ensure_ascii=False)
disconnected_by_user: dict[int, set[WebSocket]] = {}
for user_id, connections in admin_snapshot:
for ws in connections:
try:
await ws.send_text(data)
except Exception as e:
logger.warning('Failed to send to admin %d: %s', user_id, e)
if user_id not in disconnected_by_user:
disconnected_by_user[user_id] = set()
disconnected_by_user[user_id].add(ws)
# Cleanup disconnected
if disconnected_by_user:
async with self._lock:
for user_id, ws_set in disconnected_by_user.items():
for ws in ws_set:
self._admin_connections.get(user_id, set()).discard(ws)
# Глобальный менеджер подключений
cabinet_ws_manager = CabinetConnectionManager()
async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]:
"""
Проверить JWT токен для WebSocket.
Returns:
tuple[user_id, is_admin] или (None, False) если токен невалидный
"""
if not token:
return None, False
payload = get_token_payload(token, expected_type='access')
if not payload:
return None, False
try:
user_id = int(payload.get('sub'))
except (TypeError, ValueError):
return None, False
try:
async with AsyncSessionLocal() as db:
user = await get_user_by_id(db, user_id)
if not user or user.status != 'active':
return None, False
is_admin = settings.is_admin(
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
)
return user_id, is_admin
except (TimeoutError, OSError, ConnectionRefusedError) as e:
logger.error('Database connection error in WS token verification: %s', str(e)[:200])
return None, False
@router.websocket('/ws')
async def cabinet_websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint для real-time уведомлений кабинета."""
client_host = websocket.client.host if websocket.client else 'unknown'
# Получаем токен из query params
token = websocket.query_params.get('token')
if not token:
logger.debug('Cabinet WS: No token from %s', client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason='Unauthorized: No token')
return
# Верифицируем токен
user_id, is_admin = await verify_cabinet_ws_token(token)
if not user_id:
logger.debug('Cabinet WS: Invalid token from %s', client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason='Unauthorized: Invalid token')
return
# Принимаем соединение
try:
await websocket.accept()
logger.debug('Cabinet WS accepted: user_id=%d, is_admin=%s', user_id, is_admin)
except Exception as e:
logger.error('Cabinet WS: Failed to accept from %s: %s', client_host, e)
return
# Регистрируем подключение
await cabinet_ws_manager.connect(websocket, user_id, is_admin)
try:
# Приветственное сообщение
await websocket.send_json(
{
'type': 'connected',
'user_id': user_id,
'is_admin': is_admin,
}
)
# Обрабатываем входящие сообщения
while True:
try:
data = await websocket.receive_text()
message = json.loads(data)
# Ping/pong для keepalive
if message.get('type') == 'ping':
await websocket.send_json({'type': 'pong'})
except json.JSONDecodeError:
logger.warning('Cabinet WS: Invalid JSON from user %d', user_id)
except WebSocketDisconnect:
break
except Exception as e:
logger.exception('Cabinet WS error for user %d: %s', user_id, e)
break
except WebSocketDisconnect:
logger.debug('Cabinet WS disconnected: user_id=%d', user_id)
except Exception as e:
logger.exception('Cabinet WS error: %s', e)
finally:
await cabinet_ws_manager.disconnect(websocket, user_id)
# Функции для отправки уведомлений (используются из других модулей)
async def notify_user_ticket_reply(user_id: int, ticket_id: int, message: str) -> None:
"""Уведомить пользователя об ответе в тикете."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'ticket.admin_reply',
'ticket_id': ticket_id,
'message': message,
},
)
async def notify_admins_new_ticket(ticket_id: int, title: str, user_id: int) -> None:
"""Уведомить админов о новом тикете."""
await cabinet_ws_manager.send_to_admins(
{
'type': 'ticket.new',
'ticket_id': ticket_id,
'title': title,
'user_id': user_id,
}
)
async def notify_admins_ticket_reply(ticket_id: int, message: str, user_id: int) -> None:
"""Уведомить админов об ответе пользователя."""
await cabinet_ws_manager.send_to_admins(
{
'type': 'ticket.user_reply',
'ticket_id': ticket_id,
'message': message,
'user_id': user_id,
}
)
# ============================================================================
# Уведомления о балансе
# ============================================================================
async def notify_user_balance_topup(
user_id: int,
amount_kopeks: int,
new_balance_kopeks: int,
description: str = '',
) -> None:
"""Уведомить пользователя о пополнении баланса."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'balance.topup',
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
'new_balance_kopeks': new_balance_kopeks,
'new_balance_rubles': new_balance_kopeks / 100,
'description': description,
},
)
async def notify_user_balance_change(
user_id: int,
amount_kopeks: int,
new_balance_kopeks: int,
description: str = '',
) -> None:
"""Уведомить пользователя об изменении баланса."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'balance.change',
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
'new_balance_kopeks': new_balance_kopeks,
'new_balance_rubles': new_balance_kopeks / 100,
'description': description,
},
)
# ============================================================================
# Уведомления о подписке
# ============================================================================
async def notify_user_subscription_activated(
user_id: int,
expires_at: str,
tariff_name: str = '',
) -> None:
"""Уведомить пользователя об активации подписки."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.activated',
'expires_at': expires_at,
'tariff_name': tariff_name,
},
)
async def notify_user_subscription_expiring(
user_id: int,
days_left: int,
expires_at: str,
) -> None:
"""Уведомить пользователя о скором истечении подписки."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.expiring',
'days_left': days_left,
'expires_at': expires_at,
},
)
async def notify_user_subscription_expired(user_id: int) -> None:
"""Уведомить пользователя об истечении подписки."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.expired',
},
)
async def notify_user_subscription_renewed(
user_id: int,
new_expires_at: str,
amount_kopeks: int = 0,
) -> None:
"""Уведомить пользователя о продлении подписки."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.renewed',
'new_expires_at': new_expires_at,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
},
)
async def notify_user_devices_purchased(
user_id: int,
devices_added: int,
new_device_limit: int,
amount_kopeks: int,
) -> None:
"""Уведомить пользователя о покупке устройств."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.devices_purchased',
'devices_added': devices_added,
'new_device_limit': new_device_limit,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
},
)
async def notify_user_traffic_purchased(
user_id: int,
traffic_gb_added: int,
new_traffic_limit_gb: int,
amount_kopeks: int,
) -> None:
"""Уведомить пользователя о покупке трафика."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.traffic_purchased',
'traffic_gb_added': traffic_gb_added,
'new_traffic_limit_gb': new_traffic_limit_gb,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
},
)
# ============================================================================
# Уведомления об автопродлении
# ============================================================================
async def notify_user_autopay_success(
user_id: int,
amount_kopeks: int,
new_expires_at: str,
) -> None:
"""Уведомить пользователя об успешном автопродлении."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'autopay.success',
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
'new_expires_at': new_expires_at,
},
)
async def notify_user_autopay_failed(
user_id: int,
reason: str = '',
) -> None:
"""Уведомить пользователя о неудачном автопродлении."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'autopay.failed',
'reason': reason,
},
)
async def notify_user_autopay_insufficient_funds(
user_id: int,
required_kopeks: int,
balance_kopeks: int,
) -> None:
"""Уведомить о недостатке средств для автопродления."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'autopay.insufficient_funds',
'required_kopeks': required_kopeks,
'required_rubles': required_kopeks / 100,
'balance_kopeks': balance_kopeks,
'balance_rubles': balance_kopeks / 100,
},
)
# ============================================================================
# Уведомления о бане/разбане
# ============================================================================
async def notify_user_ban(user_id: int, reason: str = '') -> None:
"""Уведомить пользователя о блокировке."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'account.banned',
'reason': reason,
},
)
async def notify_user_unban(user_id: int) -> None:
"""Уведомить пользователя о разблокировке."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'account.unbanned',
},
)
async def notify_user_warning(user_id: int, message: str) -> None:
"""Уведомить пользователя о предупреждении."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'account.warning',
'message': message,
},
)
# ============================================================================
# Уведомления о рефералах
# ============================================================================
async def notify_user_referral_bonus(
user_id: int,
bonus_kopeks: int,
referral_name: str = '',
) -> None:
"""Уведомить пользователя о реферальном бонусе."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'referral.bonus',
'bonus_kopeks': bonus_kopeks,
'bonus_rubles': bonus_kopeks / 100,
'referral_name': referral_name,
},
)
async def notify_user_referral_registered(
user_id: int,
referral_name: str = '',
) -> None:
"""Уведомить пользователя о регистрации нового реферала."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'referral.registered',
'referral_name': referral_name,
},
)
# ============================================================================
# Прочие уведомления
# ============================================================================
async def notify_user_daily_debit(
user_id: int,
amount_kopeks: int,
new_balance_kopeks: int,
) -> None:
"""Уведомить о ежедневном списании."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.daily_debit',
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
'new_balance_kopeks': new_balance_kopeks,
'new_balance_rubles': new_balance_kopeks / 100,
},
)
async def notify_user_traffic_reset(user_id: int) -> None:
"""Уведомить о сбросе трафика."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.traffic_reset',
},
)
async def notify_user_payment_received(
user_id: int,
amount_kopeks: int,
payment_method: str = '',
) -> None:
"""Уведомить о полученном платеже."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'payment.received',
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
'payment_method': payment_method,
},
)
+275
View File
@@ -0,0 +1,275 @@
"""
API роуты колеса удачи для пользователей.
"""
import logging
import math
import time
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.cabinet.dependencies import get_cabinet_db, get_current_cabinet_user
from app.cabinet.schemas.wheel import (
SpinAvailabilityResponse,
SpinHistoryItem,
SpinHistoryResponse,
SpinRequest,
SpinResultResponse,
WheelConfigResponse,
WheelPrizeDisplay,
)
from app.config import settings
from app.database.crud.wheel import (
get_or_create_wheel_config,
get_user_spin_history,
get_user_spins_today,
get_wheel_prizes,
)
from app.database.models import User
from app.services.wheel_service import wheel_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/wheel', tags=['Fortune Wheel'])
@router.get('/config', response_model=WheelConfigResponse)
async def get_wheel_config(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить конфигурацию колеса удачи."""
config = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=True)
spins_today = await get_user_spins_today(db, user.id)
# Проверяем доступность
availability = await wheel_service.check_availability(db, user)
prizes_display = [
WheelPrizeDisplay(
id=p.id,
display_name=p.display_name,
emoji=p.emoji,
color=p.color,
prize_type=p.prize_type,
)
for p in prizes
]
return WheelConfigResponse(
is_enabled=config.is_enabled,
name=config.name,
spin_cost_stars=config.spin_cost_stars if config.spin_cost_stars_enabled else None,
spin_cost_days=config.spin_cost_days if config.spin_cost_days_enabled else None,
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
spin_cost_days_enabled=config.spin_cost_days_enabled,
prizes=prizes_display,
daily_limit=config.daily_spin_limit,
user_spins_today=spins_today,
can_spin=availability.can_spin,
can_spin_reason=availability.reason,
can_pay_stars=availability.can_pay_stars,
can_pay_days=availability.can_pay_days,
user_balance_kopeks=availability.user_balance_kopeks,
required_balance_kopeks=availability.required_balance_kopeks,
)
@router.get('/availability', response_model=SpinAvailabilityResponse)
async def check_spin_availability(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Проверить доступность спина."""
availability = await wheel_service.check_availability(db, user)
return SpinAvailabilityResponse(
can_spin=availability.can_spin,
reason=availability.reason,
spins_remaining_today=availability.spins_remaining_today,
can_pay_stars=availability.can_pay_stars,
can_pay_days=availability.can_pay_days,
min_subscription_days=availability.min_subscription_days,
user_subscription_days=availability.user_subscription_days,
user_balance_kopeks=availability.user_balance_kopeks,
required_balance_kopeks=availability.required_balance_kopeks,
)
@router.post('/spin', response_model=SpinResultResponse)
async def spin_wheel(
request: SpinRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Крутить колесо удачи."""
result = await wheel_service.spin(db, user, request.payment_type.value)
if not result.success:
# Возвращаем ошибку в теле ответа, а не HTTP exception
return SpinResultResponse(
success=False,
error=result.error,
message=result.message,
)
return SpinResultResponse(
success=True,
prize_id=result.prize_id,
prize_type=result.prize_type,
prize_value=result.prize_value,
prize_display_name=result.prize_display_name,
emoji=result.emoji,
color=result.color,
rotation_degrees=result.rotation_degrees,
message=result.message,
promocode=result.promocode,
)
@router.get('/history', response_model=SpinHistoryResponse)
async def get_spin_history(
page: int = 1,
per_page: int = 20,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить историю спинов пользователя."""
page = max(page, 1)
if per_page < 1 or per_page > 100:
per_page = 20
offset = (page - 1) * per_page
spins, total = await get_user_spin_history(db, user.id, limit=per_page, offset=offset)
items = []
for spin in spins:
# Получаем emoji и color из приза, если он есть
emoji = '🎁'
color = '#3B82F6'
if spin.prize:
emoji = spin.prize.emoji
color = spin.prize.color
items.append(
SpinHistoryItem(
id=spin.id,
payment_type=spin.payment_type,
payment_amount=spin.payment_amount,
prize_type=spin.prize_type,
prize_value=spin.prize_value,
prize_display_name=spin.prize_display_name,
emoji=emoji,
color=color,
prize_value_kopeks=spin.prize_value_kopeks,
created_at=spin.created_at,
)
)
pages = math.ceil(total / per_page) if total > 0 else 1
return SpinHistoryResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
class StarsInvoiceResponse(BaseModel):
"""Ответ с ссылкой на Stars invoice."""
invoice_url: str
stars_amount: int
@router.post('/stars-invoice', response_model=StarsInvoiceResponse)
async def create_stars_invoice(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Создать Telegram Stars invoice для оплаты спина колеса.
Используется в Telegram Mini App для прямой оплаты Stars.
"""
config = await get_or_create_wheel_config(db)
if not config.is_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Колесо удачи недоступно',
)
if not config.spin_cost_stars_enabled or not config.spin_cost_stars:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Оплата Stars не включена',
)
# Проверяем лимит спинов
spins_today = await get_user_spins_today(db, user.id)
if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Достигнут дневной лимит спинов',
)
# Проверяем наличие призов
prizes = await get_wheel_prizes(db, config.id, active_only=True)
if not prizes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Призы не настроены',
)
stars_amount = config.spin_cost_stars
payload = f'wheel_spin_{user.id}_{int(time.time())}'
# Создаем invoice через Telegram Bot API
try:
bot_token = settings.BOT_TOKEN
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
async with httpx.AsyncClient() as client:
response = await client.post(
api_url,
json={
'title': 'Колесо удачи',
'description': f'Спин колеса удачи ({stars_amount} ⭐)',
'payload': payload,
'provider_token': '', # Пустой для Stars
'currency': 'XTR',
'prices': [{'label': 'Спин колеса', 'amount': stars_amount}],
},
)
result = response.json()
if not result.get('ok'):
logger.error(f'Telegram API error: {result}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Ошибка создания инвойса',
)
invoice_url = result['result']
logger.info(f'Created Stars invoice for wheel spin: user={user.id}, stars={stars_amount}')
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
)
except httpx.HTTPError as e:
logger.error(f'HTTP error creating invoice: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Ошибка соединения с Telegram',
)
+89
View File
@@ -0,0 +1,89 @@
"""Cabinet Pydantic schemas."""
from .auth import (
AuthResponse,
EmailLoginRequest,
EmailRegisterRequest,
EmailVerifyRequest,
PasswordForgotRequest,
PasswordResetRequest,
RefreshTokenRequest,
TelegramAuthRequest,
TelegramWidgetAuthRequest,
TokenResponse,
UserResponse,
)
from .balance import (
BalanceResponse,
PaymentMethodResponse,
TopUpRequest,
TopUpResponse,
TransactionListResponse,
TransactionResponse,
)
from .referral import (
ReferralEarningResponse,
ReferralInfoResponse,
ReferralListResponse,
ReferralTermsResponse,
)
from .subscription import (
AutopayUpdateRequest,
DevicePurchaseRequest,
RenewalOptionResponse,
RenewalRequest,
SubscriptionResponse,
TrafficPackageResponse,
TrafficPurchaseInfo,
TrafficPurchaseRequest,
)
from .tickets import (
TicketCreateRequest,
TicketListResponse,
TicketMessageCreateRequest,
TicketMessageResponse,
TicketResponse,
)
__all__ = [
'AuthResponse',
'AutopayUpdateRequest',
# Balance
'BalanceResponse',
'DevicePurchaseRequest',
'EmailLoginRequest',
'EmailRegisterRequest',
'EmailVerifyRequest',
'PasswordForgotRequest',
'PasswordResetRequest',
'PaymentMethodResponse',
'ReferralEarningResponse',
# Referral
'ReferralInfoResponse',
'ReferralListResponse',
'ReferralTermsResponse',
'RefreshTokenRequest',
'RenewalOptionResponse',
'RenewalRequest',
# Subscription
'SubscriptionResponse',
# Auth
'TelegramAuthRequest',
'TelegramWidgetAuthRequest',
'TicketCreateRequest',
'TicketListResponse',
'TicketMessageCreateRequest',
'TicketMessageResponse',
# Tickets
'TicketResponse',
'TokenResponse',
'TopUpRequest',
'TopUpResponse',
'TrafficPackageResponse',
'TrafficPurchaseInfo',
'TrafficPurchaseRequest',
'TransactionListResponse',
'TransactionResponse',
'UserResponse',
]
+140
View File
@@ -0,0 +1,140 @@
"""Authentication schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
class TelegramAuthRequest(BaseModel):
"""Request for Telegram WebApp initData authentication."""
init_data: str = Field(..., description='Telegram WebApp initData string')
class TelegramWidgetAuthRequest(BaseModel):
"""Request for Telegram Login Widget authentication."""
id: int = Field(..., description='Telegram user ID')
first_name: str = Field(..., description="User's first name")
last_name: str | None = Field(None, description="User's last name")
username: str | None = Field(None, description="User's username")
photo_url: str | None = Field(None, description="User's photo URL")
auth_date: int = Field(..., description='Unix timestamp of authentication')
hash: str = Field(..., description='Authentication hash')
class EmailRegisterRequest(BaseModel):
"""Request to register/link email to existing Telegram account."""
email: EmailStr = Field(..., description='Email address')
password: str = Field(..., min_length=8, max_length=128, description='Password (min 8 chars)')
class EmailVerifyRequest(BaseModel):
"""Request to verify email with token."""
token: str = Field(..., description='Email verification token')
class EmailLoginRequest(BaseModel):
"""Request to login with email and password."""
email: EmailStr = Field(..., description='Email address')
password: str = Field(..., description='Password')
class RefreshTokenRequest(BaseModel):
"""Request to refresh access token."""
refresh_token: str = Field(..., description='Refresh token')
class PasswordForgotRequest(BaseModel):
"""Request to initiate password reset."""
email: EmailStr = Field(..., description='Email address')
class PasswordResetRequest(BaseModel):
"""Request to reset password with token."""
token: str = Field(..., description='Password reset token')
password: str = Field(..., min_length=8, max_length=128, description='New password (min 8 chars)')
class TokenResponse(BaseModel):
"""Token pair response."""
access_token: str
refresh_token: str
token_type: str = 'bearer'
expires_in: int = Field(..., description='Access token expiration in seconds')
class UserResponse(BaseModel):
"""User data response."""
id: int
telegram_id: int | None = None # Nullable для email-only пользователей
username: str | None = None
first_name: str | None = None
last_name: str | None = None
email: str | None = None
email_verified: bool = False
balance_kopeks: int = 0
balance_rubles: float = 0.0
referral_code: str | None = None
language: str = 'ru'
created_at: datetime
auth_type: str = 'telegram' # "telegram" или "email"
class Config:
from_attributes = True
class EmailRegisterStandaloneRequest(BaseModel):
"""Request to register new account with email (no Telegram required)."""
email: EmailStr = Field(..., description='Email address')
password: str = Field(..., min_length=8, max_length=128, description='Password (min 8 chars)')
first_name: str | None = Field(None, max_length=64, description='First name')
language: str = Field('ru', description='Preferred language')
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
class AuthResponse(BaseModel):
"""Full authentication response with tokens and user."""
access_token: str
refresh_token: str
token_type: str = 'bearer'
expires_in: int
user: UserResponse
class RegisterResponse(BaseModel):
"""Response for email registration (before verification)."""
message: str = Field(..., description='Success message')
email: str = Field(..., description='Email address to verify')
requires_verification: bool = Field(True, description='Whether email verification is required')
class EmailChangeRequest(BaseModel):
"""Request to initiate email change."""
new_email: EmailStr = Field(..., description='New email address')
class EmailChangeVerifyRequest(BaseModel):
"""Request to verify email change with code."""
code: str = Field(..., min_length=6, max_length=6, description='6-digit verification code')
class EmailChangeResponse(BaseModel):
"""Response for email change initiation."""
message: str = Field(..., description='Success message')
new_email: str = Field(..., description='New email address pending verification')
expires_in_minutes: int = Field(..., description='Code expiration time in minutes')
+139
View File
@@ -0,0 +1,139 @@
"""Balance and payment schemas for cabinet."""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class BalanceResponse(BaseModel):
"""User balance data."""
balance_kopeks: int
balance_rubles: float
class TransactionResponse(BaseModel):
"""Transaction history item."""
id: int
type: str
amount_kopeks: int
amount_rubles: float
description: str | None = None
payment_method: str | None = None
is_completed: bool
created_at: datetime
completed_at: datetime | None = None
class Config:
from_attributes = True
class TransactionListResponse(BaseModel):
"""Paginated transaction list."""
items: list[TransactionResponse]
total: int
page: int
per_page: int
pages: int
class PaymentOptionResponse(BaseModel):
"""Payment method option (e.g. Platega sub-methods)."""
id: str
name: str
description: str | None = None
class PaymentMethodResponse(BaseModel):
"""Available payment method."""
id: str
name: str
description: str | None = None
min_amount_kopeks: int
max_amount_kopeks: int
is_available: bool = True
options: list[dict[str, Any]] | None = None
class TopUpRequest(BaseModel):
"""Request to create payment for balance top-up."""
amount_kopeks: int = Field(..., ge=1000, 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)')
class TopUpResponse(BaseModel):
"""Response with payment info."""
payment_id: str
payment_url: str
amount_kopeks: int
amount_rubles: float
status: str
expires_at: datetime | None = None
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)')
class StarsInvoiceResponse(BaseModel):
"""Response with Telegram Stars invoice link."""
invoice_url: str
stars_amount: int
amount_kopeks: int
class PendingPaymentResponse(BaseModel):
"""Pending payment details for manual verification."""
id: int
method: str
method_display: str
identifier: str
amount_kopeks: int
amount_rubles: float
status: str
status_emoji: str
status_text: str
is_paid: bool
is_checkable: bool
created_at: datetime
expires_at: datetime | None = None
payment_url: str | None = None
user_id: int | None = None
user_telegram_id: int | None = None
user_username: str | None = None
class Config:
from_attributes = True
class PendingPaymentListResponse(BaseModel):
"""Paginated list of pending payments."""
items: list[PendingPaymentResponse]
total: int
page: int
per_page: int
pages: int
class ManualCheckResponse(BaseModel):
"""Response after manual payment status check."""
success: bool
message: str
payment: PendingPaymentResponse | None = None
status_changed: bool = False
old_status: str | None = None
new_status: str | None = None
+386
View File
@@ -0,0 +1,386 @@
"""Schemas for Ban System integration in cabinet."""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
# === Status ===
class BanSystemStatusResponse(BaseModel):
"""Ban System integration status."""
enabled: bool
configured: bool
# === Stats ===
class BanSystemStatsResponse(BaseModel):
"""Overall Ban System statistics."""
total_users: int = 0
active_users: int = 0
users_over_limit: int = 0
total_requests: int = 0
total_punishments: int = 0
active_punishments: int = 0
nodes_online: int = 0
nodes_total: int = 0
agents_online: int = 0
agents_total: int = 0
panel_connected: bool = False
uptime_seconds: int | None = None
# === Users ===
class BanUserIPInfo(BaseModel):
"""User IP address information."""
ip: str
first_seen: datetime | None = None
last_seen: datetime | None = None
node: str | None = None
request_count: int = 0
country_code: str | None = None
country_name: str | None = None
city: str | None = None
class BanUserRequestLog(BaseModel):
"""User request log entry."""
timestamp: datetime
source_ip: str
destination: str | None = None
dest_port: int | None = None
protocol: str | None = None
action: str | None = None
node: str | None = None
class BanUserListItem(BaseModel):
"""User in the list."""
email: str
unique_ip_count: int = 0
total_requests: int = 0
limit: int | None = None
is_over_limit: bool = False
blocked_count: int = 0
last_seen: datetime | None = None
class BanUsersListResponse(BaseModel):
"""Paginated list of users."""
users: list[BanUserListItem] = []
total: int = 0
offset: int = 0
limit: int = 50
class BanUserDetailResponse(BaseModel):
"""Detailed user information."""
email: str
unique_ip_count: int = 0
total_requests: int = 0
limit: int | None = None
is_over_limit: bool = False
blocked_count: int = 0
ips: list[BanUserIPInfo] = []
recent_requests: list[BanUserRequestLog] = []
network_type: str | None = None # wifi, mobile, mixed
# === Punishments (Bans) ===
class BanPunishmentItem(BaseModel):
"""Punishment/ban entry."""
id: int | None = None
user_id: str
uuid: str | None = None
username: str
reason: str | None = None
punished_at: datetime
enable_at: datetime | None = None
ip_count: int = 0
limit: int = 0
enabled: bool = False
enabled_at: datetime | None = None
node_name: str | None = None
class BanPunishmentsListResponse(BaseModel):
"""List of active punishments."""
punishments: list[BanPunishmentItem] = []
total: int = 0
class BanHistoryResponse(BaseModel):
"""Punishment history."""
items: list[BanPunishmentItem] = []
total: int = 0
class BanUserRequest(BaseModel):
"""Request to ban a user."""
username: str = Field(..., min_length=1)
minutes: int = Field(default=30, ge=1)
reason: str | None = Field(None, max_length=500)
class UnbanResponse(BaseModel):
"""Unban response."""
success: bool
message: str
# === Nodes ===
class BanNodeItem(BaseModel):
"""Node information."""
name: str
address: str | None = None
is_connected: bool = False
last_seen: datetime | None = None
users_count: int = 0
agent_stats: dict[str, Any] | None = None
class BanNodesListResponse(BaseModel):
"""List of nodes."""
nodes: list[BanNodeItem] = []
total: int = 0
online: int = 0
# === Agents ===
class BanAgentItem(BaseModel):
"""Monitoring agent information."""
node_name: str
sent_total: int = 0
dropped_total: int = 0
batches_total: int = 0
reconnects: int = 0
failures: int = 0
queue_size: int = 0
queue_max: int = 0
dedup_checked: int = 0
dedup_skipped: int = 0
filter_checked: int = 0
filter_filtered: int = 0
health: str = 'unknown' # healthy, warning, critical
is_online: bool = False
last_report: datetime | None = None
class BanAgentsSummary(BaseModel):
"""Agents summary statistics."""
total_agents: int = 0
online_agents: int = 0
total_sent: int = 0
total_dropped: int = 0
avg_queue_size: float = 0.0
healthy_count: int = 0
warning_count: int = 0
critical_count: int = 0
class BanAgentsListResponse(BaseModel):
"""List of agents."""
agents: list[BanAgentItem] = []
summary: BanAgentsSummary | None = None
total: int = 0
online: int = 0
# === Traffic ===
class BanTrafficStats(BaseModel):
"""Traffic statistics."""
total_bytes: int = 0
upload_bytes: int = 0
download_bytes: int = 0
total_users: int = 0
violators_count: int = 0
class BanTrafficUserItem(BaseModel):
"""User traffic information."""
username: str
email: str | None = None
total_bytes: int = 0
upload_bytes: int = 0
download_bytes: int = 0
limit_bytes: int | None = None
is_over_limit: bool = False
class BanTrafficViolationItem(BaseModel):
"""Traffic limit violation entry."""
id: int | None = None
username: str
email: str | None = None
violation_type: str
description: str | None = None
bytes_used: int = 0
bytes_limit: int = 0
detected_at: datetime
resolved: bool = False
class BanTrafficViolationsResponse(BaseModel):
"""List of traffic violations."""
violations: list[BanTrafficViolationItem] = []
total: int = 0
class BanTrafficTopItem(BaseModel):
"""Top user by traffic."""
username: str
bytes_total: int = 0
bytes_limit: int | None = None
over_limit: bool = False
class BanTrafficResponse(BaseModel):
"""Full traffic statistics response."""
enabled: bool = False
stats: dict[str, Any] | None = None
top_users: list[BanTrafficTopItem] = []
recent_violations: list[BanTrafficViolationItem] = []
# === Settings ===
class BanSettingDefinition(BaseModel):
"""Setting definition with value."""
key: str
value: Any
type: str # bool, int, str, list
min_value: int | None = None
max_value: int | None = None
editable: bool = True
description: str | None = None
category: str | None = None
class BanSettingsResponse(BaseModel):
"""All settings response."""
settings: list[BanSettingDefinition] = []
class BanSettingUpdateRequest(BaseModel):
"""Request to update a setting."""
value: Any
class BanWhitelistRequest(BaseModel):
"""Request to add/remove from whitelist."""
username: str = Field(..., min_length=1)
# === Reports ===
class BanReportTopViolator(BaseModel):
"""Top violator in report."""
username: str
count: int = 0
class BanReportResponse(BaseModel):
"""Period report response."""
period_hours: int = 24
current_users: int = 0
current_ips: int = 0
punishment_stats: dict[str, Any] | None = None
top_violators: list[BanReportTopViolator] = []
# === Health ===
class BanHealthComponent(BaseModel):
"""Health component status."""
name: str
status: str # healthy, degraded, unhealthy
message: str | None = None
details: dict[str, Any] | None = None
class BanHealthResponse(BaseModel):
"""Health status response."""
status: str # healthy, degraded, unhealthy
uptime: int | None = None
components: list[BanHealthComponent] = []
class BanHealthDetailedResponse(BaseModel):
"""Detailed health response."""
status: str
uptime: int | None = None
components: dict[str, Any] = {}
# === Agent History ===
class BanAgentHistoryItem(BaseModel):
"""Agent history item."""
timestamp: datetime
sent_total: int = 0
dropped_total: int = 0
queue_size: int = 0
batches_total: int = 0
class BanAgentHistoryResponse(BaseModel):
"""Agent history response."""
node: str
hours: int = 24
records: int = 0
delta: dict[str, Any] | None = None
first: dict[str, Any] | None = None
last: dict[str, Any] | None = None
history: list[BanAgentHistoryItem] = []
+209
View File
@@ -0,0 +1,209 @@
"""Pydantic schemas for cabinet broadcasts."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
# ============ Channel Types ============
BroadcastChannel = Literal['telegram', 'email', 'both']
# ============ Filters ============
class BroadcastFilter(BaseModel):
"""Single broadcast filter."""
key: str
label: str
count: int | None = None
group: str | None = None # basic, subscription, traffic, registration, source, activity
class TariffFilter(BaseModel):
"""Tariff-based filter."""
key: str # tariff_1, tariff_2, ...
label: str # tariff name
tariff_id: int
count: int
class BroadcastFiltersResponse(BaseModel):
"""Response with all available filters."""
filters: list[BroadcastFilter] # basic filters
tariff_filters: list[TariffFilter] # tariff filters
custom_filters: list[BroadcastFilter] # custom filters
# ============ Tariffs ============
class TariffForBroadcast(BaseModel):
"""Tariff info for broadcast filtering."""
id: int
name: str
filter_key: str # tariff_{id}
active_users_count: int
class BroadcastTariffsResponse(BaseModel):
"""Response with tariffs for filtering."""
tariffs: list[TariffForBroadcast]
# ============ Buttons ============
class BroadcastButton(BaseModel):
"""Single broadcast button."""
key: str
label: str
default: bool = False
class BroadcastButtonsResponse(BaseModel):
"""Response with available buttons."""
buttons: list[BroadcastButton]
# ============ Media ============
class BroadcastMediaRequest(BaseModel):
"""Media attachment for broadcast."""
type: str = Field(..., pattern=r'^(photo|video|document)$')
file_id: str
caption: str | None = None
# ============ Create ============
class BroadcastCreateRequest(BaseModel):
"""Request to create a broadcast."""
target: str
message_text: str = Field(..., min_length=1, max_length=4000)
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
media: BroadcastMediaRequest | None = None
# ============ Response ============
class BroadcastResponse(BaseModel):
"""Broadcast response."""
id: int
target_type: str
message_text: str | None = None
has_media: bool
media_type: str | None = None
media_file_id: str | None = None
media_caption: str | None = None
total_count: int
sent_count: int
failed_count: int
status: str # queued|in_progress|completed|partial|failed|cancelled|cancelling
admin_id: int | None = None
admin_name: str | None = None
created_at: datetime
completed_at: datetime | None = None
progress_percent: float = 0.0
# Email/channel fields
channel: str = 'telegram' # telegram|email|both
email_subject: str | None = None
email_html_content: str | None = None
class Config:
from_attributes = True
class BroadcastListResponse(BaseModel):
"""Paginated list of broadcasts."""
items: list[BroadcastResponse]
total: int
limit: int
offset: int
# ============ Preview ============
class BroadcastPreviewRequest(BaseModel):
"""Request to preview broadcast recipients count."""
target: str
class BroadcastPreviewResponse(BaseModel):
"""Preview response with recipients count."""
target: str
count: int
# ============ Email Filters ============
class EmailFilterItem(BaseModel):
"""Single email filter with count."""
key: str
label: str
count: int
group: str | None = None
class EmailFiltersResponse(BaseModel):
"""Response with all email filters and their counts."""
filters: list[EmailFilterItem]
total_with_email: int
# ============ Combined Broadcast ============
class CombinedBroadcastCreateRequest(BaseModel):
"""Request to create a combined (telegram/email/both) broadcast."""
channel: BroadcastChannel
target: str
# Telegram-specific fields
message_text: str | None = Field(default=None, max_length=4000)
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
media: BroadcastMediaRequest | None = None
# Email-specific fields
email_subject: str | None = Field(default=None, max_length=255)
email_html_content: str | None = Field(default=None, max_length=100000)
# ============ Email Preview ============
class EmailPreviewRequest(BaseModel):
"""Request to preview email broadcast recipients."""
target: str
class EmailPreviewResponse(BaseModel):
"""Preview response for email broadcast."""
target: str
count: int
+203
View File
@@ -0,0 +1,203 @@
"""Schemas for advertising campaigns management in cabinet."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
CampaignBonusType = Literal['balance', 'subscription', 'none', 'tariff']
class TariffInfo(BaseModel):
"""Tariff info for campaign."""
id: int
name: str
class CampaignListItem(BaseModel):
"""Campaign item for list view."""
id: int
name: str
start_parameter: str
bonus_type: CampaignBonusType
is_active: bool
registrations_count: int
total_revenue_kopeks: int = 0
conversion_rate: float = 0.0
created_at: datetime
class Config:
from_attributes = True
class CampaignListResponse(BaseModel):
"""Response with list of campaigns."""
campaigns: list[CampaignListItem]
total: int
class CampaignDetailResponse(BaseModel):
"""Detailed campaign response."""
id: int
name: str
start_parameter: str
bonus_type: CampaignBonusType
is_active: bool
# Balance bonus
balance_bonus_kopeks: int = 0
balance_bonus_rubles: float = 0.0
# Subscription bonus
subscription_duration_days: int | None = None
subscription_traffic_gb: int | None = None
subscription_device_limit: int | None = None
subscription_squads: list[str] = Field(default_factory=list)
# Tariff bonus
tariff_id: int | None = None
tariff_duration_days: int | None = None
tariff: TariffInfo | None = None
# Meta
created_by: int | None = None
created_at: datetime
updated_at: datetime | None = None
# Deep link
deep_link: str | None = None
class Config:
from_attributes = True
class CampaignCreateRequest(BaseModel):
"""Request to create a campaign."""
name: str = Field(..., min_length=1, max_length=255)
start_parameter: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$')
bonus_type: CampaignBonusType
is_active: bool = True
# Balance bonus
balance_bonus_kopeks: int = Field(0, ge=0)
# Subscription bonus
subscription_duration_days: int | None = Field(None, ge=1)
subscription_traffic_gb: int | None = Field(None, ge=0)
subscription_device_limit: int | None = Field(None, ge=1)
subscription_squads: list[str] = Field(default_factory=list)
# Tariff bonus
tariff_id: int | None = None
tariff_duration_days: int | None = Field(None, ge=1)
class CampaignUpdateRequest(BaseModel):
"""Request to update a campaign."""
name: str | None = Field(None, min_length=1, max_length=255)
start_parameter: str | None = Field(None, min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$')
bonus_type: CampaignBonusType | None = None
is_active: bool | None = None
# Balance bonus
balance_bonus_kopeks: int | None = Field(None, ge=0)
# Subscription bonus
subscription_duration_days: int | None = Field(None, ge=1)
subscription_traffic_gb: int | None = Field(None, ge=0)
subscription_device_limit: int | None = Field(None, ge=1)
subscription_squads: list[str] | None = None
# Tariff bonus
tariff_id: int | None = None
tariff_duration_days: int | None = Field(None, ge=1)
class CampaignToggleResponse(BaseModel):
"""Response after toggling campaign."""
id: int
is_active: bool
message: str
class CampaignStatisticsResponse(BaseModel):
"""Detailed campaign statistics."""
id: int
name: str
start_parameter: str
bonus_type: CampaignBonusType
is_active: bool
# Registration stats
registrations: int = 0
balance_issued_kopeks: int = 0
balance_issued_rubles: float = 0.0
subscription_issued: int = 0
last_registration: datetime | None = None
# Revenue stats
total_revenue_kopeks: int = 0
total_revenue_rubles: float = 0.0
avg_revenue_per_user_kopeks: int = 0
avg_revenue_per_user_rubles: float = 0.0
avg_first_payment_kopeks: int = 0
avg_first_payment_rubles: float = 0.0
# Trial & Conversion stats
trial_users_count: int = 0
active_trials_count: int = 0
conversion_count: int = 0
paid_users_count: int = 0
conversion_rate: float = 0.0
trial_conversion_rate: float = 0.0
# Deep link
deep_link: str | None = None
class CampaignRegistrationItem(BaseModel):
"""Campaign registration item."""
id: int
user_id: int
telegram_id: int | None = None
username: str | None = None
first_name: str | None = None
bonus_type: str
balance_bonus_kopeks: int = 0
subscription_duration_days: int | None = None
tariff_id: int | None = None
tariff_duration_days: int | None = None
created_at: datetime
# User stats
user_balance_kopeks: int = 0
has_subscription: bool = False
has_paid: bool = False
class Config:
from_attributes = True
class CampaignRegistrationsResponse(BaseModel):
"""Response with campaign registrations."""
registrations: list[CampaignRegistrationItem]
total: int
page: int
per_page: int
class CampaignsOverviewResponse(BaseModel):
"""Overview of all campaigns."""
total: int
active: int
inactive: int
total_registrations: int
total_balance_issued_kopeks: int
total_balance_issued_rubles: float
total_subscription_issued: int
total_tariff_issued: int = 0
class ServerSquadInfo(BaseModel):
"""Server squad info for campaign selection."""
id: int
squad_uuid: str
display_name: str
country_code: str | None = None
+64
View File
@@ -0,0 +1,64 @@
"""Pydantic schemas for cabinet pinned messages."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class PinnedMessageMedia(BaseModel):
type: str = Field(pattern=r'^(photo|video)$')
file_id: str = Field(..., min_length=1, max_length=255)
class PinnedMessageCreateRequest(BaseModel):
content: str = Field(..., min_length=1, max_length=4000)
media: PinnedMessageMedia | None = None
send_before_menu: bool = True
send_on_every_start: bool = True
broadcast: bool = False
class PinnedMessageUpdateRequest(BaseModel):
content: str | None = Field(None, max_length=4000)
send_before_menu: bool | None = None
send_on_every_start: bool | None = None
media: PinnedMessageMedia | None = None
class PinnedMessageSettingsRequest(BaseModel):
send_before_menu: bool | None = None
send_on_every_start: bool | None = None
class PinnedMessageResponse(BaseModel):
id: int
content: str | None
media_type: str | None = None
media_file_id: str | None = None
send_before_menu: bool
send_on_every_start: bool
is_active: bool
created_by: int | None = None
created_at: datetime
updated_at: datetime | None = None
class PinnedMessageBroadcastResponse(BaseModel):
message: PinnedMessageResponse
sent_count: int
failed_count: int
class PinnedMessageUnpinResponse(BaseModel):
unpinned_count: int
failed_count: int
was_active: bool
class PinnedMessageListResponse(BaseModel):
items: list[PinnedMessageResponse]
total: int
limit: int
offset: int
+78
View File
@@ -0,0 +1,78 @@
"""Referral program schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel
class ReferralInfoResponse(BaseModel):
"""Referral program info for current user."""
referral_code: str
referral_link: str
total_referrals: int
active_referrals: int
total_earnings_kopeks: int
total_earnings_rubles: float
commission_percent: int
class ReferralItemResponse(BaseModel):
"""Single referral info."""
id: int
username: str | None = None
first_name: str | None = None
created_at: datetime
has_subscription: bool
has_paid: bool
class ReferralListResponse(BaseModel):
"""Paginated referral list."""
items: list[ReferralItemResponse]
total: int
page: int
per_page: int
pages: int
class ReferralEarningResponse(BaseModel):
"""Referral earning history item."""
id: int
amount_kopeks: int
amount_rubles: float
reason: str
referral_username: str | None = None
referral_first_name: str | None = None
created_at: datetime
class Config:
from_attributes = True
class ReferralEarningsListResponse(BaseModel):
"""Paginated referral earnings list."""
items: list[ReferralEarningResponse]
total: int
total_amount_kopeks: int
total_amount_rubles: float
page: int
per_page: int
pages: int
class ReferralTermsResponse(BaseModel):
"""Referral program terms."""
is_enabled: bool
commission_percent: int
minimum_topup_kopeks: int
minimum_topup_rubles: float
first_topup_bonus_kopeks: int
first_topup_bonus_rubles: float
inviter_bonus_kopeks: int
inviter_bonus_rubles: float
+396
View File
@@ -0,0 +1,396 @@
"""Schemas for RemnaWave management in cabinet admin panel."""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
# ============ Status & Connection ============
class ConnectionStatus(BaseModel):
"""RemnaWave API connection status."""
status: str
message: str
api_url: str | None = None
status_code: int | None = None
system_info: dict[str, Any] | None = None
class RemnaWaveStatusResponse(BaseModel):
"""RemnaWave configuration and connection status."""
is_configured: bool
configuration_error: str | None = None
connection: ConnectionStatus | None = None
# ============ System Statistics ============
class SystemSummary(BaseModel):
"""System summary statistics."""
users_online: int
total_users: int
active_connections: int
nodes_online: int
users_last_day: int
users_last_week: int
users_never_online: int
total_user_traffic: int
class ServerInfo(BaseModel):
"""Server hardware info."""
cpu_cores: int
cpu_physical_cores: int
memory_total: int
memory_used: int
memory_free: int
memory_available: int
uptime_seconds: int
class Bandwidth(BaseModel):
"""Realtime bandwidth statistics."""
realtime_download: int
realtime_upload: int
realtime_total: int
class TrafficPeriod(BaseModel):
"""Traffic statistics for a period."""
current: int
previous: int
difference: str | None = None
class TrafficPeriods(BaseModel):
"""Traffic statistics for multiple periods."""
last_2_days: TrafficPeriod
last_7_days: TrafficPeriod
last_30_days: TrafficPeriod
current_month: TrafficPeriod
current_year: TrafficPeriod
class SystemStatsResponse(BaseModel):
"""Full system statistics response."""
system: SystemSummary
users_by_status: dict[str, int]
server_info: ServerInfo
bandwidth: Bandwidth
traffic_periods: TrafficPeriods
nodes_realtime: list[dict[str, Any]] = Field(default_factory=list)
nodes_weekly: list[dict[str, Any]] = Field(default_factory=list)
last_updated: datetime | None = None
# ============ Nodes ============
class NodeInfo(BaseModel):
"""Node information."""
uuid: str
name: str
address: str
country_code: str | None = None
is_connected: bool
is_disabled: bool
is_node_online: bool
is_xray_running: bool
users_online: int | None = None
traffic_used_bytes: int | None = None
traffic_limit_bytes: int | None = None
last_status_change: datetime | None = None
last_status_message: str | None = None
xray_uptime: str | None = None
is_traffic_tracking_active: bool = False
traffic_reset_day: int | None = None
notify_percent: int | None = None
consumption_multiplier: float = 1.0
cpu_count: int | None = None
cpu_model: str | None = None
total_ram: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
provider_uuid: str | None = None
class NodesListResponse(BaseModel):
"""List of nodes response."""
items: list[NodeInfo]
total: int
class NodesOverview(BaseModel):
"""Nodes overview statistics."""
total: int
online: int
offline: int
disabled: int
total_users_online: int
nodes: list[NodeInfo]
class NodeStatisticsResponse(BaseModel):
"""Node statistics with usage history."""
node: NodeInfo
realtime: dict[str, Any] | None = None
usage_history: list[dict[str, Any]] = Field(default_factory=list)
last_updated: datetime | None = None
class NodeUsageResponse(BaseModel):
"""Node usage history response."""
items: list[dict[str, Any]] = Field(default_factory=list)
class NodeActionRequest(BaseModel):
"""Request to perform node action."""
action: Literal['enable', 'disable', 'restart']
class NodeActionResponse(BaseModel):
"""Response after node action."""
success: bool
message: str | None = None
is_disabled: bool | None = None
# ============ Squads (Internal Squads) ============
class SquadInfo(BaseModel):
"""Internal Squad information from RemnaWave."""
uuid: str
name: str
members_count: int
inbounds_count: int
inbounds: list[dict[str, Any]] = Field(default_factory=list)
class SquadWithLocalInfo(BaseModel):
"""Squad with local database info."""
uuid: str
name: str
members_count: int
inbounds_count: int
inbounds: list[dict[str, Any]] = Field(default_factory=list)
# Local DB info
local_id: int | None = None
display_name: str | None = None
country_code: str | None = None
is_available: bool | None = None
is_trial_eligible: bool | None = None
price_kopeks: int | None = None
max_users: int | None = None
current_users: int | None = None
is_synced: bool = False
class SquadsListResponse(BaseModel):
"""List of squads response."""
items: list[SquadWithLocalInfo]
total: int
class SquadDetailResponse(BaseModel):
"""Detailed squad response."""
uuid: str
name: str
members_count: int
inbounds_count: int
inbounds: list[dict[str, Any]] = Field(default_factory=list)
# Local DB info if synced
local_id: int | None = None
display_name: str | None = None
country_code: str | None = None
description: str | None = None
is_available: bool | None = None
is_trial_eligible: bool | None = None
price_kopeks: int | None = None
max_users: int | None = None
current_users: int | None = None
sort_order: int | None = None
is_synced: bool = False
active_subscriptions: int = 0
class SquadCreateRequest(BaseModel):
"""Request to create a new squad."""
name: str = Field(..., min_length=1, max_length=255)
inbound_uuids: list[str] = Field(default_factory=list)
class SquadUpdateRequest(BaseModel):
"""Request to update a squad."""
name: str | None = Field(None, min_length=1, max_length=255)
inbound_uuids: list[str] | None = None
class SquadActionRequest(BaseModel):
"""Request to perform squad action."""
action: Literal['add_all_users', 'remove_all_users', 'delete', 'rename', 'update_inbounds']
name: str | None = None
inbound_uuids: list[str] | None = None
class SquadOperationResponse(BaseModel):
"""Response after squad operation."""
success: bool
message: str | None = None
data: dict[str, Any] | None = None
# ============ Migration ============
class MigrationPreviewResponse(BaseModel):
"""Preview of squad migration."""
squad_uuid: str
squad_name: str
current_users: int
max_users: int | None = None
users_to_migrate: int
class MigrationRequest(BaseModel):
"""Request to migrate users between squads."""
source_uuid: str
target_uuid: str
class MigrationStats(BaseModel):
"""Migration statistics."""
source_uuid: str
target_uuid: str
total: int = 0
updated: int = 0
panel_updated: int = 0
panel_failed: int = 0
source_removed: int = 0
target_added: int = 0
class MigrationResponse(BaseModel):
"""Response after migration."""
success: bool
message: str | None = None
error: str | None = None
data: MigrationStats | None = None
# ============ Inbounds ============
class InboundInfo(BaseModel):
"""Inbound information."""
uuid: str
tag: str
type: str | None = None
network: str | None = None
security: str | None = None
class InboundsListResponse(BaseModel):
"""List of inbounds response."""
items: list[dict[str, Any]] = Field(default_factory=list)
total: int = 0
# ============ Auto Sync ============
class AutoSyncTime(BaseModel):
"""Scheduled sync time."""
hour: int
minute: int
class AutoSyncStatus(BaseModel):
"""Auto sync status."""
enabled: bool
times: list[str] = Field(default_factory=list) # HH:MM format
next_run: datetime | None = None
is_running: bool = False
last_run_started_at: datetime | None = None
last_run_finished_at: datetime | None = None
last_run_success: bool | None = None
last_run_reason: str | None = None
last_run_error: str | None = None
last_user_stats: dict[str, Any] | None = None
last_server_stats: dict[str, Any] | None = None
class AutoSyncToggleRequest(BaseModel):
"""Request to toggle auto sync."""
enabled: bool
class AutoSyncRunResponse(BaseModel):
"""Response after running sync."""
started: bool
success: bool | None = None
error: str | None = None
user_stats: dict[str, Any] | None = None
server_stats: dict[str, Any] | None = None
reason: str | None = None
# ============ Manual Sync ============
class SyncMode(BaseModel):
"""Sync mode options."""
mode: Literal['all', 'new_only', 'update_only'] = 'all'
class SyncResponse(BaseModel):
"""Response after sync operation."""
success: bool
message: str | None = None
data: dict[str, Any] | None = None
class SyncRecommendations(BaseModel):
"""Sync recommendations."""
success: bool
message: str | None = None
data: dict[str, Any] | None = None
+129
View File
@@ -0,0 +1,129 @@
"""Schemas for server management in cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
class PromoGroupInfo(BaseModel):
"""Promo group info for server."""
id: int
name: str
is_selected: bool = False
class ServerListItem(BaseModel):
"""Server item for list view."""
id: int
squad_uuid: str
display_name: str
original_name: str | None = None
country_code: str | None = None
is_available: bool
is_trial_eligible: bool
price_kopeks: int
price_rubles: float
max_users: int | None = None
current_users: int
sort_order: int
is_full: bool
availability_status: str
created_at: datetime
class Config:
from_attributes = True
class ServerListResponse(BaseModel):
"""Response with list of servers."""
servers: list[ServerListItem]
total: int
class ServerDetailResponse(BaseModel):
"""Detailed server response."""
id: int
squad_uuid: str
display_name: str
original_name: str | None = None
country_code: str | None = None
description: str | None = None
is_available: bool
is_trial_eligible: bool
price_kopeks: int
price_rubles: float
max_users: int | None = None
current_users: int
sort_order: int
is_full: bool
availability_status: str
promo_groups: list[PromoGroupInfo]
active_subscriptions: int
tariffs_using: list[str] # Names of tariffs using this server
created_at: datetime
updated_at: datetime | None = None
class Config:
from_attributes = True
class ServerUpdateRequest(BaseModel):
"""Request to update a server."""
display_name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = None
country_code: str | None = Field(None, max_length=5)
is_available: bool | None = None
is_trial_eligible: bool | None = None
price_kopeks: int | None = Field(None, ge=0)
max_users: int | None = Field(None, ge=0)
sort_order: int | None = Field(None, ge=0)
promo_group_ids: list[int] | None = None
class ServerToggleResponse(BaseModel):
"""Response after toggling server."""
id: int
is_available: bool
message: str
class ServerTrialToggleResponse(BaseModel):
"""Response after toggling trial eligibility."""
id: int
is_trial_eligible: bool
message: str
class ServerStatsResponse(BaseModel):
"""Server statistics."""
id: int
display_name: str
squad_uuid: str
current_users: int
max_users: int | None
active_subscriptions: int
trial_subscriptions: int
usage_percent: float | None = None
class ServerSyncResponse(BaseModel):
"""Response after syncing with RemnaWave."""
created: int
updated: int
removed: int
message: str
class ServerSyncRequest(BaseModel):
"""Request to sync servers."""
force: bool = False # Force sync even if recently synced
+159
View File
@@ -0,0 +1,159 @@
"""Subscription schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
class ServerInfo(BaseModel):
"""Server info for display."""
uuid: str
name: str
country_code: str | None = None
class TrafficPurchaseInfo(BaseModel):
"""Purchased traffic package info."""
id: int
traffic_gb: int
expires_at: datetime
created_at: datetime
days_remaining: int
progress_percent: float
class SubscriptionData(BaseModel):
"""User subscription data."""
id: int
status: str
is_trial: bool
start_date: datetime
end_date: datetime
days_left: int
hours_left: int = 0
minutes_left: int = 0
time_left_display: str = '' # Human readable format like "2д 5ч" or "5ч 30м"
traffic_limit_gb: int
traffic_used_gb: float
traffic_used_percent: float
device_limit: int
connected_squads: list[str] = []
servers: list[ServerInfo] = [] # Server display info
autopay_enabled: bool
autopay_days_before: int
subscription_url: str | None = None
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
is_active: bool
is_expired: bool
traffic_purchases: list[TrafficPurchaseInfo] = []
# Daily tariff fields
is_daily: bool = False
is_daily_paused: bool = False
daily_price_kopeks: int | None = None
next_daily_charge_at: datetime | None = None # When next daily charge will happen
tariff_id: int | None = None
tariff_name: str | None = None
class Config:
from_attributes = True
# Backward compatibility alias
SubscriptionResponse = SubscriptionData
class SubscriptionStatusResponse(BaseModel):
"""Response for subscription status endpoint - handles users with and without subscription."""
has_subscription: bool
subscription: SubscriptionData | None = None
class RenewalOptionResponse(BaseModel):
"""Available subscription renewal option."""
period_days: int
price_kopeks: int
price_rubles: float
discount_percent: int = 0
original_price_kopeks: int | None = None
class RenewalRequest(BaseModel):
"""Request to renew subscription."""
period_days: int = Field(..., description='Renewal period in days')
class TrafficPackageResponse(BaseModel):
"""Available traffic package."""
gb: int
price_kopeks: int
price_rubles: float
is_unlimited: bool = False
class TrafficPurchaseRequest(BaseModel):
"""Request to purchase additional traffic."""
gb: int = Field(..., ge=0, 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')
class AutopayUpdateRequest(BaseModel):
"""Request to update autopay settings."""
enabled: bool
days_before: int | None = Field(None, ge=1, le=30, description='Days before expiration to charge')
class TrialInfoResponse(BaseModel):
"""Trial subscription info."""
is_available: bool
duration_days: int
traffic_limit_gb: int
device_limit: int
requires_payment: bool = False
price_kopeks: int = 0
price_rubles: float = 0.0
reason_unavailable: str | None = None
# ============ Purchase Options Schemas ============
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)')
servers: list[str] | None = Field(default_factory=list, description='Server UUIDs')
devices: int | None = Field(None, description='Device limit')
class PurchasePreviewRequest(BaseModel):
"""Request to preview purchase pricing."""
selection: PurchaseSelectionRequest
# ============ Tariff Purchase Schemas ============
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)')
+228
View File
@@ -0,0 +1,228 @@
"""Schemas for tariff management in cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
class PeriodPrice(BaseModel):
"""Price for a specific period."""
days: int = Field(..., ge=1, description='Period in days')
price_kopeks: int = Field(..., ge=0, description='Price in kopeks')
price_rubles: float | None = None
def __init__(self, **data):
super().__init__(**data)
if self.price_rubles is None:
self.price_rubles = self.price_kopeks / 100
class ServerTrafficLimit(BaseModel):
"""Traffic limit for a specific server."""
traffic_limit_gb: int = Field(0, ge=0, description='0 = use default tariff limit')
class ServerInfo(BaseModel):
"""Server info for tariff."""
id: int
squad_uuid: str
display_name: str
country_code: str | None = None
is_selected: bool = False
traffic_limit_gb: int | None = None # Индивидуальный лимит для сервера
class PromoGroupInfo(BaseModel):
"""Promo group info for tariff."""
id: int
name: str
is_selected: bool = False
class TariffListItem(BaseModel):
"""Tariff item for list view."""
id: int
name: str
description: str | None = None
is_active: bool
is_trial_available: bool
is_daily: bool = False
daily_price_kopeks: int = 0
allow_traffic_topup: bool = True
traffic_limit_gb: int
device_limit: int
tier_level: int
display_order: int
servers_count: int
subscriptions_count: int
created_at: datetime
class Config:
from_attributes = True
class TariffListResponse(BaseModel):
"""Response with list of tariffs."""
tariffs: list[TariffListItem]
total: int
class TariffDetailResponse(BaseModel):
"""Detailed tariff response."""
id: int
name: str
description: str | None = None
is_active: bool
is_trial_available: bool
allow_traffic_topup: bool = True
traffic_topup_enabled: bool = False
traffic_topup_packages: dict[str, int] = Field(default_factory=dict)
max_topup_traffic_gb: int = 0
traffic_limit_gb: int
device_limit: int
device_price_kopeks: int | None = None
max_device_limit: int | None = None
tier_level: int
display_order: int
period_prices: list[PeriodPrice]
allowed_squads: list[str] # UUIDs
server_traffic_limits: dict[str, ServerTrafficLimit] = Field(default_factory=dict) # {uuid: {traffic_limit_gb}}
servers: list[ServerInfo]
promo_groups: list[PromoGroupInfo]
subscriptions_count: int
# Произвольное количество дней
custom_days_enabled: bool = False
price_per_day_kopeks: int = 0
min_days: int = 1
max_days: int = 365
# Произвольный трафик при покупке
custom_traffic_enabled: bool = False
traffic_price_per_gb_kopeks: int = 0
min_traffic_gb: int = 1
max_traffic_gb: int = 1000
# Дневной тариф
is_daily: bool = False
daily_price_kopeks: int = 0
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
created_at: datetime
updated_at: datetime | None = None
class Config:
from_attributes = True
class TariffCreateRequest(BaseModel):
"""Request to create a tariff."""
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
is_active: bool = True
allow_traffic_topup: bool = True
traffic_topup_enabled: bool = False
traffic_topup_packages: dict[str, int] = Field(default_factory=dict)
max_topup_traffic_gb: int = Field(0, ge=0)
traffic_limit_gb: int = Field(0, ge=0, description='0 = unlimited')
device_limit: int = Field(1, ge=1)
device_price_kopeks: int | None = Field(None, ge=0)
max_device_limit: int | None = Field(None, ge=1)
tier_level: int = Field(1, ge=1, le=10)
period_prices: list[PeriodPrice] = Field(default_factory=list)
allowed_squads: list[str] = Field(default_factory=list, description='Server UUIDs')
server_traffic_limits: dict[str, ServerTrafficLimit] = Field(
default_factory=dict, description='Per-server traffic limits'
)
promo_group_ids: list[int] = Field(default_factory=list)
# Произвольное количество дней
custom_days_enabled: bool = False
price_per_day_kopeks: int = Field(0, ge=0)
min_days: int = Field(1, ge=1)
max_days: int = Field(365, ge=1)
# Произвольный трафик при покупке
custom_traffic_enabled: bool = False
traffic_price_per_gb_kopeks: int = Field(0, ge=0)
min_traffic_gb: int = Field(1, ge=1)
max_traffic_gb: int = Field(1000, ge=1)
# Дневной тариф
is_daily: bool = False
daily_price_kopeks: int = Field(0, ge=0)
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
class TariffUpdateRequest(BaseModel):
"""Request to update a tariff."""
name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = None
is_active: bool | None = None
allow_traffic_topup: bool | None = None
traffic_topup_enabled: bool | None = None
traffic_topup_packages: dict[str, int] | None = None
max_topup_traffic_gb: int | None = Field(None, ge=0)
traffic_limit_gb: int | None = Field(None, ge=0)
device_limit: int | None = Field(None, ge=1)
device_price_kopeks: int | None = Field(None, ge=0)
max_device_limit: int | None = Field(None, ge=1)
tier_level: int | None = Field(None, ge=1, le=10)
display_order: int | None = Field(None, ge=0)
period_prices: list[PeriodPrice] | None = None
allowed_squads: list[str] | None = None
server_traffic_limits: dict[str, ServerTrafficLimit] | None = None
promo_group_ids: list[int] | None = None
# Произвольное количество дней
custom_days_enabled: bool | None = None
price_per_day_kopeks: int | None = Field(None, ge=0)
min_days: int | None = Field(None, ge=1)
max_days: int | None = Field(None, ge=1)
# Произвольный трафик при покупке
custom_traffic_enabled: bool | None = None
traffic_price_per_gb_kopeks: int | None = Field(None, ge=0)
min_traffic_gb: int | None = Field(None, ge=1)
max_traffic_gb: int | None = Field(None, ge=1)
# Дневной тариф
is_daily: bool | None = None
daily_price_kopeks: int | None = Field(None, ge=0)
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
class TariffSortOrderRequest(BaseModel):
"""Request to reorder tariffs."""
tariff_ids: list[int] = Field(..., min_length=1, description='Ordered list of tariff IDs')
class TariffToggleResponse(BaseModel):
"""Response after toggling tariff."""
id: int
is_active: bool
message: str
class TariffTrialResponse(BaseModel):
"""Response after setting trial tariff."""
id: int
is_trial_available: bool
message: str
class TariffStatsResponse(BaseModel):
"""Tariff statistics."""
id: int
name: str
subscriptions_count: int
active_subscriptions: int
trial_subscriptions: int
revenue_kopeks: int
revenue_rubles: float
+84
View File
@@ -0,0 +1,84 @@
"""Support tickets schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
class TicketMessageResponse(BaseModel):
"""Ticket message data."""
id: int
message_text: str
is_from_admin: bool
has_media: bool = False
media_type: str | None = None
media_file_id: str | None = None
media_caption: str | None = None
created_at: datetime
class Config:
from_attributes = True
class TicketResponse(BaseModel):
"""Ticket data."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: datetime | None = None
messages_count: int = 0
last_message: TicketMessageResponse | None = None
class Config:
from_attributes = True
class TicketDetailResponse(BaseModel):
"""Ticket with all messages."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: datetime | None = None
is_reply_blocked: bool = False
messages: list[TicketMessageResponse] = []
class Config:
from_attributes = True
class TicketListResponse(BaseModel):
"""Paginated ticket list."""
items: list[TicketResponse]
total: int
page: int
per_page: int
pages: int
class TicketCreateRequest(BaseModel):
"""Request to create a new ticket."""
title: str = Field(..., min_length=3, max_length=255, description='Ticket title')
message: str = Field(..., min_length=10, max_length=4000, description='Initial message')
media_type: str | None = Field(None, description='Media type: photo, video, document')
media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media')
media_caption: str | None = Field(None, max_length=1000, description='Media caption')
class TicketMessageCreateRequest(BaseModel):
"""Request to add message to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description='Message text')
media_type: str | None = Field(None, description='Media type: photo, video, document')
media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media')
media_caption: str | None = Field(None, max_length=1000, description='Media caption')
+62
View File
@@ -0,0 +1,62 @@
"""Schemas for admin traffic usage."""
from pydantic import BaseModel, Field
class TrafficNodeInfo(BaseModel):
node_uuid: str
node_name: str
country_code: str
class UserTrafficItem(BaseModel):
user_id: int
telegram_id: int | None
username: str | None
email: str | None
full_name: str
tariff_name: str | None
subscription_status: str | None
traffic_limit_gb: float
device_limit: int
node_traffic: dict[str, int] # {node_uuid: total_bytes}
total_bytes: int
class TrafficUsageResponse(BaseModel):
items: list[UserTrafficItem]
nodes: list[TrafficNodeInfo]
total: int
offset: int
limit: int
period_days: int
available_tariffs: list[str]
available_statuses: list[str]
class UserTrafficEnrichment(BaseModel):
devices_connected: int = 0
total_spent_kopeks: int = 0
subscription_start_date: str | None = None
subscription_end_date: str | None = None
last_node_name: str | None = None
class TrafficEnrichmentResponse(BaseModel):
data: dict[int, UserTrafficEnrichment]
class ExportCsvRequest(BaseModel):
period: int = Field(30, ge=1, le=30)
start_date: str | None = None
end_date: str | None = None
tariffs: str | None = None
statuses: str | None = None
nodes: str | None = None
total_threshold_gb: float | None = Field(None, ge=0, description='Total GB/day threshold for risk column')
node_threshold_gb: float | None = Field(None, ge=0, description='Per-node GB/day threshold for risk column')
class ExportCsvResponse(BaseModel):
success: bool
message: str
+696
View File
@@ -0,0 +1,696 @@
"""Schemas for Admin Users management in cabinet."""
from datetime import datetime
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
class UserStatusEnum(str, Enum):
"""User status enum."""
ACTIVE = 'active'
BLOCKED = 'blocked'
DELETED = 'deleted'
class SubscriptionStatusEnum(str, Enum):
"""Subscription status enum."""
TRIAL = 'trial'
ACTIVE = 'active'
EXPIRED = 'expired'
DISABLED = 'disabled'
PENDING = 'pending'
class SortByEnum(str, Enum):
"""Sort options for users list."""
CREATED_AT = 'created_at'
BALANCE = 'balance'
TRAFFIC = 'traffic'
LAST_ACTIVITY = 'last_activity'
TOTAL_SPENT = 'total_spent'
PURCHASE_COUNT = 'purchase_count'
# === User Subscription Info ===
class TrafficPurchaseItem(BaseModel):
"""Individual traffic purchase record."""
id: int
traffic_gb: int
expires_at: datetime
created_at: datetime
days_remaining: int
is_expired: bool
class UserSubscriptionInfo(BaseModel):
"""User subscription information."""
id: int
status: str
is_trial: bool
start_date: datetime | None = None
end_date: datetime | None = None
traffic_limit_gb: int = 0
traffic_used_gb: float = 0.0
device_limit: int = 1
tariff_id: int | None = None
tariff_name: str | None = None
autopay_enabled: bool = False
is_active: bool = False
days_remaining: int = 0
purchased_traffic_gb: int = 0
traffic_purchases: list[TrafficPurchaseItem] = []
class UserPromoGroupInfo(BaseModel):
"""User promo group info."""
id: int
name: str
is_default: bool = False
# === User List ===
class UserListItem(BaseModel):
"""User item in list."""
id: int
telegram_id: int | None = None
username: str | None = None
first_name: str | None = None
last_name: str | None = None
full_name: str
status: str
balance_kopeks: int
balance_rubles: float
created_at: datetime
last_activity: datetime | None = None
# Subscription summary
has_subscription: bool = False
subscription_status: str | None = None
subscription_is_trial: bool = False
subscription_end_date: datetime | None = None
# Promo group
promo_group_id: int | None = None
promo_group_name: str | None = None
# Stats
total_spent_kopeks: int = 0
purchase_count: int = 0
# Restrictions
has_restrictions: bool = False
restriction_topup: bool = False
restriction_subscription: bool = False
class UsersListResponse(BaseModel):
"""Paginated list of users."""
users: list[UserListItem]
total: int
offset: int = 0
limit: int = 50
# === User Detail ===
class UserTransactionItem(BaseModel):
"""User transaction."""
id: int
type: str
amount_kopeks: int
amount_rubles: float
description: str | None = None
payment_method: str | None = None
is_completed: bool = True
created_at: datetime
class UserReferralInfo(BaseModel):
"""User referral info."""
referral_code: str
referrals_count: int = 0
total_earnings_kopeks: int = 0
commission_percent: int | None = None
referred_by_id: int | None = None
referred_by_username: str | None = None
class UserDetailResponse(BaseModel):
"""Detailed user information."""
id: int
telegram_id: int | None = None
username: str | None = None
first_name: str | None = None
last_name: str | None = None
full_name: str
status: str
language: str
balance_kopeks: int
balance_rubles: float
# Email (cabinet)
email: str | None = None
email_verified: bool = False
# Dates
created_at: datetime
updated_at: datetime | None = None
last_activity: datetime | None = None
cabinet_last_login: datetime | None = None
# Subscription
subscription: UserSubscriptionInfo | None = None
# Promo group
promo_group: UserPromoGroupInfo | None = None
# Referral
referral: UserReferralInfo
# Stats
total_spent_kopeks: int = 0
purchase_count: int = 0
used_promocodes: int = 0
has_had_paid_subscription: bool = False
lifetime_used_traffic_bytes: int = 0
# Restrictions
restriction_topup: bool = False
restriction_subscription: bool = False
restriction_reason: str | None = None
# Promo offer
promo_offer_discount_percent: int = 0
promo_offer_discount_source: str | None = None
promo_offer_discount_expires_at: datetime | None = None
# Campaign
campaign_name: str | None = None
campaign_id: int | None = None
# Recent transactions
recent_transactions: list[UserTransactionItem] = []
# Remnawave UUID
remnawave_uuid: str | None = None
# === Panel Info ===
class UserPanelInfoResponse(BaseModel):
"""Panel info for user from Remnawave."""
found: bool = False
trojan_password: str | None = None
vless_uuid: str | None = None
ss_password: str | None = None
subscription_url: str | None = None
happ_link: str | None = None
used_traffic_bytes: int = 0
lifetime_used_traffic_bytes: int = 0
traffic_limit_bytes: int = 0
first_connected_at: datetime | None = None
online_at: datetime | None = None
last_connected_node_uuid: str | None = None
last_connected_node_name: str | None = None
# === Node Usage ===
class UserNodeUsageItem(BaseModel):
"""Per-node traffic usage item."""
node_uuid: str
node_name: str
country_code: str = ''
total_bytes: int
daily_bytes: list[int] = []
class UserNodeUsageResponse(BaseModel):
"""Node usage response with 30-day daily breakdown."""
items: list[UserNodeUsageItem]
categories: list[str] = []
period_days: int = 30
# === User Actions ===
class UpdateBalanceRequest(BaseModel):
"""Request to update user balance."""
amount_kopeks: int = Field(..., 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')
class UpdateBalanceResponse(BaseModel):
"""Response after balance update."""
success: bool
old_balance_kopeks: int
new_balance_kopeks: int
message: str
class UpdateSubscriptionRequest(BaseModel):
"""Request to update user subscription."""
action: str = Field(
..., description='Action: extend, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
)
# For extend action
days: int | None = Field(None, ge=1, le=3650, description='Days to extend')
# For set_end_date action
end_date: datetime | None = Field(None, description='New end date')
# For change_tariff action
tariff_id: int | None = Field(None, description='New tariff ID')
# For set_traffic action
traffic_limit_gb: int | None = Field(None, ge=0, description='New traffic limit in GB')
traffic_used_gb: float | None = Field(None, ge=0, description='Set traffic used in GB')
# For toggle_autopay
autopay_enabled: bool | None = Field(None, description='Enable/disable autopay')
# For add_traffic action
traffic_gb: int | None = Field(None, ge=1, description='Traffic GB to add')
# For remove_traffic action
traffic_purchase_id: int | None = Field(None, description='Traffic purchase ID to remove')
# For create new subscription
is_trial: bool | None = Field(None, description='Is trial subscription')
device_limit: int | None = Field(None, ge=1, description='Device limit')
class UpdateSubscriptionResponse(BaseModel):
"""Response after subscription update."""
success: bool
message: str
subscription: UserSubscriptionInfo | None = None
class UpdateUserStatusRequest(BaseModel):
"""Request to update user status."""
status: UserStatusEnum
reason: str | None = Field(None, max_length=500, description='Reason for status change')
class UpdateUserStatusResponse(BaseModel):
"""Response after status update."""
success: bool
old_status: str
new_status: str
message: str
class UpdateRestrictionsRequest(BaseModel):
"""Request to update user restrictions."""
restriction_topup: bool | None = Field(None, description='Block balance top-up')
restriction_subscription: bool | None = Field(None, description='Block subscription purchase/renewal')
restriction_reason: str | None = Field(None, max_length=500, description='Reason for restrictions')
class UpdateRestrictionsResponse(BaseModel):
"""Response after restrictions update."""
success: bool
restriction_topup: bool
restriction_subscription: bool
restriction_reason: str | None = None
message: str
class UpdatePromoGroupRequest(BaseModel):
"""Request to update user promo group."""
promo_group_id: int | None = Field(None, description='New promo group ID (null to remove)')
class UpdatePromoGroupResponse(BaseModel):
"""Response after promo group update."""
success: bool
old_promo_group_id: int | None = None
new_promo_group_id: int | None = None
promo_group_name: str | None = None
message: str
class UpdateReferralCommissionRequest(BaseModel):
"""Request to update user referral commission percent."""
commission_percent: int | None = Field(
None, ge=0, le=100, description='Referral commission percent (null for default)'
)
class UpdateReferralCommissionResponse(BaseModel):
"""Response after referral commission update."""
success: bool
old_commission_percent: int | None = None
new_commission_percent: int | None = None
message: str
class DeviceInfo(BaseModel):
"""Individual device info."""
hwid: str
platform: str = ''
device_model: str = ''
created_at: str | None = None
class UserDevicesResponse(BaseModel):
"""User devices from panel."""
devices: list[DeviceInfo] = []
total: int = 0
device_limit: int = 0
class DeleteDeviceResponse(BaseModel):
"""Response after device deletion."""
success: bool
message: str
deleted_hwid: str | None = None
class ResetDevicesResponse(BaseModel):
"""Response after resetting all devices."""
success: bool
message: str
deleted_count: int = 0
class DeleteUserRequest(BaseModel):
"""Request to delete user."""
soft_delete: bool = Field(default=True, description='Soft delete (mark as deleted) or hard delete')
reason: str | None = Field(None, max_length=500, description='Reason for deletion')
class DeleteUserResponse(BaseModel):
"""Response after user deletion."""
success: bool
message: str
# === Statistics ===
class UsersStatsResponse(BaseModel):
"""Users statistics."""
total_users: int = 0
active_users: int = 0
blocked_users: int = 0
deleted_users: int = 0
new_today: int = 0
new_week: int = 0
new_month: int = 0
# Subscription stats
users_with_subscription: int = 0
users_with_active_subscription: int = 0
users_with_trial: int = 0
users_with_expired_subscription: int = 0
# Financial stats
total_balance_kopeks: int = 0
total_balance_rubles: float = 0.0
avg_balance_kopeks: int = 0
# Activity stats
active_today: int = 0
active_week: int = 0
active_month: int = 0
# === Search ===
class UserSearchRequest(BaseModel):
"""Request for user search."""
query: str = Field(..., min_length=1, max_length=255)
search_by: list[str] = Field(
default=['telegram_id', 'username', 'first_name', 'last_name', 'email'], description='Fields to search in'
)
limit: int = Field(default=20, ge=1, le=100)
# === Tariffs for User ===
class PeriodPriceInfo(BaseModel):
"""Period price info."""
days: int
price_kopeks: int
price_rubles: float
class UserAvailableTariffItem(BaseModel):
"""Tariff available for user."""
id: int
name: str
description: str | None = None
is_active: bool = True
is_trial_available: bool = False
traffic_limit_gb: int = 0
device_limit: int = 1
tier_level: int = 1
display_order: int = 0
# Pricing
period_prices: list[PeriodPriceInfo] = []
is_daily: bool = False
daily_price_kopeks: int = 0
# Custom options
custom_days_enabled: bool = False
price_per_day_kopeks: int = 0
min_days: int = 1
max_days: int = 365
# Device limits
device_price_kopeks: int | None = None
max_device_limit: int | None = None
# Traffic topup
traffic_topup_enabled: bool = False
traffic_topup_packages: dict[str, int] = {}
max_topup_traffic_gb: int = 0
# Access info
is_available: bool = True # Available for this user's promo group
requires_promo_group: bool = False # Requires specific promo group
class UserAvailableTariffsResponse(BaseModel):
"""List of tariffs available for user."""
user_id: int
promo_group_id: int | None = None
promo_group_name: str | None = None
tariffs: list[UserAvailableTariffItem] = []
total: int = 0
# Current subscription tariff
current_tariff_id: int | None = None
current_tariff_name: str | None = None
# === Panel Sync ===
class PanelUserInfo(BaseModel):
"""User info from panel."""
uuid: str | None = None
short_uuid: str | None = None
username: str | None = None
status: str | None = None
expire_at: datetime | None = None
traffic_limit_gb: float = 0
traffic_used_gb: float = 0
device_limit: int = 1
subscription_url: str | None = None
active_squads: list[str] = []
class SyncFromPanelRequest(BaseModel):
"""Request to sync user from panel."""
update_subscription: bool = Field(default=True, description='Update subscription data')
update_traffic: bool = Field(default=True, description='Update traffic usage')
create_if_missing: bool = Field(
default=False, description='Create subscription if user exists in panel but not in bot'
)
class SyncFromPanelResponse(BaseModel):
"""Response after syncing from panel."""
success: bool
message: str
panel_user: PanelUserInfo | None = None
changes: dict[str, Any] = {}
errors: list[str] = []
class SyncToPanelRequest(BaseModel):
"""Request to sync user to panel."""
create_if_missing: bool = Field(default=True, description='Create user in panel if not exists')
update_status: bool = Field(default=True, description='Update user status in panel')
update_traffic_limit: bool = Field(default=True, description='Update traffic limit in panel')
update_expire_date: bool = Field(default=True, description='Update expire date in panel')
update_squads: bool = Field(default=True, description='Update connected squads in panel')
class SyncToPanelResponse(BaseModel):
"""Response after syncing to panel."""
success: bool
message: str
action: str = '' # created, updated, no_changes
panel_uuid: str | None = None
changes: dict[str, Any] = {}
errors: list[str] = []
class PanelSyncStatusResponse(BaseModel):
"""Panel sync status for user."""
user_id: int
telegram_id: int | None = None
remnawave_uuid: str | None = None
last_sync: datetime | None = None
# Bot data
bot_subscription_status: str | None = None
bot_subscription_end_date: datetime | None = None
bot_traffic_limit_gb: int = 0
bot_traffic_used_gb: float = 0
bot_device_limit: int = 0
bot_squads: list[str] = []
# Panel data (if available)
panel_found: bool = False
panel_status: str | None = None
panel_expire_at: datetime | None = None
panel_traffic_limit_gb: float = 0
panel_traffic_used_gb: float = 0
panel_device_limit: int = 0
panel_squads: list[str] = []
# Differences
has_differences: bool = False
differences: list[str] = []
# === Admin User Management Actions ===
class FullDeleteUserRequest(BaseModel):
"""Request for full user deletion (bot + panel)."""
delete_from_panel: bool = Field(default=True, description='Also delete user from Remnawave panel')
reason: str | None = Field(None, max_length=500, description='Reason for deletion')
class FullDeleteUserResponse(BaseModel):
"""Response after full user deletion."""
success: bool
message: str
deleted_from_bot: bool = False
deleted_from_panel: bool = False
panel_error: str | None = None
class ResetTrialRequest(BaseModel):
"""Request to reset user trial."""
reason: str | None = Field(None, max_length=500, description='Reason for trial reset')
class ResetTrialResponse(BaseModel):
"""Response after trial reset."""
success: bool
message: str
subscription_deleted: bool = False
has_used_trial_reset: bool = False
class ResetSubscriptionRequest(BaseModel):
"""Request to reset user subscription."""
deactivate_in_panel: bool = Field(default=True, description='Also deactivate in Remnawave panel')
reason: str | None = Field(None, max_length=500, description='Reason for subscription reset')
class ResetSubscriptionResponse(BaseModel):
"""Response after subscription reset."""
success: bool
message: str
subscription_deleted: bool = False
panel_deactivated: bool = False
panel_error: str | None = None
class DisableUserRequest(BaseModel):
"""Request to disable user."""
reason: str | None = Field(None, max_length=500, description='Reason for disabling')
class DisableUserResponse(BaseModel):
"""Response after user disable."""
success: bool
message: str
subscription_deactivated: bool = False
panel_deactivated: bool = False
user_blocked: bool = False
panel_error: str | None = None
+277
View File
@@ -0,0 +1,277 @@
"""Схемы для колеса удачи (Fortune Wheel)."""
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field
# ==================== ENUMS ====================
class WheelPaymentType(str, Enum):
"""Способы оплаты спина."""
TELEGRAM_STARS = 'telegram_stars'
SUBSCRIPTION_DAYS = 'subscription_days'
class WheelPrizeType(str, Enum):
"""Типы призов."""
SUBSCRIPTION_DAYS = 'subscription_days'
BALANCE_BONUS = 'balance_bonus'
TRAFFIC_GB = 'traffic_gb'
PROMOCODE = 'promocode'
NOTHING = 'nothing'
# ==================== USER SCHEMAS ====================
class WheelPrizeDisplay(BaseModel):
"""Отображение приза для пользователя."""
id: int
display_name: str
emoji: str
color: str
prize_type: str
class Config:
from_attributes = True
class WheelConfigResponse(BaseModel):
"""Конфигурация колеса для пользователя."""
is_enabled: bool
name: str
spin_cost_stars: int | None = None
spin_cost_days: int | None = None
spin_cost_stars_enabled: bool
spin_cost_days_enabled: bool
prizes: list[WheelPrizeDisplay]
daily_limit: int
user_spins_today: int
can_spin: bool
can_spin_reason: str | None = None
can_pay_stars: bool = False
can_pay_days: bool = False
user_balance_kopeks: int = 0
required_balance_kopeks: int = 0
class SpinAvailabilityResponse(BaseModel):
"""Доступность спина."""
can_spin: bool
reason: str | None = None
spins_remaining_today: int
can_pay_stars: bool
can_pay_days: bool
min_subscription_days: int
user_subscription_days: int
user_balance_kopeks: int = 0
required_balance_kopeks: int = 0
class SpinRequest(BaseModel):
"""Запрос на спин."""
payment_type: WheelPaymentType
class SpinResultResponse(BaseModel):
"""Результат спина."""
success: bool
prize_id: int | None = None
prize_type: str | None = None
prize_value: int = 0
prize_display_name: str = ''
emoji: str = '🎁'
color: str = '#3B82F6'
rotation_degrees: float = 0.0
message: str = ''
promocode: str | None = None
error: str | None = None
class SpinHistoryItem(BaseModel):
"""Элемент истории спинов."""
id: int
payment_type: str
payment_amount: int
prize_type: str
prize_value: int
prize_display_name: str
emoji: str = '🎁'
color: str = '#3B82F6'
prize_value_kopeks: int
created_at: datetime
class Config:
from_attributes = True
class SpinHistoryResponse(BaseModel):
"""История спинов с пагинацией."""
items: list[SpinHistoryItem]
total: int
page: int
per_page: int
pages: int
# ==================== ADMIN SCHEMAS ====================
class WheelPrizeAdminResponse(BaseModel):
"""Полная информация о призе для админа."""
id: int
config_id: int
prize_type: str
prize_value: int
display_name: str
emoji: str
color: str
prize_value_kopeks: int
sort_order: int
manual_probability: float | None = None
is_active: bool
promo_balance_bonus_kopeks: int = 0
promo_subscription_days: int = 0
promo_traffic_gb: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
class AdminWheelConfigResponse(BaseModel):
"""Полная конфигурация колеса для админа."""
id: int
is_enabled: bool
name: str
spin_cost_stars: int
spin_cost_days: int
spin_cost_stars_enabled: bool
spin_cost_days_enabled: bool
rtp_percent: int
daily_spin_limit: int
min_subscription_days_for_day_payment: int
promo_prefix: str
promo_validity_days: int
prizes: list[WheelPrizeAdminResponse]
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
class UpdateWheelConfigRequest(BaseModel):
"""Запрос на обновление конфига колеса."""
is_enabled: bool | None = None
name: str | None = Field(None, min_length=1, max_length=255)
spin_cost_stars: int | None = Field(None, ge=1, le=1000)
spin_cost_days: int | None = Field(None, ge=1, le=30)
spin_cost_stars_enabled: bool | None = None
spin_cost_days_enabled: bool | None = None
rtp_percent: int | None = Field(None, ge=0, le=100)
daily_spin_limit: int | None = Field(None, ge=0, le=100)
min_subscription_days_for_day_payment: int | None = Field(None, ge=1, le=30)
promo_prefix: str | None = Field(None, min_length=1, max_length=20)
promo_validity_days: int | None = Field(None, ge=1, le=365)
class CreatePrizeRequest(BaseModel):
"""Запрос на создание приза."""
prize_type: WheelPrizeType
prize_value: int = Field(..., ge=0)
display_name: str = Field(..., min_length=1, max_length=100)
emoji: str = Field(default='🎁', max_length=10)
color: str = Field(default='#3B82F6', pattern=r'^#[0-9A-Fa-f]{6}$')
prize_value_kopeks: int = Field(..., ge=0)
sort_order: int = Field(default=0, ge=0)
manual_probability: float | None = Field(None, ge=0, le=1)
is_active: bool = True
promo_balance_bonus_kopeks: int = Field(default=0, ge=0)
promo_subscription_days: int = Field(default=0, ge=0)
promo_traffic_gb: int = Field(default=0, ge=0)
class UpdatePrizeRequest(BaseModel):
"""Запрос на обновление приза."""
prize_type: WheelPrizeType | None = None
prize_value: int | None = Field(None, ge=0)
display_name: str | None = Field(None, min_length=1, max_length=100)
emoji: str | None = Field(None, max_length=10)
color: str | None = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
prize_value_kopeks: int | None = Field(None, ge=0)
sort_order: int | None = Field(None, ge=0)
manual_probability: float | None = Field(None, ge=0, le=1)
is_active: bool | None = None
promo_balance_bonus_kopeks: int | None = Field(None, ge=0)
promo_subscription_days: int | None = Field(None, ge=0)
promo_traffic_gb: int | None = Field(None, ge=0)
class ReorderPrizesRequest(BaseModel):
"""Запрос на переупорядочивание призов."""
prize_ids: list[int]
class AdminSpinItem(BaseModel):
"""Спин для админки."""
id: int
user_id: int
username: str | None = None
payment_type: str
payment_amount: int
payment_value_kopeks: int
prize_type: str
prize_value: int
prize_display_name: str
prize_value_kopeks: int
is_applied: bool
created_at: datetime
class Config:
from_attributes = True
class AdminSpinsResponse(BaseModel):
"""Список спинов для админки с пагинацией."""
items: list[AdminSpinItem]
total: int
page: int
per_page: int
pages: int
class WheelStatisticsResponse(BaseModel):
"""Статистика колеса."""
total_spins: int
total_revenue_kopeks: int
total_payout_kopeks: int
actual_rtp_percent: float
configured_rtp_percent: int
spins_by_payment_type: dict
prizes_distribution: list[dict]
top_wins: list[dict]
period_from: str | None = None
period_to: str | None = None
+6
View File
@@ -0,0 +1,6 @@
"""Cabinet services."""
from .email_service import EmailService, email_service
__all__ = ['EmailService', 'email_service']
+486
View File
@@ -0,0 +1,486 @@
"""Email service for sending verification and password reset emails."""
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from app.config import settings
logger = logging.getLogger(__name__)
class EmailService:
"""Service for sending emails via SMTP."""
def __init__(self):
self.host = settings.SMTP_HOST
self.port = settings.SMTP_PORT
self.user = settings.SMTP_USER
self.password = settings.SMTP_PASSWORD
self.from_email = settings.get_smtp_from_email()
self.from_name = settings.SMTP_FROM_NAME
self.use_tls = settings.SMTP_USE_TLS
def is_configured(self) -> bool:
"""Check if SMTP is properly configured."""
return settings.is_smtp_configured()
def _get_smtp_connection(self) -> smtplib.SMTP:
"""Create and return SMTP connection."""
smtp = smtplib.SMTP(self.host, self.port)
smtp.ehlo()
if self.use_tls:
smtp.starttls()
smtp.ehlo()
# Only attempt login if credentials are provided AND server supports AUTH
if self.user and self.password:
if smtp.has_extn('auth'):
smtp.login(self.user, self.password)
else:
logger.debug(f'SMTP server {self.host} does not support AUTH, skipping authentication')
return smtp
def send_email(
self,
to_email: str,
subject: str,
body_html: str,
body_text: str | None = None,
) -> bool:
"""
Send an email.
Args:
to_email: Recipient email address
subject: Email subject
body_html: HTML body content
body_text: Plain text body (optional, generated from HTML if not provided)
Returns:
True if email was sent successfully, False otherwise
"""
if not self.is_configured():
logger.warning('SMTP is not configured, cannot send email')
return False
try:
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f'{self.from_name} <{self.from_email}>'
msg['To'] = to_email
# Plain text version
if body_text is None:
# Simple HTML to text conversion
import re
body_text = re.sub(r'<[^>]+>', '', body_html)
body_text = body_text.replace('&nbsp;', ' ')
body_text = body_text.replace('&amp;', '&')
body_text = body_text.replace('&lt;', '<')
body_text = body_text.replace('&gt;', '>')
part1 = MIMEText(body_text, 'plain', 'utf-8')
part2 = MIMEText(body_html, 'html', 'utf-8')
msg.attach(part1)
msg.attach(part2)
with self._get_smtp_connection() as smtp:
smtp.sendmail(self.from_email, to_email, msg.as_string())
logger.info(f'Email sent successfully to {to_email}')
return True
except Exception as e:
logger.error(f'Failed to send email to {to_email}: {e}')
return False
def send_verification_email(
self,
to_email: str,
verification_token: str,
verification_url: str,
username: str | None = None,
language: str = 'ru',
custom_subject: str | None = None,
custom_body_html: str | None = None,
) -> bool:
"""
Send email verification email.
Args:
to_email: Recipient email address
verification_token: Verification token
verification_url: Base URL for verification (token will be appended)
username: User's name for personalization
language: Language code (ru, en, zh, ua, fa)
custom_subject: Override subject from admin template
custom_body_html: Override body HTML from admin template (already wrapped in base template)
Returns:
True if email was sent successfully, False otherwise
"""
if custom_subject and custom_body_html:
return self.send_email(to_email, custom_subject, custom_body_html)
full_url = f'{verification_url}?token={verification_token}'
expire_hours = settings.get_cabinet_email_verification_expire_hours()
# Localized content
texts = {
'ru': {
'greeting': f'Здравствуйте{", " + username if username else ""}!',
'subject': 'Подтверждение email адреса',
'intro': 'Спасибо за регистрацию! Пожалуйста, подтвердите ваш email адрес, нажав на кнопку ниже:',
'button': 'Подтвердить email',
'or_copy': 'Или скопируйте и вставьте эту ссылку в браузер:',
'expires': f'Ссылка действительна в течение {expire_hours} часов.',
'ignore': 'Если вы не создавали аккаунт, просто проигнорируйте это письмо.',
'regards': 'С уважением,',
},
'en': {
'greeting': f'Hello{", " + username if username else ""}!',
'subject': 'Verify your email address',
'intro': 'Thank you for registering! Please verify your email address by clicking the button below:',
'button': 'Verify Email',
'or_copy': 'Or copy and paste this link in your browser:',
'expires': f'This link will expire in {expire_hours} hours.',
'ignore': "If you didn't create an account, you can safely ignore this email.",
'regards': 'Best regards,',
},
'zh': {
'greeting': f'您好{", " + username if username else ""}!',
'subject': '验证您的邮箱地址',
'intro': '感谢您的注册!请点击下方按钮验证您的邮箱地址:',
'button': '验证邮箱',
'or_copy': '或将此链接复制并粘贴到浏览器中:',
'expires': f'此链接将在 {expire_hours} 小时后过期。',
'ignore': '如果您没有创建账户,请忽略此邮件。',
'regards': '此致,',
},
'ua': {
'greeting': f'Вітаємо{", " + username if username else ""}!',
'subject': 'Підтвердження email адреси',
'intro': 'Дякуємо за реєстрацію! Будь ласка, підтвердіть вашу email адресу, натиснувши на кнопку нижче:',
'button': 'Підтвердити email',
'or_copy': 'Або скопіюйте та вставте це посилання в браузер:',
'expires': f'Посилання дійсне протягом {expire_hours} годин.',
'ignore': 'Якщо ви не створювали акаунт, просто проігноруйте цей лист.',
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'subject': 'تایید آدرس ایمیل',
'intro': 'از ثبت‌نام شما سپاسگزاریم! لطفاً با کلیک روی دکمه زیر ایمیل خود را تایید کنید:',
'button': 'تایید ایمیل',
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
'ignore': 'اگر شما این حساب را ایجاد نکرده‌اید، این ایمیل را نادیده بگیرید.',
'regards': 'با احترام،',
},
}
t = texts.get(language, texts['ru'])
subject = t['subject']
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.button {{
display: inline-block;
padding: 12px 24px;
background-color: #007bff;
color: white !important;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}}
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
</style>
</head>
<body>
<div class="container">
<h2>{t['greeting']}</h2>
<p>{t['intro']}</p>
<a href="{full_url}" class="button">{t['button']}</a>
<p>{t['or_copy']}</p>
<p><a href="{full_url}">{full_url}</a></p>
<p>{t['expires']}</p>
<p>{t['ignore']}</p>
<div class="footer">
<p>{t['regards']}<br>{self.from_name}</p>
</div>
</div>
</body>
</html>
"""
return self.send_email(to_email, subject, body_html)
def send_password_reset_email(
self,
to_email: str,
reset_token: str,
reset_url: str,
username: str | None = None,
language: str = 'ru',
custom_subject: str | None = None,
custom_body_html: str | None = None,
) -> bool:
"""
Send password reset email.
Args:
to_email: Recipient email address
reset_token: Password reset token
reset_url: Base URL for password reset (token will be appended)
username: User's name for personalization
language: Language code (ru, en, zh, ua, fa)
custom_subject: Override subject from admin template
custom_body_html: Override body HTML from admin template (already wrapped in base template)
Returns:
True if email was sent successfully, False otherwise
"""
if custom_subject and custom_body_html:
return self.send_email(to_email, custom_subject, custom_body_html)
full_url = f'{reset_url}?token={reset_token}'
expire_hours = settings.get_cabinet_password_reset_expire_hours()
# Localized content
texts = {
'ru': {
'greeting': f'Здравствуйте{", " + username if username else ""}!',
'subject': 'Сброс пароля',
'intro': 'Мы получили запрос на сброс вашего пароля. Нажмите на кнопку ниже, чтобы установить новый пароль:',
'button': 'Сбросить пароль',
'or_copy': 'Или скопируйте и вставьте эту ссылку в браузер:',
'expires': f'Ссылка действительна в течение {expire_hours} часов.',
'warning': 'Если вы не запрашивали сброс пароля, проигнорируйте это письмо или свяжитесь с поддержкой.',
'regards': 'С уважением,',
},
'en': {
'greeting': f'Hello{", " + username if username else ""}!',
'subject': 'Reset your password',
'intro': 'We received a request to reset your password. Click the button below to set a new password:',
'button': 'Reset Password',
'or_copy': 'Or copy and paste this link in your browser:',
'expires': f'This link will expire in {expire_hours} hour(s).',
'warning': "If you didn't request a password reset, please ignore this email or contact support if you're concerned.",
'regards': 'Best regards,',
},
'zh': {
'greeting': f'您好{", " + username if username else ""}!',
'subject': '重置您的密码',
'intro': '我们收到了重置您密码的请求。点击下方按钮设置新密码:',
'button': '重置密码',
'or_copy': '或将此链接复制并粘贴到浏览器中:',
'expires': f'此链接将在 {expire_hours} 小时后过期。',
'warning': '如果您没有请求重置密码,请忽略此邮件或联系客服。',
'regards': '此致,',
},
'ua': {
'greeting': f'Вітаємо{", " + username if username else ""}!',
'subject': 'Скидання пароля',
'intro': 'Ми отримали запит на скидання вашого пароля. Натисніть на кнопку нижче, щоб встановити новий пароль:',
'button': 'Скинути пароль',
'or_copy': 'Або скопіюйте та вставте це посилання в браузер:',
'expires': f'Посилання дійсне протягом {expire_hours} годин.',
'warning': "Якщо ви не запитували скидання пароля, проігноруйте цей лист або зв'яжіться з підтримкою.",
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'subject': 'بازنشانی رمز عبور',
'intro': 'درخواستی برای بازنشانی رمز عبور شما دریافت شد. برای تعیین رمز جدید روی دکمه زیر بزنید:',
'button': 'بازنشانی رمز عبور',
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
'warning': 'اگر شما درخواست بازنشانی رمز عبور نداده‌اید، این ایمیل را نادیده بگیرید یا با پشتیبانی تماس بگیرید.',
'regards': 'با احترام،',
},
}
t = texts.get(language, texts['ru'])
subject = t['subject']
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.button {{
display: inline-block;
padding: 12px 24px;
background-color: #dc3545;
color: white !important;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}}
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
.warning {{ color: #dc3545; font-weight: bold; }}
</style>
</head>
<body>
<div class="container">
<h2>{t['greeting']}</h2>
<p>{t['intro']}</p>
<a href="{full_url}" class="button">{t['button']}</a>
<p>{t['or_copy']}</p>
<p><a href="{full_url}">{full_url}</a></p>
<p>{t['expires']}</p>
<p class="warning">{t['warning']}</p>
<div class="footer">
<p>{t['regards']}<br>{self.from_name}</p>
</div>
</div>
</body>
</html>
"""
return self.send_email(to_email, subject, body_html)
def send_email_change_code(
self,
to_email: str,
code: str,
username: str | None = None,
language: str = 'ru',
custom_subject: str | None = None,
custom_body_html: str | None = None,
) -> bool:
"""
Send email change verification code.
Args:
to_email: New email address
code: 6-digit verification code
username: User's name for personalization
language: Language code (ru, en, zh, ua, fa)
custom_subject: Override subject from admin template
custom_body_html: Override body HTML from admin template
Returns:
True if email was sent successfully, False otherwise
"""
if custom_subject and custom_body_html:
return self.send_email(to_email, custom_subject, custom_body_html)
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
texts = {
'ru': {
'greeting': f'Здравствуйте{", " + username if username else ""}!',
'subject': 'Код подтверждения для смены email',
'intro': 'Вы запросили смену email адреса. Используйте код ниже для подтверждения:',
'code_label': 'Ваш код подтверждения:',
'expires': f'Код действителен в течение {expire_minutes} минут.',
'ignore': 'Если вы не запрашивали смену email, просто проигнорируйте это письмо.',
'regards': 'С уважением,',
},
'en': {
'greeting': f'Hello{", " + username if username else ""}!',
'subject': 'Email change verification code',
'intro': 'You requested to change your email address. Use the code below to confirm:',
'code_label': 'Your verification code:',
'expires': f'This code will expire in {expire_minutes} minutes.',
'ignore': "If you didn't request an email change, you can safely ignore this email.",
'regards': 'Best regards,',
},
'zh': {
'greeting': f'您好{", " + username if username else ""}!',
'subject': '邮箱更换验证码',
'intro': '您请求更换邮箱地址。请使用以下验证码确认:',
'code_label': '您的验证码:',
'expires': f'此验证码将在 {expire_minutes} 分钟后过期。',
'ignore': '如果您没有请求更换邮箱,请忽略此邮件。',
'regards': '此致,',
},
'ua': {
'greeting': f'Вітаємо{", " + username if username else ""}!',
'subject': 'Код підтвердження для зміни email',
'intro': 'Ви запросили зміну email адреси. Використовуйте код нижче для підтвердження:',
'code_label': 'Ваш код підтвердження:',
'expires': f'Код дійсний протягом {expire_minutes} хвилин.',
'ignore': 'Якщо ви не запитували зміну email, просто проігноруйте цей лист.',
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'subject': 'کد تایید تغییر ایمیل',
'intro': 'شما درخواست تغییر ایمیل داده‌اید. برای تایید از کد زیر استفاده کنید:',
'code_label': 'کد تایید شما:',
'expires': f'این کد تا {expire_minutes} دقیقه معتبر است.',
'ignore': 'اگر شما درخواست تغییر ایمیل نداده‌اید، این ایمیل را نادیده بگیرید.',
'regards': 'با احترام،',
},
}
t = texts.get(language, texts['ru'])
subject = t['subject']
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.code-box {{
background-color: #f8f9fa;
border: 2px solid #007bff;
border-radius: 8px;
padding: 20px;
text-align: center;
margin: 20px 0;
}}
.code {{
font-size: 32px;
font-weight: bold;
letter-spacing: 8px;
color: #007bff;
font-family: monospace;
}}
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
</style>
</head>
<body>
<div class="container">
<h2>{t['greeting']}</h2>
<p>{t['intro']}</p>
<div class="code-box">
<p>{t['code_label']}</p>
<p class="code">{code}</p>
</div>
<p>{t['expires']}</p>
<p>{t['ignore']}</p>
<div class="footer">
<p>{t['regards']}<br>{self.from_name}</p>
</div>
</div>
</body>
</html>
"""
return self.send_email(to_email, subject, body_html)
# Singleton instance
email_service = EmailService()
@@ -0,0 +1,220 @@
"""
Service for managing email template overrides stored in the database.
Custom templates override the hardcoded defaults from email_templates.py.
"""
import logging
from datetime import datetime
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.database import AsyncSessionLocal
logger = logging.getLogger(__name__)
async def get_template_override(
notification_type: str,
language: str,
db: AsyncSession | None = None,
) -> dict[str, str] | None:
"""
Get custom email template from the database.
Returns:
Dict with 'subject' and 'body_html' if found, None otherwise.
"""
try:
if db:
result = await db.execute(
text(
'SELECT subject, body_html FROM email_templates '
'WHERE notification_type = :ntype AND language = :lang AND is_active = :active'
),
{'ntype': notification_type, 'lang': language, 'active': True},
)
row = result.fetchone()
if row:
return {'subject': row[0], 'body_html': row[1]}
return None
async with AsyncSessionLocal() as session:
result = await session.execute(
text(
'SELECT subject, body_html FROM email_templates '
'WHERE notification_type = :ntype AND language = :lang AND is_active = :active'
),
{'ntype': notification_type, 'lang': language, 'active': True},
)
row = result.fetchone()
if row:
return {'subject': row[0], 'body_html': row[1]}
return None
except Exception as e:
logger.debug('Не удалось получить override шаблона %s/%s: %s', notification_type, language, e)
return None
async def get_all_overrides(db: AsyncSession) -> list[dict[str, Any]]:
"""Get all custom template overrides from the database."""
result = await db.execute(
text(
'SELECT id, notification_type, language, subject, body_html, is_active, created_at, updated_at FROM email_templates ORDER BY notification_type, language'
)
)
rows = result.fetchall()
return [
{
'id': row[0],
'notification_type': row[1],
'language': row[2],
'subject': row[3],
'body_html': row[4],
'is_active': row[5],
'created_at': str(row[6]) if row[6] else None,
'updated_at': str(row[7]) if row[7] else None,
}
for row in rows
]
async def get_overrides_for_type(notification_type: str, db: AsyncSession) -> list[dict[str, Any]]:
"""Get all language overrides for a specific notification type."""
result = await db.execute(
text(
'SELECT id, language, subject, body_html, is_active, created_at, updated_at '
'FROM email_templates WHERE notification_type = :ntype ORDER BY language'
),
{'ntype': notification_type},
)
rows = result.fetchall()
return [
{
'id': row[0],
'language': row[1],
'subject': row[2],
'body_html': row[3],
'is_active': row[4],
'created_at': str(row[5]) if row[5] else None,
'updated_at': str(row[6]) if row[6] else None,
}
for row in rows
]
async def save_template_override(
notification_type: str,
language: str,
subject: str,
body_html: str,
db: AsyncSession,
) -> dict[str, Any]:
"""Save or update a custom email template in the database."""
# Check if exists
existing = await db.execute(
text('SELECT id FROM email_templates WHERE notification_type = :ntype AND language = :lang'),
{'ntype': notification_type, 'lang': language},
)
row = existing.fetchone()
now = datetime.utcnow()
if row:
# Update
await db.execute(
text(
'UPDATE email_templates SET subject = :subject, body_html = :body_html, '
'is_active = :active, updated_at = :now '
'WHERE notification_type = :ntype AND language = :lang'
),
{
'subject': subject,
'body_html': body_html,
'active': True,
'now': now,
'ntype': notification_type,
'lang': language,
},
)
else:
# Insert
await db.execute(
text(
'INSERT INTO email_templates (notification_type, language, subject, body_html, is_active, created_at, updated_at) '
'VALUES (:ntype, :lang, :subject, :body_html, :active, :now, :now)'
),
{
'ntype': notification_type,
'lang': language,
'subject': subject,
'body_html': body_html,
'active': True,
'now': now,
},
)
await db.commit()
return {
'notification_type': notification_type,
'language': language,
'subject': subject,
'body_html': body_html,
'is_active': True,
}
async def get_rendered_override(
notification_type: str,
language: str,
context: dict[str, Any] | None = None,
db: AsyncSession | None = None,
) -> tuple[str, str] | None:
"""
Get a custom template override rendered with the base email template.
Returns:
Tuple of (subject, body_html) if override exists, None otherwise.
"""
override = await get_template_override(notification_type, language, db)
if not override:
return None
from .email_templates import EmailNotificationTemplates
templates = EmailNotificationTemplates()
body_html = override['body_html']
# Simple variable substitution for context vars like {username}, {verification_url}, etc.
if context:
for key, value in context.items():
body_html = body_html.replace(f'{{{key}}}', str(value))
rendered = templates._get_base_template(body_html, language)
subject = override['subject']
# Also substitute in subject
if context:
for key, value in context.items():
subject = subject.replace(f'{{{key}}}', str(value))
return (subject, rendered)
async def delete_template_override(
notification_type: str,
language: str,
db: AsyncSession,
) -> bool:
"""Delete a custom template override (revert to default)."""
result = await db.execute(
text('DELETE FROM email_templates WHERE notification_type = :ntype AND language = :lang'),
{'ntype': notification_type, 'lang': language},
)
await db.commit()
return result.rowcount > 0
File diff suppressed because it is too large Load Diff
+1900 -621
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
"""Database package exports."""
from .database import (
DatabaseManager,
batch_ops,
close_db,
db_manager,
get_db,
get_db_read_only,
get_pool_metrics,
init_db,
)
__all__ = [
'DatabaseManager',
'batch_ops',
'close_db',
'db_manager',
'get_db',
'get_db_read_only',
'get_pool_metrics',
'init_db',
]
+82 -98
View File
@@ -1,8 +1,7 @@
import logging
from datetime import datetime
from typing import Dict, List, Optional
from sqlalchemy import and_, func, select, update, delete
from sqlalchemy import and_, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -17,6 +16,7 @@ from app.database.models import (
User,
)
logger = logging.getLogger(__name__)
@@ -26,12 +26,15 @@ async def create_campaign(
name: str,
start_parameter: str,
bonus_type: str,
created_by: Optional[int] = None,
created_by: int | None = None,
balance_bonus_kopeks: int = 0,
subscription_duration_days: Optional[int] = None,
subscription_traffic_gb: Optional[int] = None,
subscription_device_limit: Optional[int] = None,
subscription_squads: Optional[List[str]] = None,
subscription_duration_days: int | None = None,
subscription_traffic_gb: int | None = None,
subscription_device_limit: int | None = None,
subscription_squads: list[str] | None = None,
# Поля для типа "tariff"
tariff_id: int | None = None,
tariff_duration_days: int | None = None,
is_active: bool = True,
) -> AdvertisingCampaign:
campaign = AdvertisingCampaign(
@@ -43,6 +46,8 @@ async def create_campaign(
subscription_traffic_gb=subscription_traffic_gb,
subscription_device_limit=subscription_device_limit,
subscription_squads=subscription_squads or [],
tariff_id=tariff_id,
tariff_duration_days=tariff_duration_days,
created_by=created_by,
is_active=is_active,
)
@@ -52,7 +57,7 @@ async def create_campaign(
await db.refresh(campaign)
logger.info(
"📣 Создана рекламная кампания %s (start=%s, bonus=%s)",
'📣 Создана рекламная кампания %s (start=%s, bonus=%s)',
campaign.name,
campaign.start_parameter,
campaign.bonus_type,
@@ -60,12 +65,13 @@ async def create_campaign(
return campaign
async def get_campaign_by_id(
db: AsyncSession, campaign_id: int
) -> Optional[AdvertisingCampaign]:
async def get_campaign_by_id(db: AsyncSession, campaign_id: int) -> AdvertisingCampaign | None:
result = await db.execute(
select(AdvertisingCampaign)
.options(selectinload(AdvertisingCampaign.registrations))
.options(
selectinload(AdvertisingCampaign.registrations),
selectinload(AdvertisingCampaign.tariff),
)
.where(AdvertisingCampaign.id == campaign_id)
)
return result.scalar_one_or_none()
@@ -76,10 +82,8 @@ async def get_campaign_by_start_parameter(
start_parameter: str,
*,
only_active: bool = False,
) -> Optional[AdvertisingCampaign]:
stmt = select(AdvertisingCampaign).where(
AdvertisingCampaign.start_parameter == start_parameter
)
) -> AdvertisingCampaign | None:
stmt = select(AdvertisingCampaign).where(AdvertisingCampaign.start_parameter == start_parameter)
if only_active:
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
@@ -93,10 +97,13 @@ async def get_campaigns_list(
offset: int = 0,
limit: int = 20,
include_inactive: bool = True,
) -> List[AdvertisingCampaign]:
) -> list[AdvertisingCampaign]:
stmt = (
select(AdvertisingCampaign)
.options(selectinload(AdvertisingCampaign.registrations))
.options(
selectinload(AdvertisingCampaign.registrations),
selectinload(AdvertisingCampaign.tariff),
)
.order_by(AdvertisingCampaign.created_at.desc())
.offset(offset)
.limit(limit)
@@ -108,9 +115,7 @@ async def get_campaigns_list(
return result.scalars().all()
async def get_campaigns_count(
db: AsyncSession, *, is_active: Optional[bool] = None
) -> int:
async def get_campaigns_count(db: AsyncSession, *, is_active: bool | None = None) -> int:
stmt = select(func.count(AdvertisingCampaign.id))
if is_active is not None:
stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active))
@@ -125,15 +130,17 @@ async def update_campaign(
**kwargs,
) -> AdvertisingCampaign:
allowed_fields = {
"name",
"start_parameter",
"bonus_type",
"balance_bonus_kopeks",
"subscription_duration_days",
"subscription_traffic_gb",
"subscription_device_limit",
"subscription_squads",
"is_active",
'name',
'start_parameter',
'bonus_type',
'balance_bonus_kopeks',
'subscription_duration_days',
'subscription_traffic_gb',
'subscription_device_limit',
'subscription_squads',
'tariff_id',
'tariff_duration_days',
'is_active',
}
update_data = {}
@@ -144,33 +151,27 @@ async def update_campaign(
if not update_data:
return campaign
update_data["updated_at"] = datetime.utcnow()
update_data['updated_at'] = datetime.utcnow()
await db.execute(
update(AdvertisingCampaign)
.where(AdvertisingCampaign.id == campaign.id)
.values(**update_data)
)
await db.execute(update(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id).values(**update_data))
await db.commit()
await db.refresh(campaign)
logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data)
logger.info('✏️ Обновлена рекламная кампания %s (%s)', campaign.name, update_data)
return campaign
async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
await db.execute(
delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id)
)
await db.execute(delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id))
await db.commit()
logger.info("🗑️ Удалена рекламная кампания %s", campaign.name)
logger.info('🗑️ Удалена рекламная кампания %s', campaign.name)
return True
async def get_campaign_registration_by_user(
db: AsyncSession,
user_id: int,
) -> Optional[AdvertisingCampaignRegistration]:
) -> AdvertisingCampaignRegistration | None:
result = await db.execute(
select(AdvertisingCampaignRegistration)
.options(selectinload(AdvertisingCampaignRegistration.campaign))
@@ -187,7 +188,9 @@ async def record_campaign_registration(
user_id: int,
bonus_type: str,
balance_bonus_kopeks: int = 0,
subscription_duration_days: Optional[int] = None,
subscription_duration_days: int | None = None,
tariff_id: int | None = None,
tariff_duration_days: int | None = None,
) -> AdvertisingCampaignRegistration:
existing = await db.execute(
select(AdvertisingCampaignRegistration).where(
@@ -207,19 +210,21 @@ async def record_campaign_registration(
bonus_type=bonus_type,
balance_bonus_kopeks=balance_bonus_kopeks or 0,
subscription_duration_days=subscription_duration_days,
tariff_id=tariff_id,
tariff_duration_days=tariff_duration_days,
)
db.add(registration)
await db.commit()
await db.refresh(registration)
logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id)
logger.info('📈 Регистрируем пользователя %s в кампании %s', user_id, campaign_id)
return registration
async def get_campaign_statistics(
db: AsyncSession,
campaign_id: int,
) -> Dict[str, Optional[int]]:
) -> dict[str, int | None]:
registrations_query = select(AdvertisingCampaignRegistration.user_id).where(
AdvertisingCampaignRegistration.campaign_id == campaign_id
)
@@ -228,9 +233,7 @@ async def get_campaign_statistics(
result = await db.execute(
select(
func.count(AdvertisingCampaignRegistration.id),
func.coalesce(
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
),
func.coalesce(func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0),
func.max(AdvertisingCampaignRegistration.created_at),
).where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
)
@@ -242,7 +245,7 @@ async def get_campaign_statistics(
select(func.count(AdvertisingCampaignRegistration.id)).where(
and_(
AdvertisingCampaignRegistration.campaign_id == campaign_id,
AdvertisingCampaignRegistration.bonus_type == "subscription",
AdvertisingCampaignRegistration.bonus_type == 'subscription',
)
)
)
@@ -295,11 +298,7 @@ async def get_campaign_statistics(
SubscriptionConversion.first_payment_amount_kopeks,
SubscriptionConversion.converted_at,
)
.where(
SubscriptionConversion.user_id.in_(
select(registrations_subquery.c.user_id)
)
)
.where(SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id)))
.order_by(SubscriptionConversion.converted_at)
)
conversion_entries = conversions_rows.all()
@@ -322,8 +321,8 @@ async def get_campaign_statistics(
subscription_payments_total = 0
paid_users_from_transactions = set()
conversion_user_ids = set()
first_payment_amount_by_user: Dict[int, int] = {}
first_payment_time_by_user: Dict[int, Optional[datetime]] = {}
first_payment_amount_by_user: dict[int, int] = {}
first_payment_time_by_user: dict[int, datetime | None] = {}
for user_id, amount_kopeks, converted_at in conversion_entries:
conversion_user_ids.add(user_id)
@@ -341,13 +340,8 @@ async def get_campaign_statistics(
first_payment_time_by_user[user_id] = created_at
else:
existing_time = first_payment_time_by_user.get(user_id)
if existing_time is None and created_at is not None:
first_payment_amount_by_user[user_id] = amount_value
first_payment_time_by_user[user_id] = created_at
elif (
existing_time is not None
and created_at is not None
and created_at < existing_time
if (existing_time is None and created_at is not None) or (
existing_time is not None and created_at is not None and created_at < existing_time
):
first_payment_amount_by_user[user_id] = amount_value
first_payment_time_by_user[user_id] = created_at
@@ -359,15 +353,11 @@ async def get_campaign_statistics(
paid_users_count = max(len(paid_user_ids), paid_users_from_flag)
conversion_count = conversion_count or len(paid_user_ids)
if conversion_count < len(paid_user_ids):
conversion_count = len(paid_user_ids)
conversion_count = max(conversion_count, len(paid_user_ids))
avg_first_payment = 0
if first_payment_amount_by_user:
avg_first_payment = int(
sum(first_payment_amount_by_user.values())
/ len(first_payment_amount_by_user)
)
avg_first_payment = int(sum(first_payment_amount_by_user.values()) / len(first_payment_amount_by_user))
conversion_rate = 0.0
if count:
@@ -442,50 +432,44 @@ async def get_campaign_statistics(
avg_revenue_per_user = int(total_revenue / count)
return {
"registrations": count,
"balance_issued": total_balance,
"subscription_issued": subscription_bonuses_issued,
"last_registration": last_registration,
"total_revenue_kopeks": total_revenue,
"trial_users_count": trial_users_count,
"active_trials_count": active_trials_count,
"conversion_count": conversion_count,
"paid_users_count": paid_users_count,
"conversion_rate": conversion_rate,
"trial_conversion_rate": trial_conversion_rate,
"avg_revenue_per_user_kopeks": avg_revenue_per_user,
"avg_first_payment_kopeks": avg_first_payment,
'registrations': count,
'balance_issued': total_balance,
'subscription_issued': subscription_bonuses_issued,
'last_registration': last_registration,
'total_revenue_kopeks': total_revenue,
'trial_users_count': trial_users_count,
'active_trials_count': active_trials_count,
'conversion_count': conversion_count,
'paid_users_count': paid_users_count,
'conversion_rate': conversion_rate,
'trial_conversion_rate': trial_conversion_rate,
'avg_revenue_per_user_kopeks': avg_revenue_per_user,
'avg_first_payment_kopeks': avg_first_payment,
}
async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]:
async def get_campaigns_overview(db: AsyncSession) -> dict[str, int]:
total = await get_campaigns_count(db)
active = await get_campaigns_count(db, is_active=True)
inactive = await get_campaigns_count(db, is_active=False)
registrations_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id))
)
registrations_result = await db.execute(select(func.count(AdvertisingCampaignRegistration.id)))
balance_result = await db.execute(
select(
func.coalesce(
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
)
)
select(func.coalesce(func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0))
)
subscription_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.bonus_type == "subscription"
AdvertisingCampaignRegistration.bonus_type == 'subscription'
)
)
return {
"total": total,
"active": active,
"inactive": inactive,
"registrations": registrations_result.scalar() or 0,
"balance_total": balance_result.scalar() or 0,
"subscription_total": subscription_result.scalar() or 0,
'total': total,
'active': active,
'inactive': inactive,
'registrations': registrations_result.scalar() or 0,
'balance_total': balance_result.scalar() or 0,
'subscription_total': subscription_result.scalar() or 0,
}
+238
View File
@@ -0,0 +1,238 @@
"""CRUD operations for CloudPayments payments."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import CloudPaymentsPayment
logger = logging.getLogger(__name__)
async def create_cloudpayments_payment(
db: AsyncSession,
*,
user_id: int,
invoice_id: str,
amount_kopeks: int,
description: str | None = None,
currency: str = 'RUB',
payment_url: str | None = None,
email: str | None = None,
metadata: dict[str, Any] | None = None,
test_mode: bool = False,
) -> CloudPaymentsPayment:
"""
Create a new CloudPayments payment record.
Args:
db: Database session
user_id: Internal user ID
invoice_id: Unique invoice ID
amount_kopeks: Amount in kopeks
description: Payment description
currency: Currency code (default RUB)
payment_url: Payment widget URL
email: User's email
metadata: Additional metadata
test_mode: Whether this is a test payment
Returns:
Created CloudPaymentsPayment object
"""
payment = CloudPaymentsPayment(
user_id=user_id,
invoice_id=invoice_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status='pending',
is_paid=False,
payment_url=payment_url,
email=email,
metadata_json=metadata,
test_mode=test_mode,
)
db.add(payment)
await db.flush()
await db.refresh(payment)
logger.debug(
'Created CloudPayments payment: id=%s, invoice=%s, amount=%s',
payment.id,
invoice_id,
amount_kopeks,
)
return payment
async def get_cloudpayments_payment_by_invoice_id(
db: AsyncSession,
invoice_id: str,
) -> CloudPaymentsPayment | None:
"""Get CloudPayments payment by invoice ID."""
result = await db.execute(select(CloudPaymentsPayment).where(CloudPaymentsPayment.invoice_id == invoice_id))
return result.scalars().first()
async def get_cloudpayments_payment_by_id(
db: AsyncSession,
payment_id: int,
) -> CloudPaymentsPayment | None:
"""Get CloudPayments payment by internal ID."""
result = await db.execute(select(CloudPaymentsPayment).where(CloudPaymentsPayment.id == payment_id))
return result.scalars().first()
async def get_cloudpayments_payment_by_transaction_id(
db: AsyncSession,
transaction_id_cp: int,
) -> CloudPaymentsPayment | None:
"""Get CloudPayments payment by CloudPayments transaction ID."""
result = await db.execute(
select(CloudPaymentsPayment).where(CloudPaymentsPayment.transaction_id_cp == transaction_id_cp)
)
return result.scalars().first()
async def update_cloudpayments_payment(
db: AsyncSession,
payment_id: int,
**kwargs: Any,
) -> CloudPaymentsPayment | None:
"""
Update CloudPayments payment record.
Args:
db: Database session
payment_id: Internal payment ID
**kwargs: Fields to update
Returns:
Updated payment or None if not found
"""
payment = await get_cloudpayments_payment_by_id(db, payment_id)
if not payment:
return None
for key, value in kwargs.items():
if hasattr(payment, key):
setattr(payment, key, value)
payment.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(payment)
return payment
async def mark_cloudpayments_payment_as_paid(
db: AsyncSession,
payment_id: int,
*,
transaction_id_cp: int | None = None,
token: str | None = None,
card_first_six: str | None = None,
card_last_four: str | None = None,
card_type: str | None = None,
card_exp_date: str | None = None,
email: str | None = None,
callback_payload: dict[str, Any] | None = None,
) -> CloudPaymentsPayment | None:
"""
Mark CloudPayments payment as paid.
Args:
db: Database session
payment_id: Internal payment ID
transaction_id_cp: CloudPayments transaction ID
token: Card token for recurrent payments
card_first_six: First 6 digits of card
card_last_four: Last 4 digits of card
card_type: Card type (Visa, MasterCard, etc.)
card_exp_date: Card expiration date
email: Payer's email
callback_payload: Full webhook payload
Returns:
Updated payment or None if not found
"""
payment = await get_cloudpayments_payment_by_id(db, payment_id)
if not payment:
return None
payment.status = 'completed'
payment.is_paid = True
payment.paid_at = datetime.utcnow()
if transaction_id_cp is not None:
payment.transaction_id_cp = transaction_id_cp
if token:
payment.token = token
if card_first_six:
payment.card_first_six = card_first_six
if card_last_four:
payment.card_last_four = card_last_four
if card_type:
payment.card_type = card_type
if card_exp_date:
payment.card_exp_date = card_exp_date
if email:
payment.email = email
if callback_payload:
payment.callback_payload = callback_payload
payment.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(payment)
logger.info(
'Marked CloudPayments payment as paid: id=%s, invoice=%s',
payment.id,
payment.invoice_id,
)
return payment
async def link_cloudpayments_payment_to_transaction(
db: AsyncSession,
payment_id: int,
transaction_id: int,
) -> CloudPaymentsPayment | None:
"""Link CloudPayments payment to internal transaction."""
payment = await get_cloudpayments_payment_by_id(db, payment_id)
if not payment:
return None
payment.transaction_id = transaction_id
await db.flush()
await db.refresh(payment)
return payment
async def get_user_cloudpayments_payments(
db: AsyncSession,
user_id: int,
*,
limit: int = 10,
offset: int = 0,
) -> list[CloudPaymentsPayment]:
"""Get CloudPayments payments for a user."""
result = await db.execute(
select(CloudPaymentsPayment)
.where(CloudPaymentsPayment.user_id == user_id)
.order_by(CloudPaymentsPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
+225
View File
@@ -0,0 +1,225 @@
import logging
from collections.abc import Sequence
from datetime import datetime
from sqlalchemy import and_, delete, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import ContestAttempt, ContestRound, ContestTemplate, User
logger = logging.getLogger(__name__)
# Templates
async def get_template_by_id(db: AsyncSession, template_id: int) -> ContestTemplate | None:
result = await db.execute(select(ContestTemplate).where(ContestTemplate.id == template_id))
return result.scalar_one_or_none()
async def get_template_by_slug(db: AsyncSession, slug: str) -> ContestTemplate | None:
result = await db.execute(select(ContestTemplate).where(ContestTemplate.slug == slug))
return result.scalar_one_or_none()
async def list_templates(db: AsyncSession, enabled_only: bool = True) -> list[ContestTemplate]:
query = select(ContestTemplate).order_by(ContestTemplate.id)
if enabled_only:
query = query.where(ContestTemplate.is_enabled.is_(True))
result = await db.execute(query)
return list(result.scalars().all())
async def upsert_template(
db: AsyncSession,
*,
slug: str,
name: str,
description: str = '',
prize_type: str = 'days',
prize_value: str = '1',
max_winners: int = 1,
attempts_per_user: int = 1,
times_per_day: int = 1,
schedule_times: str | None = None,
cooldown_hours: int = 24,
payload: dict | None = None,
is_enabled: bool | None = None,
) -> ContestTemplate:
template = await get_template_by_slug(db, slug)
if not template:
template = ContestTemplate(slug=slug)
db.add(template)
template.name = name
template.description = description
template.prize_type = prize_type
template.prize_value = prize_value
template.max_winners = max_winners
template.attempts_per_user = attempts_per_user
template.times_per_day = times_per_day
template.schedule_times = schedule_times
template.cooldown_hours = cooldown_hours
template.payload = payload or {}
if is_enabled is not None:
template.is_enabled = is_enabled
await db.commit()
await db.refresh(template)
return template
async def update_template_fields(
db: AsyncSession,
template: ContestTemplate,
**fields: object,
) -> ContestTemplate:
for key, value in fields.items():
if hasattr(template, key):
setattr(template, key, value)
await db.commit()
await db.refresh(template)
return template
# Rounds
async def create_round(
db: AsyncSession,
*,
template: ContestTemplate,
starts_at: datetime,
ends_at: datetime,
payload: dict,
) -> ContestRound:
round_obj = ContestRound(
template_id=template.id,
starts_at=starts_at,
ends_at=ends_at,
status='active',
payload=payload,
max_winners=template.max_winners,
attempts_per_user=template.attempts_per_user,
)
db.add(round_obj)
await db.commit()
await db.refresh(round_obj)
return round_obj
async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
now = datetime.utcnow()
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
.where(
and_(
ContestRound.status == 'active',
ContestRound.starts_at <= now,
ContestRound.ends_at >= now,
)
)
.order_by(ContestRound.starts_at)
)
return list(result.scalars().all())
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> ContestRound | None:
now = datetime.utcnow()
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
.where(
and_(
ContestRound.template_id == template_id,
ContestRound.status == 'active',
ContestRound.starts_at <= now,
ContestRound.ends_at >= now,
)
)
.order_by(desc(ContestRound.starts_at))
)
return result.scalars().first()
async def finish_round(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
round_obj.status = 'finished'
await db.commit()
await db.refresh(round_obj)
return round_obj
async def increment_winner_count(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
round_obj.winners_count += 1
await db.commit()
await db.refresh(round_obj)
return round_obj
# Attempts
async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> ContestAttempt | None:
result = await db.execute(
select(ContestAttempt).where(
and_(
ContestAttempt.round_id == round_id,
ContestAttempt.user_id == user_id,
)
)
)
return result.scalar_one_or_none()
async def create_attempt(
db: AsyncSession,
*,
round_id: int,
user_id: int,
answer: str | None,
is_winner: bool,
) -> ContestAttempt:
attempt = ContestAttempt(
round_id=round_id,
user_id=user_id,
answer=answer,
is_winner=is_winner,
)
db.add(attempt)
await db.commit()
await db.refresh(attempt)
return attempt
async def update_attempt(
db: AsyncSession,
attempt: ContestAttempt,
*,
answer: str | None = None,
is_winner: bool = False,
) -> ContestAttempt:
"""Update existing attempt with answer and winner status."""
if answer is not None:
attempt.answer = answer
attempt.is_winner = is_winner
await db.commit()
await db.refresh(attempt)
return attempt
async def clear_attempts(db: AsyncSession, round_id: int) -> int:
result = await db.execute(delete(ContestAttempt).where(ContestAttempt.round_id == round_id))
deleted_count = result.rowcount
await db.commit()
return deleted_count
async def list_winners(db: AsyncSession, round_id: int) -> Sequence[tuple[User, ContestAttempt]]:
result = await db.execute(
select(User, ContestAttempt)
.join(ContestAttempt, ContestAttempt.user_id == User.id)
.where(
and_(
ContestAttempt.round_id == round_id,
ContestAttempt.is_winner.is_(True),
)
)
)
return result.all()
+38 -67
View File
@@ -1,12 +1,13 @@
import logging
from datetime import datetime
from typing import Optional, List
from sqlalchemy import select, and_
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import CryptoBotPayment
logger = logging.getLogger(__name__)
@@ -16,14 +17,13 @@ async def create_cryptobot_payment(
invoice_id: str,
amount: str,
asset: str,
status: str = "active",
description: Optional[str] = None,
payload: Optional[str] = None,
bot_invoice_url: Optional[str] = None,
mini_app_invoice_url: Optional[str] = None,
web_app_invoice_url: Optional[str] = None
status: str = 'active',
description: str | None = None,
payload: str | None = None,
bot_invoice_url: str | None = None,
mini_app_invoice_url: str | None = None,
web_app_invoice_url: str | None = None,
) -> CryptoBotPayment:
payment = CryptoBotPayment(
user_id=user_id,
invoice_id=invoice_id,
@@ -34,22 +34,18 @@ async def create_cryptobot_payment(
payload=payload,
bot_invoice_url=bot_invoice_url,
mini_app_invoice_url=mini_app_invoice_url,
web_app_invoice_url=web_app_invoice_url
web_app_invoice_url=web_app_invoice_url,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f"Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}")
logger.info(f'Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}')
return payment
async def get_cryptobot_payment_by_invoice_id(
db: AsyncSession,
invoice_id: str
) -> Optional[CryptoBotPayment]:
async def get_cryptobot_payment_by_invoice_id(db: AsyncSession, invoice_id: str) -> CryptoBotPayment | None:
result = await db.execute(
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
@@ -58,72 +54,55 @@ async def get_cryptobot_payment_by_invoice_id(
return result.scalar_one_or_none()
async def get_cryptobot_payment_by_id(
db: AsyncSession,
payment_id: int
) -> Optional[CryptoBotPayment]:
async def get_cryptobot_payment_by_id(db: AsyncSession, payment_id: int) -> CryptoBotPayment | None:
result = await db.execute(
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.where(CryptoBotPayment.id == payment_id)
select(CryptoBotPayment).options(selectinload(CryptoBotPayment.user)).where(CryptoBotPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def update_cryptobot_payment_status(
db: AsyncSession,
invoice_id: str,
status: str,
paid_at: Optional[datetime] = None
) -> Optional[CryptoBotPayment]:
db: AsyncSession, invoice_id: str, status: str, paid_at: datetime | None = None
) -> CryptoBotPayment | None:
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
if not payment:
return None
payment.status = status
payment.updated_at = datetime.utcnow()
if status == "paid" and paid_at:
if status == 'paid' and paid_at:
payment.paid_at = paid_at
await db.commit()
await db.refresh(payment)
logger.info(f"Обновлен статус CryptoBot платежа {invoice_id}: {status}")
logger.info(f'Обновлен статус CryptoBot платежа {invoice_id}: {status}')
return payment
async def link_cryptobot_payment_to_transaction(
db: AsyncSession,
invoice_id: str,
transaction_id: int
) -> Optional[CryptoBotPayment]:
db: AsyncSession, invoice_id: str, transaction_id: int
) -> CryptoBotPayment | None:
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
if not payment:
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
logger.info(f"Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}")
logger.info(f'Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}')
return payment
async def get_user_cryptobot_payments(
db: AsyncSession,
user_id: int,
limit: int = 50,
offset: int = 0
) -> List[CryptoBotPayment]:
db: AsyncSession, user_id: int, limit: int = 50, offset: int = 0
) -> list[CryptoBotPayment]:
result = await db.execute(
select(CryptoBotPayment)
.where(CryptoBotPayment.user_id == user_id)
@@ -134,23 +113,15 @@ async def get_user_cryptobot_payments(
return result.scalars().all()
async def get_pending_cryptobot_payments(
db: AsyncSession,
older_than_hours: int = 24
) -> List[CryptoBotPayment]:
async def get_pending_cryptobot_payments(db: AsyncSession, older_than_hours: int = 24) -> list[CryptoBotPayment]:
from datetime import timedelta
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute(
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.where(
and_(
CryptoBotPayment.status == "active",
CryptoBotPayment.created_at < cutoff_time
)
)
.where(and_(CryptoBotPayment.status == 'active', CryptoBotPayment.created_at < cutoff_time))
.order_by(CryptoBotPayment.created_at)
)
return result.scalars().all()
+38 -38
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import List, Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +10,7 @@ from sqlalchemy.orm import selectinload
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.models import DiscountOffer
logger = logging.getLogger(__name__)
@@ -18,13 +18,13 @@ async def upsert_discount_offer(
db: AsyncSession,
*,
user_id: int,
subscription_id: Optional[int],
subscription_id: int | None,
notification_type: str,
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
effect_type: str = "percent_discount",
extra_data: Optional[dict] = None,
effect_type: str = 'percent_discount',
extra_data: dict | None = None,
) -> DiscountOffer:
"""Create or refresh a discount offer for a user."""
@@ -35,7 +35,7 @@ async def upsert_discount_offer(
.where(
DiscountOffer.user_id == user_id,
DiscountOffer.notification_type == notification_type,
DiscountOffer.is_active == True, # noqa: E712
DiscountOffer.is_active == True,
)
.order_by(DiscountOffer.created_at.desc())
)
@@ -67,7 +67,7 @@ async def upsert_discount_offer(
return offer
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> DiscountOffer | None:
result = await db.execute(
select(DiscountOffer)
.options(
@@ -84,10 +84,10 @@ async def list_discount_offers(
*,
offset: int = 0,
limit: int = 50,
user_id: Optional[int] = None,
notification_type: Optional[str] = None,
is_active: Optional[bool] = None,
) -> List[DiscountOffer]:
user_id: int | None = None,
notification_type: str | None = None,
is_active: bool | None = None,
) -> list[DiscountOffer]:
stmt = (
select(DiscountOffer)
.options(
@@ -113,7 +113,7 @@ async def list_discount_offers(
async def list_active_discount_offers_for_user(
db: AsyncSession,
user_id: int,
) -> List[DiscountOffer]:
) -> list[DiscountOffer]:
"""Return active (not yet claimed) offers for a user."""
now = datetime.utcnow()
@@ -125,7 +125,7 @@ async def list_active_discount_offers_for_user(
)
.where(
DiscountOffer.user_id == user_id,
DiscountOffer.is_active == True, # noqa: E712
DiscountOffer.is_active == True,
DiscountOffer.expires_at > now,
)
.order_by(DiscountOffer.expires_at.asc())
@@ -138,9 +138,9 @@ async def list_active_discount_offers_for_user(
async def count_discount_offers(
db: AsyncSession,
*,
user_id: Optional[int] = None,
notification_type: Optional[str] = None,
is_active: Optional[bool] = None,
user_id: int | None = None,
notification_type: str | None = None,
is_active: bool | None = None,
) -> int:
stmt = select(func.count(DiscountOffer.id))
@@ -159,7 +159,7 @@ async def mark_offer_claimed(
db: AsyncSession,
offer: DiscountOffer,
*,
details: Optional[dict] = None,
details: dict | None = None,
) -> DiscountOffer:
offer.claimed_at = datetime.utcnow()
offer.is_active = False
@@ -171,7 +171,7 @@ async def mark_offer_claimed(
db,
user_id=offer.user_id,
offer_id=offer.id,
action="claimed",
action='claimed',
source=offer.notification_type,
percent=offer.discount_percent,
effect_type=offer.effect_type,
@@ -179,7 +179,7 @@ async def mark_offer_claimed(
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer claim log for offer %s: %s",
'Failed to record promo offer claim log for offer %s: %s',
offer.id,
exc,
)
@@ -187,7 +187,7 @@ async def mark_offer_claimed(
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer claim log failure: %s",
'Failed to rollback session after promo offer claim log failure: %s',
rollback_error,
)
@@ -198,7 +198,7 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(DiscountOffer).where(
DiscountOffer.is_active == True, # noqa: E712
DiscountOffer.is_active == True,
DiscountOffer.expires_at < now,
)
)
@@ -213,41 +213,41 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
count += 1
log_payloads.append(
{
"user_id": offer.user_id,
"offer_id": offer.id,
"source": offer.notification_type,
"percent": offer.discount_percent,
"effect_type": offer.effect_type,
'user_id': offer.user_id,
'offer_id': offer.id,
'source': offer.notification_type,
'percent': offer.discount_percent,
'effect_type': offer.effect_type,
}
)
await db.commit()
for payload in log_payloads:
if not payload.get("user_id"):
if not payload.get('user_id'):
continue
try:
await log_promo_offer_action(
db,
user_id=payload["user_id"],
offer_id=payload["offer_id"],
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details={"reason": "offer_expired"},
user_id=payload['user_id'],
offer_id=payload['offer_id'],
action='disabled',
source=payload.get('source'),
percent=payload.get('percent'),
effect_type=payload.get('effect_type'),
details={'reason': 'offer_expired'},
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer disable log for offer %s: %s",
payload.get("offer_id"),
'Failed to record promo offer disable log for offer %s: %s',
payload.get('offer_id'),
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer disable log failure: %s",
'Failed to rollback session after promo offer disable log failure: %s',
rollback_error,
)
@@ -257,8 +257,8 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
async def get_latest_claimed_offer_for_user(
db: AsyncSession,
user_id: int,
source: Optional[str] = None,
) -> Optional[DiscountOffer]:
source: str | None = None,
) -> DiscountOffer | None:
stmt = (
select(DiscountOffer)
.where(
+17 -23
View File
@@ -1,19 +1,18 @@
import logging
from collections.abc import Iterable
from datetime import datetime
from typing import Iterable, Optional
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FaqPage, FaqSetting
logger = logging.getLogger(__name__)
async def get_faq_setting(db: AsyncSession, language: str) -> Optional[FaqSetting]:
result = await db.execute(
select(FaqSetting).where(FaqSetting.language == language)
)
async def get_faq_setting(db: AsyncSession, language: str) -> FaqSetting | None:
result = await db.execute(select(FaqSetting).where(FaqSetting.language == language))
return result.scalar_one_or_none()
@@ -34,9 +33,9 @@ async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> Faq
await db.refresh(setting)
logger.info(
"✅ Статус FAQ для языка %s обновлен: %s",
'✅ Статус FAQ для языка %s обновлен: %s',
language,
"enabled" if setting.is_enabled else "disabled",
'enabled' if setting.is_enabled else 'disabled',
)
return setting
@@ -64,7 +63,7 @@ async def get_faq_pages(
return pages
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> Optional[FaqPage]:
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> FaqPage | None:
result = await db.execute(select(FaqPage).where(FaqPage.id == page_id))
return result.scalar_one_or_none()
@@ -75,13 +74,11 @@ async def create_faq_page(
language: str,
title: str,
content: str,
display_order: Optional[int] = None,
display_order: int | None = None,
is_active: bool = True,
) -> FaqPage:
if display_order is None:
result = await db.execute(
select(func.max(FaqPage.display_order)).where(FaqPage.language == language)
)
result = await db.execute(select(func.max(FaqPage.display_order)).where(FaqPage.language == language))
max_order = result.scalar() or 0
display_order = max_order + 1
@@ -97,7 +94,7 @@ async def create_faq_page(
await db.commit()
await db.refresh(page)
logger.info("✅ Создана страница FAQ %s для языка %s", page.id, language)
logger.info('✅ Создана страница FAQ %s для языка %s', page.id, language)
return page
@@ -106,10 +103,10 @@ async def update_faq_page(
db: AsyncSession,
page: FaqPage,
*,
title: Optional[str] = None,
content: Optional[str] = None,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
title: str | None = None,
content: str | None = None,
display_order: int | None = None,
is_active: bool | None = None,
) -> FaqPage:
if title is not None:
page.title = title
@@ -125,7 +122,7 @@ async def update_faq_page(
await db.commit()
await db.refresh(page)
logger.info("✅ Страница FAQ %s обновлена", page.id)
logger.info('✅ Страница FAQ %s обновлена', page.id)
return page
@@ -133,7 +130,7 @@ async def update_faq_page(
async def delete_faq_page(db: AsyncSession, page_id: int) -> None:
await db.execute(delete(FaqPage).where(FaqPage.id == page_id))
await db.commit()
logger.info("🗑️ Страница FAQ %s удалена", page_id)
logger.info('🗑️ Страница FAQ %s удалена', page_id)
async def bulk_update_order(
@@ -142,9 +139,6 @@ async def bulk_update_order(
) -> None:
for page_id, order in pages:
await db.execute(
update(FaqPage)
.where(FaqPage.id == page_id)
.values(display_order=order, updated_at=datetime.utcnow())
update(FaqPage).where(FaqPage.id == page_id).values(display_order=order, updated_at=datetime.utcnow())
)
await db.commit()
+140
View File
@@ -0,0 +1,140 @@
"""CRUD операции для платежей Freekassa."""
import json
import logging
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FreekassaPayment
logger = logging.getLogger(__name__)
async def create_freekassa_payment(
db: AsyncSession,
*,
user_id: int,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
expires_at: datetime | None = None,
metadata_json: str | None = None,
) -> FreekassaPayment:
"""Создает запись о платеже Freekassa."""
payment = FreekassaPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
expires_at=expires_at,
metadata_json=json.loads(metadata_json) if metadata_json else None,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f'Создан платеж Freekassa: order_id={order_id}, user_id={user_id}')
return payment
async def get_freekassa_payment_by_order_id(db: AsyncSession, order_id: str) -> FreekassaPayment | None:
"""Получает платеж по order_id."""
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_freekassa_payment_by_fk_order_id(db: AsyncSession, freekassa_order_id: str) -> FreekassaPayment | None:
"""Получает платеж по ID от Freekassa (intid)."""
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.freekassa_order_id == freekassa_order_id))
return result.scalar_one_or_none()
async def get_freekassa_payment_by_id(db: AsyncSession, payment_id: int) -> FreekassaPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.id == payment_id))
return result.scalar_one_or_none()
async def update_freekassa_payment_status(
db: AsyncSession,
payment: FreekassaPayment,
*,
status: str,
is_paid: bool = False,
freekassa_order_id: str | None = None,
payment_system_id: int | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> FreekassaPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.is_paid = is_paid
payment.updated_at = datetime.utcnow()
if is_paid:
payment.paid_at = datetime.utcnow()
if freekassa_order_id:
payment.freekassa_order_id = freekassa_order_id
if payment_system_id is not None:
payment.payment_system_id = payment_system_id
if callback_payload:
payment.callback_payload = callback_payload
if transaction_id:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(f'Обновлен статус платежа Freekassa: order_id={payment.order_id}, status={status}, is_paid={is_paid}')
return payment
async def get_pending_freekassa_payments(db: AsyncSession, user_id: int) -> list[FreekassaPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(FreekassaPayment).where(
FreekassaPayment.user_id == user_id,
FreekassaPayment.status == 'pending',
FreekassaPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_user_freekassa_payments(
db: AsyncSession,
user_id: int,
limit: int = 10,
offset: int = 0,
) -> list[FreekassaPayment]:
"""Получает платежи пользователя с пагинацией."""
result = await db.execute(
select(FreekassaPayment)
.where(FreekassaPayment.user_id == user_id)
.order_by(FreekassaPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
async def get_expired_pending_payments(
db: AsyncSession,
) -> list[FreekassaPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.utcnow()
result = await db.execute(
select(FreekassaPayment).where(
FreekassaPayment.status == 'pending',
FreekassaPayment.is_paid == False,
FreekassaPayment.expires_at < now,
)
)
return list(result.scalars().all())
+171
View File
@@ -0,0 +1,171 @@
import logging
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import HeleketPayment
logger = logging.getLogger(__name__)
async def create_heleket_payment(
db: AsyncSession,
*,
user_id: int,
uuid: str,
order_id: str,
amount: str,
currency: str,
status: str,
payer_amount: str | None = None,
payer_currency: str | None = None,
exchange_rate: float | None = None,
discount_percent: int | None = None,
payment_url: str | None = None,
expires_at: datetime | None = None,
metadata: dict[str, Any] | None = None,
) -> HeleketPayment:
payment = HeleketPayment(
user_id=user_id,
uuid=uuid,
order_id=order_id,
amount=amount,
currency=currency,
status=status,
payer_amount=payer_amount,
payer_currency=payer_currency,
exchange_rate=exchange_rate,
discount_percent=discount_percent,
payment_url=payment_url,
expires_at=expires_at,
metadata_json=metadata or {},
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
'Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s',
uuid,
order_id,
amount,
currency,
user_id,
)
return payment
async def get_heleket_payment_by_uuid(
db: AsyncSession,
uuid: str,
) -> HeleketPayment | None:
result = await db.execute(
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.uuid == uuid)
)
return result.scalar_one_or_none()
async def get_heleket_payment_by_order_id(
db: AsyncSession,
order_id: str,
) -> HeleketPayment | None:
result = await db.execute(
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.order_id == order_id)
)
return result.scalar_one_or_none()
async def get_heleket_payment_by_id(
db: AsyncSession,
payment_id: int,
) -> HeleketPayment | None:
result = await db.execute(
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def update_heleket_payment(
db: AsyncSession,
uuid: str,
*,
status: str | None = None,
payer_amount: str | None = None,
payer_currency: str | None = None,
exchange_rate: float | None = None,
discount_percent: int | None = None,
paid_at: datetime | None = None,
payment_url: str | None = None,
metadata: dict[str, Any] | None = None,
) -> HeleketPayment | None:
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error('Heleket платеж с uuid=%s не найден', uuid)
return None
if status is not None:
payment.status = status
if payer_amount is not None:
payment.payer_amount = payer_amount
if payer_currency is not None:
payment.payer_currency = payer_currency
if exchange_rate is not None:
payment.exchange_rate = exchange_rate
if discount_percent is not None:
payment.discount_percent = discount_percent
if payment_url is not None:
payment.payment_url = payment_url
if metadata is not None:
existing = dict(payment.metadata_json or {})
existing.update(metadata)
payment.metadata_json = existing
if paid_at is not None:
payment.paid_at = paid_at
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s',
uuid,
payment.status,
payment.payer_amount,
payment.payer_currency,
)
return payment
async def link_heleket_payment_to_transaction(
db: AsyncSession,
uuid: str,
transaction_id: int,
) -> HeleketPayment | None:
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error('Не найден Heleket платеж для связи с транзакцией: %s', uuid)
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
logger.info(
'Heleket платеж %s связан с транзакцией %s',
uuid,
transaction_id,
)
return payment
+142
View File
@@ -0,0 +1,142 @@
"""CRUD операции для платежей KassaAI."""
import json
import logging
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import KassaAiPayment
logger = logging.getLogger(__name__)
async def create_kassa_ai_payment(
db: AsyncSession,
*,
user_id: int,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_system_id: int | None = None,
expires_at: datetime | None = None,
metadata_json: str | None = None,
) -> KassaAiPayment:
"""Создает запись о платеже KassaAI."""
payment = KassaAiPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_system_id=payment_system_id,
expires_at=expires_at,
metadata_json=json.loads(metadata_json) if metadata_json else None,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f'Создан платеж KassaAI: order_id={order_id}, user_id={user_id}')
return payment
async def get_kassa_ai_payment_by_order_id(db: AsyncSession, order_id: str) -> KassaAiPayment | None:
"""Получает платеж по order_id."""
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_kassa_ai_payment_by_external_order_id(db: AsyncSession, kassa_ai_order_id: str) -> KassaAiPayment | None:
"""Получает платеж по ID от KassaAI (orderId)."""
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.kassa_ai_order_id == kassa_ai_order_id))
return result.scalar_one_or_none()
async def get_kassa_ai_payment_by_id(db: AsyncSession, payment_id: int) -> KassaAiPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.id == payment_id))
return result.scalar_one_or_none()
async def update_kassa_ai_payment_status(
db: AsyncSession,
payment: KassaAiPayment,
*,
status: str,
is_paid: bool = False,
kassa_ai_order_id: str | None = None,
payment_system_id: int | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> KassaAiPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.is_paid = is_paid
payment.updated_at = datetime.utcnow()
if is_paid:
payment.paid_at = datetime.utcnow()
if kassa_ai_order_id:
payment.kassa_ai_order_id = kassa_ai_order_id
if payment_system_id is not None:
payment.payment_system_id = payment_system_id
if callback_payload:
payment.callback_payload = callback_payload
if transaction_id:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(f'Обновлен статус платежа KassaAI: order_id={payment.order_id}, status={status}, is_paid={is_paid}')
return payment
async def get_pending_kassa_ai_payments(db: AsyncSession, user_id: int) -> list[KassaAiPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(KassaAiPayment).where(
KassaAiPayment.user_id == user_id,
KassaAiPayment.status == 'pending',
KassaAiPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_user_kassa_ai_payments(
db: AsyncSession,
user_id: int,
limit: int = 10,
offset: int = 0,
) -> list[KassaAiPayment]:
"""Получает платежи пользователя с пагинацией."""
result = await db.execute(
select(KassaAiPayment)
.where(KassaAiPayment.user_id == user_id)
.order_by(KassaAiPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
async def get_expired_pending_kassa_ai_payments(
db: AsyncSession,
) -> list[KassaAiPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.utcnow()
result = await db.execute(
select(KassaAiPayment).where(
KassaAiPayment.status == 'pending',
KassaAiPayment.is_paid == False,
KassaAiPayment.expires_at < now,
)
)
return list(result.scalars().all())
+12 -19
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Optional, Sequence
from collections.abc import Sequence
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,8 +20,8 @@ async def count_main_menu_buttons(db: AsyncSession) -> int:
async def get_main_menu_buttons(
db: AsyncSession,
*,
limit: Optional[int] = None,
offset: Optional[int] = None,
limit: int | None = None,
offset: int | None = None,
) -> list[MainMenuButton]:
stmt = select(MainMenuButton).order_by(
MainMenuButton.display_order.asc(),
@@ -37,12 +37,8 @@ async def get_main_menu_buttons(
return list(result.scalars().all())
async def get_main_menu_button_by_id(
db: AsyncSession, button_id: int
) -> MainMenuButton | None:
result = await db.execute(
select(MainMenuButton).where(MainMenuButton.id == button_id)
)
async def get_main_menu_button_by_id(db: AsyncSession, button_id: int) -> MainMenuButton | None:
result = await db.execute(select(MainMenuButton).where(MainMenuButton.id == button_id))
return result.scalar_one_or_none()
@@ -68,7 +64,7 @@ async def create_main_menu_button(
action_value: str,
visibility: MainMenuButtonVisibility | str = MainMenuButtonVisibility.ALL,
is_active: bool = True,
display_order: Optional[int] = None,
display_order: int | None = None,
) -> MainMenuButton:
if display_order is None:
display_order = await get_next_display_order(db)
@@ -77,8 +73,7 @@ async def create_main_menu_button(
text=text,
action_type=_enum_value(action_type, MainMenuButtonActionType),
action_value=action_value,
visibility=_enum_value(visibility, MainMenuButtonVisibility)
or MainMenuButtonVisibility.ALL.value,
visibility=_enum_value(visibility, MainMenuButtonVisibility) or MainMenuButtonVisibility.ALL.value,
is_active=bool(is_active),
display_order=int(display_order),
)
@@ -93,12 +88,12 @@ async def update_main_menu_button(
db: AsyncSession,
button: MainMenuButton,
*,
text: Optional[str] = None,
text: str | None = None,
action_type: MainMenuButtonActionType | str | None = None,
action_value: Optional[str] = None,
action_value: str | None = None,
visibility: MainMenuButtonVisibility | str | None = None,
is_active: Optional[bool] = None,
display_order: Optional[int] = None,
is_active: bool | None = None,
display_order: int | None = None,
) -> MainMenuButton:
if text is not None:
button.text = text
@@ -132,9 +127,7 @@ async def reorder_main_menu_buttons(
order_map = {int(button_id): index for index, button_id in enumerate(ordered_ids)}
result = await db.execute(
select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys()))
)
result = await db.execute(select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys())))
buttons = result.scalars().all()
for button in buttons:
+31 -29
View File
@@ -1,6 +1,5 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import MulenPayPayment
logger = logging.getLogger(__name__)
@@ -18,11 +18,11 @@ async def create_mulenpay_payment(
amount_kopeks: int,
uuid: str,
description: str,
payment_url: Optional[str],
mulen_payment_id: Optional[int],
payment_url: str | None,
mulen_payment_id: int | None,
currency: str,
status: str,
metadata: Optional[dict] = None,
metadata: dict | None = None,
) -> MulenPayPayment:
payment = MulenPayPayment(
user_id=user_id,
@@ -41,7 +41,7 @@ async def create_mulenpay_payment(
await db.refresh(payment)
logger.info(
"Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
'Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s',
settings.get_mulenpay_display_name(),
payment.mulen_payment_id,
uuid,
@@ -52,32 +52,18 @@ async def create_mulenpay_payment(
return payment
async def get_mulenpay_payment_by_local_id(
db: AsyncSession, payment_id: int
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment).where(MulenPayPayment.id == payment_id)
)
async def get_mulenpay_payment_by_local_id(db: AsyncSession, payment_id: int) -> MulenPayPayment | None:
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_mulenpay_payment_by_uuid(
db: AsyncSession, uuid: str
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment).where(MulenPayPayment.uuid == uuid)
)
async def get_mulenpay_payment_by_uuid(db: AsyncSession, uuid: str) -> MulenPayPayment | None:
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.uuid == uuid))
return result.scalar_one_or_none()
async def get_mulenpay_payment_by_mulen_id(
db: AsyncSession, mulen_payment_id: int
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment).where(
MulenPayPayment.mulen_payment_id == mulen_payment_id
)
)
async def get_mulenpay_payment_by_mulen_id(db: AsyncSession, mulen_payment_id: int) -> MulenPayPayment | None:
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.mulen_payment_id == mulen_payment_id))
return result.scalar_one_or_none()
@@ -86,10 +72,11 @@ async def update_mulenpay_payment_status(
*,
payment: MulenPayPayment,
status: str,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
callback_payload: Optional[dict] = None,
mulen_payment_id: Optional[int] = None,
is_paid: bool | None = None,
paid_at: datetime | None = None,
callback_payload: dict | None = None,
mulen_payment_id: int | None = None,
metadata: dict | None = None,
) -> MulenPayPayment:
payment.status = status
if is_paid is not None:
@@ -100,6 +87,8 @@ async def update_mulenpay_payment_status(
payment.callback_payload = callback_payload
if mulen_payment_id is not None and not payment.mulen_payment_id:
payment.mulen_payment_id = mulen_payment_id
if metadata is not None:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow()
await db.commit()
@@ -107,6 +96,19 @@ async def update_mulenpay_payment_status(
return payment
async def update_mulenpay_payment_metadata(
db: AsyncSession,
*,
payment: MulenPayPayment,
metadata: dict,
) -> MulenPayPayment:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
async def link_mulenpay_payment_to_transaction(
db: AsyncSession,
*,
+13 -11
View File
@@ -1,10 +1,11 @@
import logging
from typing import Optional
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from app.database.models import SentNotification
logger = logging.getLogger(__name__)
@@ -13,17 +14,19 @@ async def notification_sent(
user_id: int,
subscription_id: int,
notification_type: str,
days_before: Optional[int] = None,
days_before: int | None = None,
) -> bool:
result = await db.execute(
select(SentNotification).where(
select(SentNotification)
.where(
SentNotification.user_id == user_id,
SentNotification.subscription_id == subscription_id,
SentNotification.notification_type == notification_type,
SentNotification.days_before == days_before,
)
.limit(1)
)
return result.scalar_one_or_none() is not None
return result.scalars().first() is not None
async def record_notification(
@@ -31,8 +34,11 @@ async def record_notification(
user_id: int,
subscription_id: int,
notification_type: str,
days_before: Optional[int] = None,
days_before: int | None = None,
) -> None:
already_exists = await notification_sent(db, user_id, subscription_id, notification_type, days_before)
if already_exists:
return
notification = SentNotification(
user_id=user_id,
subscription_id=subscription_id,
@@ -44,11 +50,7 @@ async def record_notification(
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
await db.execute(
delete(SentNotification).where(
SentNotification.subscription_id == subscription_id
)
)
await db.execute(delete(SentNotification).where(SentNotification.subscription_id == subscription_id))
await db.commit()
+45 -56
View File
@@ -4,13 +4,14 @@ from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Pal24Payment
logger = logging.getLogger(__name__)
@@ -20,15 +21,15 @@ async def create_pal24_payment(
user_id: int,
bill_id: str,
amount_kopeks: int,
description: Optional[str],
description: str | None,
status: str,
type_: str,
currency: str,
link_url: Optional[str],
link_page_url: Optional[str],
order_id: Optional[str] = None,
ttl: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None,
link_url: str | None,
link_page_url: str | None,
order_id: str | None = None,
ttl: int | None = None,
metadata: dict[str, Any] | None = None,
) -> Pal24Payment:
payment = Pal24Payment(
user_id=user_id,
@@ -50,7 +51,7 @@ async def create_pal24_payment(
await db.refresh(payment)
logger.info(
"Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)",
'Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)',
payment.id,
user_id,
amount_kopeks,
@@ -60,24 +61,18 @@ async def create_pal24_payment(
return payment
async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.id == payment_id)
)
async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Pal24Payment | None:
result = await db.execute(select(Pal24Payment).where(Pal24Payment.id == payment_id))
return result.scalar_one_or_none()
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.bill_id == bill_id)
)
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Pal24Payment | None:
result = await db.execute(select(Pal24Payment).where(Pal24Payment.bill_id == bill_id))
return result.scalar_one_or_none()
async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.order_id == order_id)
)
async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Pal24Payment | None:
result = await db.execute(select(Pal24Payment).where(Pal24Payment.order_id == order_id))
return result.scalar_one_or_none()
@@ -86,55 +81,54 @@ async def update_pal24_payment_status(
payment: Pal24Payment,
*,
status: str,
is_active: Optional[bool] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
payment_id: Optional[str] = None,
payment_status: Optional[str] = None,
payment_method: Optional[str] = None,
balance_amount: Optional[str] = None,
balance_currency: Optional[str] = None,
payer_account: Optional[str] = None,
callback_payload: Optional[Dict[str, Any]] = None,
is_active: bool | None = None,
is_paid: bool | None = None,
paid_at: datetime | None = None,
payment_id: str | None = None,
payment_status: str | None = None,
payment_method: str | None = None,
balance_amount: str | None = None,
balance_currency: str | None = None,
payer_account: str | None = None,
callback_payload: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
) -> Pal24Payment:
update_values: Dict[str, Any] = {
"status": status,
update_values: dict[str, Any] = {
'status': status,
}
if is_active is not None:
update_values["is_active"] = is_active
update_values['is_active'] = is_active
if is_paid is not None:
update_values["is_paid"] = is_paid
update_values['is_paid'] = is_paid
if paid_at is not None:
update_values["paid_at"] = paid_at
update_values['paid_at'] = paid_at
if payment_id is not None:
update_values["payment_id"] = payment_id
update_values['payment_id'] = payment_id
if payment_status is not None:
update_values["payment_status"] = payment_status
update_values['payment_status'] = payment_status
if payment_method is not None:
update_values["payment_method"] = payment_method
update_values['payment_method'] = payment_method
if balance_amount is not None:
update_values["balance_amount"] = balance_amount
update_values['balance_amount'] = balance_amount
if balance_currency is not None:
update_values["balance_currency"] = balance_currency
update_values['balance_currency'] = balance_currency
if payer_account is not None:
update_values["payer_account"] = payer_account
update_values['payer_account'] = payer_account
if callback_payload is not None:
update_values["callback_payload"] = callback_payload
update_values['callback_payload'] = callback_payload
if metadata is not None:
update_values['metadata_json'] = metadata
update_values["last_status"] = status
update_values['last_status'] = status
await db.execute(
update(Pal24Payment)
.where(Pal24Payment.id == payment.id)
.values(**update_values)
)
await db.execute(update(Pal24Payment).where(Pal24Payment.id == payment.id).values(**update_values))
await db.commit()
await db.refresh(payment)
logger.info(
"Обновлен Pal24 платеж %s: статус=%s, is_paid=%s",
'Обновлен Pal24 платеж %s: статус=%s, is_paid=%s',
payment.bill_id,
payment.status,
payment.is_paid,
@@ -148,17 +142,12 @@ async def link_pal24_payment_to_transaction(
payment: Pal24Payment,
transaction_id: int,
) -> Pal24Payment:
await db.execute(
update(Pal24Payment)
.where(Pal24Payment.id == payment.id)
.values(transaction_id=transaction_id)
)
await db.execute(update(Pal24Payment).where(Pal24Payment.id == payment.id).values(transaction_id=transaction_id))
await db.commit()
await db.refresh(payment)
logger.info(
"Pal24 платеж %s привязан к транзакции %s",
'Pal24 платеж %s привязан к транзакции %s',
payment.bill_id,
transaction_id,
)
return payment

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