Compare commits

...

863 Commits

Author SHA1 Message Date
Egor 799c83dd84 Merge pull request #2627 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.16.1
2026-02-18 10:29:59 +03:00
github-actions[bot] 4cc18cbc9a chore(main): release 3.16.1 2026-02-18 07:29:30 +00:00
Egor 4645be53cb Merge pull request #2626 from BEDOLAGA-DEV/dev
fix: add migration for partner system tables and columns
2026-02-18 10:29:04 +03:00
Fringg 79ea398d1d fix: add migration for partner system tables and columns
Existing databases stamped at 0001 (create_all checkfirst=True) are
missing new columns/tables from the partner system:
- users.partner_status
- broadcast_history.blocked_count
- advertising_campaigns.partner_user_id
- withdrawal_requests table
- partner_applications table

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

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

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

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

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

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

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

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

Changes in all registration paths:
- cmd_start: store referrer_id from campaign.partner_user_id
- language/rules/privacy handlers: skip referral code prompt when
  referrer_id already set from campaign
- channel check: pick up referrer_id from state instead of hardcoding None
2026-02-17 11:22:38 +03:00
Fringg d39063b22f fix: unassign all campaigns when revoking partner status
Previously revoke_partner only changed partner_status and commission,
leaving campaigns orphaned with invalid partner_user_id. Now sets
partner_user_id=NULL on all campaigns belonging to the revoked partner.
2026-02-17 11:11:25 +03:00
Fringg ea5d932476 feat: include partner campaigns in /partner/status response
Return assigned active campaigns with bonus info, deep_link and
web_link so the partner's referral page can display shareable links.
2026-02-17 10:45:11 +03:00
Fringg acc1323a54 fix: move PartnerStatus enum before User class to fix NameError
PartnerStatus was defined after the User class that references it,
causing a NameError on startup.
2026-02-17 09:56:11 +03:00
Fringg 58bfaeaddb feat: add partner system and withdrawal management to cabinet
- Partner application flow: user applies, admin reviews/approves/rejects
- Individual commission % per partner with admin management
- Campaign assignment/unassignment to partners
- Withdrawal system: balance check, create request, cancel
- Admin withdrawal management with risk scoring and fraud analysis
- Database migration: partner_applications table, user partner fields, campaign partner_user_id
- Pydantic schemas with proper validation bounds
- Batch user fetching to prevent N+1 queries
- Row locking on cancel to prevent race conditions
2026-02-17 09:51:36 +03:00
Fringg df5415f30b fix: reorder button_click_logs migration to nullify before ALTER TYPE
ALTER COLUMN user_id TYPE INTEGER failed with "integer out of range"
because the column contained telegram_id values (BIGINT) exceeding
INTEGER max. Swapped order: SET NULL first, then ALTER TYPE.
2026-02-17 08:19:21 +03:00
Egor 330d670f3f Merge pull request #2621 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.15.1
2026-02-17 07:55:52 +03:00
github-actions[bot] 41cb122a2f chore(main): release 3.15.1 2026-02-17 04:54:40 +00:00
Egor 1b3e6f2f11 Merge pull request #2620 from BEDOLAGA-DEV/dev
fix: add naive datetime guards to fromisoformat() in Redis cache readers
2026-02-17 07:54:20 +03:00
Fringg 6fa49485d9 fix: add naive datetime guards to fromisoformat() in Redis cache readers
Old Redis entries saved before utcnow→now(UTC) migration lack timezone
info, causing TypeError on subtraction with aware datetimes.
2026-02-17 07:52:26 +03:00
Egor 71aa023133 Merge pull request #2619 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.15.0
2026-02-17 07:25:32 +03:00
github-actions[bot] e567c02658 chore(main): release 3.15.0 2026-02-17 04:01:31 +00:00
Egor f393dc0840 Merge pull request #2618 from BEDOLAGA-DEV/dev
Dev
2026-02-17 07:01:07 +03:00
Fringg 5dc4b0ec15 chore: ruff format oauth.py, auth schemas, admin_notification_service 2026-02-17 06:57:30 +03:00
Fringg e68760cc66 fix: remove local UTC re-imports shadowing module-level import in purchase.py
Caused UnboundLocalError on datetime.now(UTC) at line 209 because
Python treats the function-local `from datetime import UTC` (lines 351, 362)
as a local variable declaration, making UTC unbound before those lines.
2026-02-17 06:45:46 +03:00
Fringg d9552799c1 feat: add web campaign links with bonus processing in auth flow
- Add web_link generation for campaigns (uses MINIAPP_CUSTOM_URL)
- Process campaign_slug in all auth endpoints (telegram, widget, email, oauth)
- Apply campaign bonus (balance/subscription/tariff) with SELECT FOR UPDATE lock
- Add rollback + user refresh on campaign bonus failure
- Fix N+1 query in campaign registrations (batch subscription check)
- Remove duplicate queries in get_campaign_statistics (~60 lines dead code)
- Simplify _store_refresh_token (remove TOCTOU pre-check, keep IntegrityError)
- Remove dead expression in campaign_service.py
- Align start_parameter max_length to 64 (matches DB column)
- Remove unused campaign_slug from EmailRegisterStandaloneRequest
2026-02-17 06:44:03 +03:00
Fringg c75ec0b22a fix: AttributeError in withdrawal admin notification (send_to_admins → send_admin_notification) 2026-02-17 05:23:49 +03:00
Fringg 27309f53d9 feat: add LOG_COLORS env setting to toggle console ANSI colors 2026-02-17 05:15:03 +03:00
Egor 4193f717ee Merge pull request #2617 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.14.1
2026-02-17 05:04:03 +03:00
github-actions[bot] d297985b0b chore(main): release 3.14.1 2026-02-17 02:03:34 +00:00
Egor 6dcf3a9f0d Merge pull request #2616 from BEDOLAGA-DEV/dev
Dev
2026-02-17 05:03:06 +03:00
Fringg 094609005a fix: add naive datetime guards to parsers and fix test datetime literals 2026-02-17 05:00:13 +03:00
Fringg eb18994b7d fix: complete datetime.utcnow() → datetime.now(UTC) migration
- Migrate 660+ datetime.utcnow() across 153 files to datetime.now(UTC)
- Migrate 30+ datetime.now() without UTC to datetime.now(UTC)
- Convert all 170 DateTime columns to DateTime(timezone=True)
- Add migrate_datetime_to_timestamptz() in universal_migration with SET LOCAL timezone='UTC' safety
- Remove 70+ .replace(tzinfo=None) workarounds
- Fix utcfromtimestamp → fromtimestamp(..., tz=UTC)
- Fix fromtimestamp() without tz= (system_logs, backup_service, referral_diagnostics)
- Fix fromisoformat/isoparse to ensure aware output (platega, yookassa, wata, miniapp, nalogo)
- Fix strptime() to add .replace(tzinfo=UTC) (backup_service, referral_diagnostics)
- Fix datetime.combine() to include tzinfo=UTC (remnawave_sync, traffic_monitoring)
- Fix datetime.max/datetime.min sentinels with .replace(tzinfo=UTC)
- Rename panel_datetime_to_naive_utc → panel_datetime_to_utc
- Remove DTZ003 from ruff ignore list
2026-02-17 04:45:40 +03:00
Fringg ff21b27b98 fix: address remaining abs() issues from review
- admin_traffic._get_bulk_spending: add func.abs() for SUBSCRIPTION_PAYMENT SUM
- get_user_total_spent_kopeks: move abs() from Python to SQL (per-row func.abs)
- referral_contest.total_outside: add abs() for mixed-type sum
- Revert func.abs() from generic by_type aggregation to preserve refund/withdrawal signs
2026-02-17 03:47:39 +03:00
Fringg 4247981c98 fix: normalize transaction amount signs across all aggregations
SUBSCRIPTION_PAYMENT transactions have inconsistent signs in DB
(some negative, some positive). Add func.abs()/abs() to all SUM
queries and display code to ensure correct totals regardless of sign.

Affected: admin statistics, referral contest stats, tariff revenue,
campaign stats, reporting service, admin renewal notifications.
2026-02-17 03:40:37 +03:00
Fringg c30972f6a7 fix: prevent negative amounts in spent display and balance history
SUBSCRIPTION_PAYMENT transactions are stored with negative amount_kopeks.
- get_user_total_spent_kopeks now returns abs() to fix "Потрачено: -155 ₽"
  and broken promo group threshold comparisons
- Balance history uses abs() before format_price to prevent "--85 ₽"
2026-02-17 03:36:56 +03:00
Egor 7628fb9f6e Merge pull request #2613 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.14.0
2026-02-16 19:26:11 +03:00
github-actions[bot] 4c48eadebc chore(main): release 3.14.0 2026-02-16 16:23:56 +00:00
Egor 6ea3860a2f Merge pull request #2612 from BEDOLAGA-DEV/dev
Dev
2026-02-16 19:23:30 +03:00
Fringg 1b8ef69a1b fix: NameError in set_user_devices_button — undefined action_text
Replaced undefined action_text with devices (the actual value being set).
Removed duplicate await callback.answer() call.
2026-02-16 19:09:52 +03:00
Fringg 9d710050ad feat: show all active webhook endpoints in startup log
Added missing webhook endpoints to the startup section:
Platega, CloudPayments, Kassa.ai, and RemnaWave webhook.
2026-02-16 19:08:49 +03:00
Fringg 491a7e1c42 fix: remove unused PaymentService from MonitoringService init
MonitoringService instantiated PaymentService() at module level during
import, triggering a debug log before structlog/logging were configured.
This caused [debug    ] with padded spaces (structlog default pad_level)
and appeared 7 seconds before the startup banner. The payment_service
attribute was never used in MonitoringService.
2026-02-16 19:02:57 +03:00
Fringg 7eb8d4e153 fix: force basicConfig to replace pre-existing handlers
logging.basicConfig() silently does nothing if the root logger already
has handlers. When import-time side effects trigger stdlib logging before
main() configures formatters, our ProcessorFormatter with pad_level=False
never gets applied — producing [debug    ] instead of [debug].
2026-02-16 18:49:39 +03:00
Fringg f63720467a refactor: improve log formatting — logger name prefix and table alignment
1. Add _prefix_logger_name processor that moves [module.name] before
   event text for consistent format: timestamp [level] [module] message
2. Fix startup summary table alignment by using display width calculation
   instead of len() — properly accounts for wide emoji and variation
   selectors that render as 2 terminal cells
2026-02-16 18:33:40 +03:00
Fringg 516be6e600 fix: sync support mode from cabinet admin to SupportSettingsService
Cabinet admin endpoint was setting settings.SUPPORT_SYSTEM_MODE directly
without updating SupportSettingsService JSON, causing bot to show stale
mode. Now routes through set_system_mode() which updates both stores.
2026-02-16 18:24:27 +03:00
Fringg 0807a9ff19 fix: sync SUPPORT_SYSTEM_MODE between SystemSettings and SupportSettings
When changing SUPPORT_SYSTEM_MODE via system settings admin panel, the
SupportSettingsService JSON cache was not updated, causing the old value
to take priority. Now both services stay in sync bidirectionally.
2026-02-16 18:22:44 +03:00
Fringg a93a32f3a7 fix: resolve MissingGreenlet error when accessing subscription.tariff
Add .selectinload(Subscription.tariff) chain to all User queries that
load subscriptions, preventing lazy loading of the tariff relationship
in async context. Also replace unsafe getattr(subscription, 'tariff')
with explicit async get_tariff_by_id() in handle_extend_subscription.
2026-02-16 17:54:43 +03:00
Egor 68de66f526 Merge pull request #2610 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.13.0
2026-02-16 10:12:33 +03:00
github-actions[bot] 15aba2b3db chore(main): release 3.13.0 2026-02-16 07:11:21 +00:00
Egor fa78fa6d09 Merge pull request #2609 from BEDOLAGA-DEV/dev
Dev
2026-02-16 10:10:52 +03:00
Fringg 11f8af003f fix: resolve exc_info for admin notifications, clean log formatting
- TelegramNotifierProcessor: resolve exc_info=True → sys.exc_info()
  tuple while still in except block, fixing "(no traceback available)"
- Use real exception type (e.g. TelegramBadRequest) instead of LogError
- Include user_id/username in admin notification context
- ConsoleRenderer: pad_level=False removes trailing spaces in [info]
- Strip [__main__] logger name from startup/timeline logs
2026-02-16 10:06:37 +03:00
Fringg 11ef714e0d fix: limit Rich traceback output to prevent console flood
RichTracebackFormatter defaults (show_locals=True, max_frames=100)
produced 5000+ line tracebacks on chained exceptions with aiogram.
Now: show_locals=False, max_frames=20, suppress aiogram/aiohttp frames.
2026-02-16 09:57:46 +03:00
Fringg 909a4039c4 fix: traceback in Telegram notifications + reduce log padding
- LoggingMiddleware: logger.error → logger.exception to include exc_info
  so TelegramNotifierProcessor can extract traceback for admin chat
- ConsoleRenderer: pad_event_to=0 to remove excessive whitespace
  in short event names (timeline markers like ┃, ┗)
2026-02-16 09:55:56 +03:00
Fringg bf646112df feat: colored console logs via structlog + rich + FORCE_COLOR
- Add rich dependency for colored tracebacks and console rendering
- Set FORCE_COLOR=1 in docker-compose for color output in containers
- Remove format_exc_info from processor chain — ConsoleRenderer now
  handles exc_info directly (Rich tracebacks on console, plain in files)
- Let ConsoleRenderer auto-detect colors via FORCE_COLOR env var
2026-02-16 09:43:41 +03:00
Fringg 8a6650e57c fix: suppress startup log noise (~350 lines → ~30)
- Suppress migration logger to WARNING during startup (main.py)
- Remove debug logs from get_traffic_packages() leaking before structlog init
- Downgrade handler registration logs to debug (start.py)
- Remove duplicate section headers from migration orchestrator
2026-02-16 09:34:17 +03:00
Fringg 25e8c9f8fc fix: use sync context manager for structlog bound_contextvars
bound_contextvars() returns a sync _GeneratorContextManager, not async.
Using `async with` caused TypeError crashing all web API requests.
2026-02-16 09:23:22 +03:00
Fringg 1f0fef114b refactor: complete structlog migration with contextvars, kwargs, and logging hardening
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding
  via structlog contextvars (aiogram) and http_method/http_path (FastAPI)
- Use bound_contextvars() context manager instead of clear_contextvars()
  to safely restore previous state instead of wiping all context
- Register ContextVarsMiddleware as outermost middleware (before GlobalError)
  so all error logs include user context
- Replace structlog.get_logger() with structlog.get_logger(__name__) across
  270 calls in 265 files for meaningful logger names
- Switch wrapper_class from BoundLogger to make_filtering_bound_logger()
  for pre-processor level filtering (performance optimization)
- Migrate 1411 %-style positional arg logger calls to structlog kwargs
  style across 161 files via AST script
- Migrate log_rotation_service.py from stdlib logging to structlog
- Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES
  and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking
  to Telegram notifications and general log files
- Fix LoggingMiddleware: add from_user null-safety for channel posts,
  switch time.time() to time.monotonic() for duration measurement
- Remove duplicate logger assignments in purchase.py, config.py,
  inline.py, and admin/payments.py
2026-02-16 09:18:12 +03:00
Egor be6036e879 Merge pull request #2607 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.12.1
2026-02-16 07:32:42 +03:00
github-actions[bot] bba85a309a chore(main): release 3.12.1 2026-02-16 04:32:16 +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
554 changed files with 96613 additions and 104630 deletions
+90 -14
View File
@@ -27,6 +27,8 @@ 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)
@@ -37,10 +39,20 @@ CABINET_REFRESH_TOKEN_EXPIRE_DAYS=7
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)
@@ -140,7 +152,7 @@ REMNAWAVE_API_KEY=your_api_key_here
# Тип авторизации: "api_key", "basic_auth", "caddy"
REMNAWAVE_AUTH_TYPE=api_key
REMNAWAVE_CADDY_TOKEN=YWRtaW46cGFzc3dvcmQ=
REMNAWAVE_CADDY_TOKEN=
# Для панелей с Basic Auth (опционально)
REMNAWAVE_USERNAME=
@@ -175,6 +187,42 @@ 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
@@ -323,7 +371,8 @@ REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# Показывать раздел партнёрки в кабинете
REFERRAL_PARTNER_SECTION_VISIBLE=true
# Уведомления
REFERRAL_NOTIFICATIONS_ENABLED=true
@@ -336,6 +385,8 @@ REFERRAL_WITHDRAWAL_ENABLED=false
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS=50000
# Интервал между запросами на вывод (дни)
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS=30
# Текст-подсказка для поля реквизитов при выводе (пустая строка = стандартный текст)
REFERRAL_WITHDRAWAL_REQUISITES_TEXT=
# Выводить только реферальный баланс (true) или весь баланс (false)
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE=true
# ID топика для уведомлений о заявках на вывод (0 = основной чат)
@@ -524,6 +575,8 @@ 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
@@ -532,7 +585,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
@@ -581,6 +633,23 @@ 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
@@ -631,8 +700,19 @@ CLOUDPAYMENTS_TEST_MODE=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
@@ -645,7 +725,7 @@ 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=
@@ -712,7 +792,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en,ua,zh
AVAILABLE_LANGUAGES=ru,en,ua,zh,fa
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
@@ -768,6 +848,8 @@ VERSION_CHECK_INTERVAL_HOURS=1
# ===== ЛОГИРОВАНИЕ =====
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
# ANSI-цвета в консоли (true — цветной вывод с Rich, false — plain-text)
LOG_COLORS=true
# === Ротация логов ===
# Включить новую систему ротации (по умолчанию старое поведение)
@@ -801,7 +883,7 @@ 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 или both
BOT_RUN_MODE=polling # polling или webhook
# ===== КОНКУРСНАЯ СИСТЕМА =====
CONTESTS_ENABLED=false
@@ -809,15 +891,9 @@ CONTESTS_BUTTON_VISIBLE=false
# Реферальные конкурсы (турниры среди рефералов)
REFERRAL_CONTESTS_ENABLED=false
# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
# ===== АВТОПОКУПКА ПОСЛЕ ПОПОЛНЕНИЯ =====
# Автоматическая покупка из сохранённой корзины после пополнения баланса
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
# Умная автоактивация: система сама решает — продлить или создать подписку
# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
# Показывать предупреждение об активации подписки после пополнения баланса
# Если true - после пополнения показывает сообщение с кнопками: "Активировать", "Продлить", "Добавить устройства"
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=false
# ===== КНОПКА АКТИВАЦИИ =====
ACTIVATE_BUTTON_VISIBLE=false
+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="v3.1.1-$(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="v3.1.1-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="v3.1.1-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="v3.1.1-$(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="v3.1.1-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="v3.1.1-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 -24
View File
@@ -1,39 +1,102 @@
# Игнорируем все файлы и папки по умолчанию
*
docker-compose.override.yml
# Исключения: разрешаем только нужные файлы
# ========== 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/**
!migrations/
!migrations/**
!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.16.1"
}
+438
View File
@@ -0,0 +1,438 @@
# Changelog
## [3.16.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.0...v3.16.1) (2026-02-18)
### Bug Fixes
* add migration for partner system tables and columns ([4645be5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4645be53cbb3799aa6b2b6a623af30460357a554))
* add migration for partner system tables and columns ([79ea398](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79ea398d1db436a7812a799bf01b2c1c3b1b73be))
## [3.16.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.15.1...v3.16.0) (2026-02-18)
### New Features
* add admin notifications for partner applications and withdrawals ([cf7cc5a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf7cc5a84e295608009f255fcd0dcedb5a2a04a3))
* add admin partner settings API (withdrawal toggle, requisites text, partner visibility) ([6881d97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6881d97bbb1f6cd8ca3609c2d9286a6e4fb24fc3))
* add campaign_id to ReferralEarning for campaign attribution ([0c07812](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c07812ecc9502f54a7745a77b086fc52bdc0e34))
* add partner system and withdrawal management to cabinet ([58bfaea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58bfaeaddbcbb98cb67dbd507847a0e5c8d07809))
* attribute campaign registrations to partner for referral earnings ([767e965](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/767e9650285adc72b067b2c0b8a4d1ac5c5bba57))
* blocked user detection during broadcasts, filter blocked from all notifications ([10e231e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10e231e52e0dbabd9195a2df373b3c95129a5e4f))
* enforce 1-to-1 partner-campaign binding with partner info in campaigns ([366df18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/366df18c547047a7c69192c768970ebc6ee426fc))
* expose traffic_reset_mode in subscription response ([59383bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59383bdbd8c72428d151cb24d132452414b14fa3))
* expose traffic_reset_mode in tariff API response ([5d4a94b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5d4a94b8cea8f16f0b4c31e24a4695bee4c67af7))
* include partner campaigns in /partner/status response ([ea5d932](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ea5d932476553ad1750da3bebbd4b8f055478040))
* link campaign registrations to partner for referral earnings ([c4dc43e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4dc43e054e9faec2f9614fe51a64635f80c1796))
* notify users on partner/withdrawal approve/reject ([327d4f4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/327d4f4d1559e37dc591adbfd0c839d986d1068d))
### Bug Fixes
* add blocked_count column migration to universal_migration.py ([b4b10c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4b10c998cadbb879540e56dbd0e362b5497ee57))
* add missing payment providers to payment_utils and fix {total_amount} formatting ([bdb6161](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bdb61613de378efab4de6de98fde2de3b554c548))
* add selectinload for subscription in campaign user list ([eb9dba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb9dba3f4728b478f2206ff992700a9677f879c7))
* campaign web link uses ?campaign= param, not ?start= ([28f524b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28f524b7622ed975d2fece66edc94d9713354738))
* correct subscription_service import in broadcast cleanup ([6c4e035](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c4e035146934dffb576477cc75f7365b2f27b99))
* critical security and data integrity fixes for partner system ([8899749](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88997492c3534ea2f6e194c0382c77302557c2f3))
* handle YooKassa NotFoundError gracefully in get_payment_info ([df5b1a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5b1a072d99ff8aee0c94304b2a0214f0fcffe7))
* medium-priority fixes for partner system ([7c20fde](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c20fde4e887749d72280a8804467645e5bab416))
* move PartnerStatus enum before User class to fix NameError ([acc1323](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acc1323a542b8e92433cabf1334d2d98bfa21e21))
* prevent fileConfig from destroying structlog handlers ([e78b104](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e78b1040a50ac14759bceab396d0c3e34dd79cdd))
* reorder button_click_logs migration to nullify before ALTER TYPE ([df5415f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5415f30b2aae4412ff5fbd3cac8076128b818c))
* resolve HIGH-priority performance and security issues in partner system ([fcf3a2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcf3a2c8062752b2b1dc06b5993ac2d8ae80ee85))
* return zeroed stats dict when withdrawal is disabled ([7883efc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7883efc3d6e6d8bedf8e4b7d72634cbab6e2f3d7))
* unassign all campaigns when revoking partner status ([d39063b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d39063b22ffb6442e275db39704361cdb9251793))
### Refactoring
* replace universal_migration.py with Alembic ([b6c7f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6c7f91a7c79d108820c9f89c9070fde4843316c))
* replace universal_migration.py with Alembic ([784616b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/784616b349ef12b35ee021dd7a7b2a2ef9fc57f6))
## [3.15.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.15.0...v3.15.1) (2026-02-17)
### Bug Fixes
* add naive datetime guards to fromisoformat() in Redis cache readers ([1b3e6f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b3e6f2f11c20aa240da1beb11dd7dfb20dbe6e8))
* add naive datetime guards to fromisoformat() in Redis cache readers ([6fa4948](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6fa49485d9f1cd678cb5f9fa7d0375fd47643239))
## [3.15.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.14.1...v3.15.0) (2026-02-17)
### New Features
* add LOG_COLORS env setting to toggle console ANSI colors ([27309f5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27309f53d9fa0ba9a2ca07a65feed96bf38f470c))
* add web campaign links with bonus processing in auth flow ([d955279](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9552799c17a76e2cc2118699528c5b591bd97fb))
### Bug Fixes
* AttributeError in withdrawal admin notification (send_to_admins → send_admin_notification) ([c75ec0b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c75ec0b22a3f674d3e1a24b9d546eca1998701b3))
* remove local UTC re-imports shadowing module-level import in purchase.py ([e68760c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e68760cc668016209f4f19a2e08af8680343d6ed))
## [3.14.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.14.0...v3.14.1) (2026-02-17)
### Bug Fixes
* add naive datetime guards to parsers and fix test datetime literals ([0946090](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/094609005af7358bf5d34d252fc66685bd25751c))
* address remaining abs() issues from review ([ff21b27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff21b27b98bb5a7517e06057eb319c9f3ebb74c7))
* complete datetime.utcnow() → datetime.now(UTC) migration ([eb18994](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18994b7d34d777ca39d3278d509e41359e2a85))
* normalize transaction amount signs across all aggregations ([4247981](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4247981c98111af388c98628c1e61f0517c57417))
* prevent negative amounts in spent display and balance history ([c30972f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30972f6a7911a89a6c3f2080019ff465d11b597))
## [3.14.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.13.0...v3.14.0) (2026-02-16)
### New Features
* show all active webhook endpoints in startup log ([9d71005](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d710050ad40ba76a14aa6ace8e8a47f25cdde94))
### Bug Fixes
* force basicConfig to replace pre-existing handlers ([7eb8d4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7eb8d4e153bab640a5829f75bfa6f70df5763284))
* NameError in set_user_devices_button — undefined action_text ([1b8ef69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b8ef69a1bbb7d8d86827cf7aaa4f05cbf480d75))
* remove unused PaymentService from MonitoringService init ([491a7e1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/491a7e1c425a355e55b3020e2bcc7b96047bdf5e))
* resolve MissingGreenlet error when accessing subscription.tariff ([a93a32f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a93a32f3a7d1b259a2e24954ae5d2b7c966c5639))
* sync support mode from cabinet admin to SupportSettingsService ([516be6e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/516be6e600a08ad700d83b793dc64b2ca07bdf44))
* sync SUPPORT_SYSTEM_MODE between SystemSettings and SupportSettings ([0807a9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0807a9ff19d1eb4f1204f7cbeb1da1c1cfefe83a))
### Refactoring
* improve log formatting — logger name prefix and table alignment ([f637204](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f63720467a935bdaaa58bb34d588d65e46698f26))
## [3.13.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.12.1...v3.13.0) (2026-02-16)
### New Features
* colored console logs via structlog + rich + FORCE_COLOR ([bf64611](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf646112df02aa7aa7918d0513cb6968ceb7f378))
### Bug Fixes
* limit Rich traceback output to prevent console flood ([11ef714](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11ef714e0dde25a08711c0daeee943b6e71e20b7))
* resolve exc_info for admin notifications, clean log formatting ([11f8af0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11f8af003fc60384abafa2b670b89d6ad3ac57a4))
* suppress startup log noise (~350 lines → ~30) ([8a6650e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a6650e57cd8ea396d9b057a7753469947f38d29))
* traceback in Telegram notifications + reduce log padding ([909a403](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/909a4039c43b910761bd05c36e79c8e6773199db))
* use sync context manager for structlog bound_contextvars ([25e8c9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25e8c9f8fc4d2c66d5a1407d3de5c7402dc596da))
### Refactoring
* complete structlog migration with contextvars, kwargs, and logging hardening ([1f0fef1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f0fef114bd979b2b0d2bd38dde6ce05e7bba07b))
## [3.12.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.12.0...v3.12.1) (2026-02-16)
### Bug Fixes
* add /start burst rate-limit to prevent spam abuse ([61a9722](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61a97220d30031816ab23e33a46717e4895c0758))
* add promo code anti-abuse protections ([97ec39a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97ec39aa803f0e3f03fdcd482df0cbcb86fd1efd))
* handle TelegramBadRequest in ticket edit_message_text calls ([8e61fe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e61fe47746da2ac09c3ea8c4dbfc6be198e49e3))
* replace deprecated Query(regex=) with pattern= ([871ceb8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/871ceb866ccf1f3a770c7ef33406e1a43d0a7ff7))
## [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))
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.1.1"
ARG VERSION="v3.16.1" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+31 -5
View File
@@ -25,15 +25,41 @@ reload-follow: ## Перезапустить контейнеры с логам
.PHONY: test
test: ## Запустить тесты
@echo "🧪 Запускаем тесты..."
pytest -v
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: migrate
migrate: ## Применить миграции (alembic upgrade head)
uv run alembic upgrade head
.PHONY: migration
migration: ## Создать миграцию (usage: make migration m="description")
uv run alembic revision --autogenerate -m "$(m)"
.PHONY: migrate-stamp
migrate-stamp: ## Пометить БД как актуальную (для существующих БД)
uv run alembic stamp head
.PHONY: migrate-history
migrate-history: ## Показать историю миграций
uv run alembic history --verbose
.PHONY: help
help: ## Показать список доступных команд
@echo ""
@echo "📘 Команды Makefile:"
@echo ""
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | \
sed -E 's/:.*?## /| /' | \
awk -F'|' '{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
@awk -F':.*## ' '/^[a-zA-Z0-9_-]+:.*## / {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@echo ""
+122 -7
View File
@@ -1,6 +1,6 @@
# 🚀 Remnawave Bedolaga Bot
> **🆕 Новый веб-кабинет (Cabinet WebApp)**
> **🆕 Новый веб-кабинет (Cabinet WebApp) https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/**
>
> Вышла новая версия личного кабинета пользователя — веб-интерфейс для управления подписками!
>
@@ -37,7 +37,7 @@ _Полнофункциональное решение с управлением
---
## 🧪 [Тестирование бота](https://t.me/FringVPN_bot)
## 🧪 [Тестирование бота](https://t.me/zero_ping_vpn_bot?start=Git)
## 💬 **[Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi)** - Для общения, вопросов, предложений
@@ -160,7 +160,6 @@ docker compose logs
| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------ |
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. |
### 2. Минимальные настройки для webhook
@@ -611,6 +610,16 @@ hooks.domain.com {
}
}
handle /remnawave-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
# app-config.json с CORS
handle /app-config.json {
header Access-Control-Allow-Origin "*"
@@ -819,6 +828,18 @@ http {
proxy_request_buffering off;
}
location = /remnawave-webhook {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
# app-config.json с CORS
location = /app-config.json {
add_header Access-Control-Allow-Origin "*";
@@ -1012,7 +1033,7 @@ curl -I https://miniapp.domain.com
| ---------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------ |
| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` |
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. |
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling` или `webhook`. |
[Полный список доступных параметров:](.env.example)
@@ -1022,7 +1043,7 @@ curl -I https://miniapp.domain.com
### 🤖 Режимы запуска бота
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling` или `webhook`.
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защащаться от перегрузок.
@@ -1056,6 +1077,102 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
REMNAWAVE_SECRET_KEY=secret_key_name
```
### 📡 Вебхуки Remnawave (real-time события)
Бот может принимать входящие вебхуки от панели Remnawave для мгновенной реакции на события подписок. Это значительно улучшает скорость обновления данных по сравнению с периодической синхронизацией.
#### Поддерживаемые события
| Событие | Описание |
|---------|----------|
| `user.expired` | Подписка истекла |
| `user.disabled` | Подписка деактивирована |
| `user.enabled` | Подписка активирована |
| `user.limited` | Превышен лимит трафика |
| `user.traffic_reset` | Трафик сброшен |
| `user.modified` | Данные подписки изменены (трафик, дата, URL) |
| `user.deleted` | Пользователь удалён |
| `user.revoked` | Ключи подписки отозваны |
| `user.created` | Пользователь создан |
| `user.expires_in_*` | Предупреждения об истечении (72ч, 48ч, 24ч) |
| `user.first_connected` | Первое подключение |
| `user.bandwidth_usage_threshold_reached` | Порог трафика достигнут |
| `user_hwid_devices.*` | Устройство добавлено/удалено |
| `node.*`, `service.*` | Административные события (ноды, сервис) |
#### Настройка
**1. Переменные окружения в `.env`:**
```env
REMNAWAVE_WEBHOOK_ENABLED=true
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
REMNAWAVE_WEBHOOK_SECRET=your_secret_min_32_chars_here
```
Сгенерируйте секрет:
```bash
openssl rand -hex 32
```
**2. Настройка в панели Remnawave:**
В env панели Remnawave заполните:
- **URL**: `https://hooks.domain.com/remnawave-webhook`
- **Secret**: тот же секрет, что и в `REMNAWAVE_WEBHOOK_SECRET`
**3. Настройка прокси:**
Добавьте путь `/remnawave-webhook` в конфигурацию обратного прокси.
**Caddy:**
```caddy
handle /remnawave-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
```
**Nginx:**
```nginx
location = /remnawave-webhook {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
```
**4. Проверка работоспособности:**
```bash
# Health-check (GET запрос)
curl -s https://hooks.domain.com/remnawave-webhook | jq
# Ожидаемый ответ:
# {"status": "ok", "service": "remnawave_webhook", "enabled": true}
```
**Важно:**
- Секрет должен быть не менее 32 символов
- Бот верифицирует подпись `X-Remnawave-Signature` (HMAC-SHA256) для каждого запроса
- При включённых вебхуках бот автоматически защищает подписки от перезаписи данными из периодической синхронизации в течение 60 секунд после получения события
- Если бот и панель на одном сервере, URL вебхука может быть `http://remnawave_bot:8080/remnawave-webhook` (внутри Docker-сети)
### 💳 Freekassa
Платёжный провайдер [Freekassa](https://freekassa.ru) поддерживает NSPK СБП и банковские карты.
@@ -1343,7 +1460,6 @@ CONTEST_BUTTON_VISIBLE=true
- 🔄 Автоплатёж с настройкой дня списания
- 🎁 Реферальные и промо-бонусы
-**Быстрое пополнение** с кнопками быстрых сумм
- 🔄 **Умная автоактивация** подписки после пополнения баланса
📱 **Управление подписками**
@@ -1530,7 +1646,6 @@ CONTEST_BUTTON_VISIBLE=true
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
- 💸 **Сервис автопроверки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
- 🔄 **Умная автоактивация** - автоматическая активация подписки после пополнения баланса
- 📝 **Ротация логов** - автоматическая очистка и архивация старых логов
- 🎮 **Система конкурсов** - ежедневные игры и реферальные конкурсы с призами
+1 -1
View File
@@ -2,7 +2,7 @@
script_location = migrations/alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
+133 -96
View File
@@ -1,121 +1,120 @@
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
import structlog
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.middlewares.button_stats import ButtonStatsMiddleware
from app.services.maintenance_service import maintenance_service
from app.utils.cache import cache
from app.handlers import (
start,
menu,
subscription,
balance,
common,
contests as user_contests,
menu,
polls as user_polls,
promocode,
referral,
support,
server_status,
common,
simple_subscription,
start,
subscription,
support,
tickets,
)
from app.handlers import polls as user_polls
from app.handlers import simple_subscription
from app.handlers.admin import (
main as admin_main,
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,
users as admin_users,
subscriptions as admin_subscriptions,
promocodes as admin_promocodes,
messages as admin_messages,
monitoring as admin_monitoring,
referrals as admin_referrals,
rules as admin_rules,
remnawave as admin_remnawave,
statistics as admin_statistics,
polls as admin_polls,
servers as admin_servers,
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
campaigns as admin_campaigns,
contests as admin_contests,
daily_contests as admin_daily_contests,
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,
faq as admin_faq,
main as admin_main,
maintenance as admin_maintenance,
messages as admin_messages,
monitoring as admin_monitoring,
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,
payments as admin_payments,
trials as admin_trials,
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 import contests as user_contests
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.context_binding import ContextVarsMiddleware
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__)
logger = structlog.get_logger(__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('Data', callback_data=callback.data)
logger.info('User', from_user_id=callback.from_user.id)
logger.info('Username', 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('Кеш не инициализирован', error=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('Не удалось подключиться к Redis', error=e)
logger.info('Используется MemoryStorage для FSM')
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
dp.message.middleware(ContextVarsMiddleware())
dp.callback_query.middleware(ContextVarsMiddleware())
dp.pre_checkout_query.middleware(ContextVarsMiddleware())
dp.message.middleware(GlobalErrorMiddleware())
dp.callback_query.middleware(GlobalErrorMiddleware())
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
@@ -123,18 +122,18 @@ 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 активирован")
logger.info('📊 ButtonStatsMiddleware активирован')
if settings.CHANNEL_IS_REQUIRED_SUB:
from app.middlewares.channel_checker import ChannelCheckerMiddleware
@@ -142,9 +141,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
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())
@@ -162,7 +161,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)
@@ -194,39 +193,77 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
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("⚡ Зарегистрированы обработчики простой подписки")
logger.info('⭐ Зарегистрированы обработчики Telegram Stars платежей')
logger.info('⚡ Зарегистрированы обработчики простой покупки')
logger.info('⚡ Зарегистрированы обработчики простой подписки')
if settings.is_maintenance_monitoring_enabled():
try:
await maintenance_service.start_monitoring()
logger.info("Мониторинг техработ запущен")
logger.info('Мониторинг техработ запущен')
except Exception as e:
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
logger.error('Ошибка запуска мониторинга техработ', error=e)
else:
logger.info("Мониторинг техработ отключен настройками")
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
logger.info("Бот успешно настроен")
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('🏠 Режим Cabinet активен, базовый URL', MINIAPP_CUSTOM_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('Failed to load button styles cache', error=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('Ошибка остановки мониторинга', error=e)
try:
await cache.close()
logger.info("Соединения с кешем закрыты")
logger.info('Соединения с кешем закрыты')
except Exception as e:
logger.error(f"Ошибка закрытия кеша: {e}")
logger.error('Ошибка закрытия кеша', error=e)
+11 -10
View File
@@ -1,21 +1,22 @@
"""Cabinet authentication module."""
from .password_utils import hash_password, verify_password
from .jwt_handler import (
create_access_token,
create_refresh_token,
decode_token,
get_token_payload,
)
from .telegram_auth import validate_telegram_login_widget, validate_telegram_init_data
from .password_utils import hash_password, verify_password
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
__all__ = [
"hash_password",
"verify_password",
"create_access_token",
"create_refresh_token",
"decode_token",
"get_token_payload",
"validate_telegram_login_widget",
"validate_telegram_init_data",
'create_access_token',
'create_refresh_token',
'decode_token',
'get_token_payload',
'hash_password',
'validate_telegram_init_data',
'validate_telegram_login_widget',
'verify_password',
]
+26 -6
View File
@@ -1,12 +1,32 @@
"""Email verification token generation and validation."""
import secrets
from datetime import datetime, timedelta
from typing import Optional
from datetime import UTC, 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.now(UTC) + timedelta(minutes=minutes)
def generate_verification_token() -> str:
"""
Generate a secure random verification token.
@@ -35,7 +55,7 @@ def get_verification_expires_at() -> datetime:
Datetime when the verification token expires
"""
hours = settings.get_cabinet_email_verification_expire_hours()
return datetime.utcnow() + timedelta(hours=hours)
return datetime.now(UTC) + timedelta(hours=hours)
def get_password_reset_expires_at() -> datetime:
@@ -46,10 +66,10 @@ def get_password_reset_expires_at() -> datetime:
Datetime when the password reset token expires
"""
hours = settings.get_cabinet_password_reset_expire_hours()
return datetime.utcnow() + timedelta(hours=hours)
return datetime.now(UTC) + timedelta(hours=hours)
def is_token_expired(expires_at: Optional[datetime]) -> bool:
def is_token_expired(expires_at: datetime | None) -> bool:
"""
Check if a token has expired.
@@ -61,4 +81,4 @@ def is_token_expired(expires_at: Optional[datetime]) -> bool:
"""
if expires_at is None:
return True
return datetime.utcnow() > expires_at
return datetime.now(UTC) > expires_at
+25 -20
View File
@@ -1,36 +1,41 @@
"""JWT token handling for cabinet authentication."""
from datetime import UTC, datetime, timedelta
from typing import Any
import jwt
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from app.config import settings
JWT_ALGORITHM = "HS256"
JWT_ALGORITHM = 'HS256'
def create_access_token(user_id: int, telegram_id: int) -> str:
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
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)
expires = datetime.now(UTC) + timedelta(minutes=expire_minutes)
payload = {
"sub": str(user_id),
"telegram_id": telegram_id,
"type": "access",
"exp": expires,
"iat": datetime.utcnow(),
'sub': str(user_id),
'type': 'access',
'exp': expires,
'iat': datetime.now(UTC),
}
# Добавляем 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)
@@ -46,20 +51,20 @@ def create_refresh_token(user_id: int) -> str:
Encoded JWT refresh token
"""
expire_days = settings.get_cabinet_refresh_token_expire_days()
expires = datetime.utcnow() + timedelta(days=expire_days)
expires = datetime.now(UTC) + timedelta(days=expire_days)
payload = {
"sub": str(user_id),
"type": "refresh",
"exp": expires,
"iat": datetime.utcnow(),
'sub': str(user_id),
'type': 'refresh',
'exp': expires,
'iat': datetime.now(UTC),
}
secret = settings.get_cabinet_jwt_secret()
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
def decode_token(token: str) -> Optional[Dict[str, Any]]:
def decode_token(token: str) -> dict[str, Any] | None:
"""
Decode and validate a JWT token.
@@ -78,7 +83,7 @@ def decode_token(token: str) -> Optional[Dict[str, Any]]:
return None
def get_token_payload(token: str, expected_type: str = "access") -> Optional[Dict[str, Any]]:
def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, Any] | None:
"""
Decode token and verify its type.
@@ -94,7 +99,7 @@ def get_token_payload(token: str, expected_type: str = "access") -> Optional[Dic
if not payload:
return None
if payload.get("type") != expected_type:
if payload.get('type') != expected_type:
return None
return payload
@@ -103,4 +108,4 @@ def get_token_payload(token: str, expected_type: str = "access") -> Optional[Dic
def get_refresh_token_expires_at() -> datetime:
"""Get the expiration datetime for a new refresh token."""
expire_days = settings.get_cabinet_refresh_token_expire_days()
return datetime.utcnow() + timedelta(days=expire_days)
return datetime.now(UTC) + timedelta(days=expire_days)
+429
View File
@@ -0,0 +1,429 @@
"""OAuth 2.0 provider implementations for cabinet authentication."""
import secrets
from abc import ABC, abstractmethod
from typing import Any, TypedDict
import httpx
import structlog
from pydantic import BaseModel
from app.config import settings
from app.utils.cache import cache, cache_key
logger = structlog.get_logger(__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,
)
+5 -4
View File
@@ -2,6 +2,7 @@
import bcrypt
BCRYPT_ROUNDS = 12
@@ -15,10 +16,10 @@ def hash_password(password: str) -> str:
Returns:
Hashed password string
"""
password_bytes = password.encode("utf-8")
password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode("utf-8")
return hashed.decode('utf-8')
def verify_password(password: str, password_hash: str) -> bool:
@@ -33,8 +34,8 @@ def verify_password(password: str, password_hash: str) -> bool:
True if password matches, False otherwise
"""
try:
password_bytes = password.encode("utf-8")
hash_bytes = password_hash.encode("utf-8")
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
+21 -33
View File
@@ -3,14 +3,14 @@
import hashlib
import hmac
import json
from datetime import datetime
from typing import Dict, Any, Optional
from datetime import UTC, 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:
def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int = 86400) -> bool:
"""
Validate Telegram Login Widget data.
@@ -24,42 +24,38 @@ def validate_telegram_login_widget(data: Dict[str, Any], max_age_seconds: int =
True if data is valid, False otherwise
"""
auth_data = data.copy()
check_hash = auth_data.pop("hash", None)
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")
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()
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
age = (datetime.now(UTC) - auth_time).total_seconds()
if age > max_age_seconds:
return False
except (ValueError, TypeError, OSError):
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)
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()
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) -> Optional[Dict[str, Any]]:
def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) -> dict[str, Any] | None:
"""
Validate Telegram WebApp initData.
@@ -76,46 +72,38 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
# Parse the init_data string
parsed = dict(parse_qsl(init_data, keep_blank_values=True))
received_hash = parsed.pop("hash", None)
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")
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()
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
age = (datetime.now(UTC) - auth_time).total_seconds()
if age > max_age_seconds:
return None
except (ValueError, TypeError, OSError):
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)
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()
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()
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")
user_data_str = parsed.get('user')
if user_data_str:
user_data = json.loads(unquote(user_data_str))
return user_data
@@ -126,7 +114,7 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
return None
def extract_telegram_user_from_init_data(init_data: str) -> Optional[Dict[str, Any]]:
def extract_telegram_user_from_init_data(init_data: str) -> dict[str, Any] | None:
"""
Extract and validate user info from Telegram WebApp initData.
+116 -26
View File
@@ -1,18 +1,38 @@
"""FastAPI dependencies for cabinet module."""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
import asyncio
import structlog
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.database.crud.user import get_user_by_id
from app.config import settings
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 = structlog.get_logger(__name__)
security = HTTPBearer(auto_error=False)
# Кешированный Bot для проверки подписки на канал
_channel_check_bot: Bot | None = None
def _get_channel_check_bot() -> Bot:
"""Получить или создать Bot для проверки подписки на канал."""
global _channel_check_bot
if _channel_check_bot is None:
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
return _channel_check_bot
async def get_cabinet_db() -> AsyncSession:
"""Get database session for cabinet operations."""
@@ -24,7 +44,7 @@ async def get_cabinet_db() -> AsyncSession:
async def get_current_cabinet_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
"""
@@ -40,30 +60,35 @@ async def get_current_cabinet_user(
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"},
detail='Authentication required',
headers={'WWW-Authenticate': 'Bearer'},
)
token = credentials.credentials
payload = get_token_payload(token, expected_type="access")
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"},
detail='Invalid or expired token',
headers={'WWW-Authenticate': 'Bearer'},
)
try:
user_id = int(payload.get("sub"))
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"},
detail='Invalid token payload',
headers={'WWW-Authenticate': 'Bearer'},
)
user = await get_user_by_id(db, user_id)
@@ -71,22 +96,86 @@ async def get_current_cabinet_user(
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
detail='User not found',
)
if user.status != "active":
if user.status != 'active':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is not active",
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('Timeout checking channel subscription for user', telegram_id=user.telegram_id)
# Don't block user if check times out
except Exception as e:
logger.warning(
'Failed to check channel subscription for user', telegram_id=user.telegram_id, error=e
)
# Don't block user if check fails
return user
async def get_optional_cabinet_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> Optional[User]:
) -> User | None:
"""
Optionally get current authenticated cabinet user.
@@ -96,19 +185,19 @@ async def get_optional_cabinet_user(
return None
token = credentials.credentials
payload = get_token_payload(token, expected_type="access")
payload = get_token_payload(token, expected_type='access')
if not payload:
return None
try:
user_id = int(payload.get("sub"))
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":
if not user or user.status != 'active':
return None
return user
@@ -120,7 +209,7 @@ async def get_current_admin_user(
"""
Get current authenticated admin user.
Checks if the user's telegram_id is in ADMIN_IDS from settings.
Checks if the user is admin by telegram_id or email.
Args:
user: Authenticated User object
@@ -131,10 +220,11 @@ async def get_current_admin_user(
Raises:
HTTPException: If user is not an admin
"""
if not settings.is_admin(user.telegram_id):
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",
detail='Admin access required',
)
return user
+50 -26
View File
@@ -2,48 +2,64 @@
from fastapi import APIRouter
from .auth import router as auth_router
from .subscription import router as subscription_router
from .balance import router as balance_router
from .referral import router as referral_router
from .tickets import router as tickets_router
from .ticket_notifications import router as ticket_notifications_router
from .ticket_notifications import admin_router as admin_ticket_notifications_router
from .admin_tickets import router as admin_tickets_router
from .admin_settings import router as admin_settings_router
from .admin_apps import router as admin_apps_router
from .promocode import router as promocode_router
from .contests import router as contests_router
from .polls import router as polls_router
from .promo import router as promo_router
from .notifications import router as notifications_router
from .info import router as info_router
from .branding import router as branding_router
from .wheel import router as wheel_router
from .admin_wheel import router as admin_wheel_router
from .admin_tariffs import router as admin_tariffs_router
from .admin_servers import router as admin_servers_router
from .admin_stats import router as admin_stats_router
from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router
from .admin_promocodes import router as admin_promocodes_router
from .admin_promocodes import promo_groups_router as admin_promo_groups_router
from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_users import router as admin_users_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_partners import router as admin_partners_router
from .admin_payment_methods import router as admin_payment_methods_router
from .admin_payments import router as admin_payments_router
from .admin_pinned_messages import router as admin_pinned_messages_router
from .admin_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 .admin_withdrawals import router as admin_withdrawals_router
from .auth import router as auth_router
from .balance import router as balance_router
from .branding import router as branding_router
from .contests import router as contests_router
from .info import router as info_router
from .media import router as media_router
from .notifications import router as notifications_router
from .oauth import router as oauth_router
from .partner_application import router as partner_application_router
from .polls import router as polls_router
from .promo import router as promo_router
from .promocode import router as promocode_router
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
from .withdrawal import router as withdrawal_router
# Main cabinet router
router = APIRouter(prefix="/cabinet", tags=["Cabinet"])
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)
router.include_router(partner_application_router)
router.include_router(withdrawal_router)
# Notifications router MUST be before tickets router to avoid route conflict
router.include_router(ticket_notifications_router)
router.include_router(tickets_router)
@@ -73,12 +89,20 @@ router.include_router(admin_broadcasts_router)
router.include_router(admin_promocodes_router)
router.include_router(admin_promo_groups_router)
router.include_router(admin_campaigns_router)
router.include_router(admin_partners_router)
router.include_router(admin_withdrawals_router)
router.include_router(admin_users_router)
router.include_router(admin_payment_methods_router)
router.include_router(admin_payments_router)
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"]
__all__ = ['router']
+126 -101
View File
@@ -1,71 +1,78 @@
"""Admin routes for managing VPN applications in app-config.json."""
import json
import logging
from typing import List, Optional, Dict, Any
from pathlib import Path
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
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"])
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
# ============ Schemas ============
class LocalizedText(BaseModel):
"""Localized text for multiple languages."""
en: str = ""
ru: str = ""
zh: Optional[str] = ""
fa: Optional[str] = ""
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: Optional[List[AppButton]] = None
title: Optional[LocalizedText] = None
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: Optional[bool] = None
isNeedBase64Encoding: bool | None = None
installationStep: AppStep
addSubscriptionStep: AppStep
connectAndUseStep: AppStep
additionalBeforeAddSubscriptionStep: Optional[AppStep] = None
additionalAfterAddSubscriptionStep: Optional[AppStep] = None
additionalBeforeAddSubscriptionStep: AppStep | None = None
additionalAfterAddSubscriptionStep: AppStep | None = None
class PlatformApps(BaseModel):
"""Apps for a specific platform."""
platform: str
apps: List[AppDefinition]
apps: list[AppDefinition]
class AppConfigBranding(BaseModel):
"""Branding configuration."""
name: str
logoUrl: str
supportUrl: str
@@ -73,39 +80,46 @@ class AppConfigBranding(BaseModel):
class AppConfigConfig(BaseModel):
"""Top-level config section."""
additionalLocales: List[str]
additionalLocales: list[str]
branding: AppConfigBranding
class AppConfigResponse(BaseModel):
"""Full app config response."""
config: AppConfigConfig
platforms: Dict[str, List[AppDefinition]]
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]
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())
@@ -117,16 +131,16 @@ def _load_config() -> dict:
if not config_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App config file not found: {config_path}",
detail=f'App config file not found: {config_path}',
)
try:
with open(config_path, "r", encoding="utf-8") as f:
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}",
detail=f'Failed to parse app config: {e}',
)
@@ -135,21 +149,22 @@ def _save_config(config: dict) -> None:
config_path = _get_config_path()
try:
with open(config_path, "w", encoding="utf-8") as f:
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}",
detail=f'Failed to save app config: {e}',
)
VALID_PLATFORMS = ["ios", "android", "macos", "windows", "linux", "androidTV", "appleTV"]
VALID_PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']
# ============ Routes ============
@router.get("", response_model=AppConfigResponse)
@router.get('', response_model=AppConfigResponse)
async def get_app_config(
admin: User = Depends(get_current_admin_user),
):
@@ -158,7 +173,7 @@ async def get_app_config(
return config
@router.get("/platforms", response_model=List[str])
@router.get('/platforms', response_model=list[str])
async def get_platforms(
admin: User = Depends(get_current_admin_user),
):
@@ -166,7 +181,7 @@ async def get_platforms(
return VALID_PLATFORMS
@router.get("/platforms/{platform}", response_model=List[AppDefinition])
@router.get('/platforms/{platform}', response_model=list[AppDefinition])
async def get_platform_apps(
platform: str,
admin: User = Depends(get_current_admin_user),
@@ -175,15 +190,15 @@ async def get_platform_apps(
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}",
detail=f'Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}',
)
config = _load_config()
platforms = config.get("platforms", {})
platforms = config.get('platforms', {})
return platforms.get(platform, [])
@router.post("/platforms/{platform}", response_model=AppDefinition)
@router.post('/platforms/{platform}', response_model=AppDefinition)
async def create_app(
platform: str,
request: CreateAppRequest,
@@ -193,17 +208,17 @@ async def create_app(
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid platform: {platform}",
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get("platforms", {})
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]]
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,
@@ -213,15 +228,15 @@ async def create_app(
# Add new app
app_dict = request.app.model_dump(exclude_none=True)
platforms[platform].append(app_dict)
config["platforms"] = platforms
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'")
logger.info('Admin created app for platform', admin_id=admin.id, app_id=request.app.id, platform=platform)
return request.app
@router.put("/platforms/{platform}/{app_id}", response_model=AppDefinition)
@router.put('/platforms/{platform}/{app_id}', response_model=AppDefinition)
async def update_app(
platform: str,
app_id: str,
@@ -232,17 +247,17 @@ async def update_app(
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid platform: {platform}",
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get("platforms", {})
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:
if app.get('id') == app_id:
app_index = i
break
@@ -256,15 +271,15 @@ async def update_app(
app_dict = request.app.model_dump(exclude_none=True)
apps[app_index] = app_dict
platforms[platform] = apps
config["platforms"] = platforms
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'")
logger.info('Admin updated app in platform', admin_id=admin.id, app_id=app_id, platform=platform)
return request.app
@router.delete("/platforms/{platform}/{app_id}")
@router.delete('/platforms/{platform}/{app_id}')
async def delete_app(
platform: str,
app_id: str,
@@ -274,16 +289,16 @@ async def delete_app(
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid platform: {platform}",
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get("platforms", {})
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]
apps = [app for app in apps if app.get('id') != app_id]
if len(apps) == original_length:
raise HTTPException(
@@ -292,15 +307,15 @@ async def delete_app(
)
platforms[platform] = apps
config["platforms"] = platforms
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'")
logger.info('Admin deleted app from platform', admin_id=admin.id, app_id=app_id, platform=platform)
return {"status": "deleted", "app_id": app_id}
return {'status': 'deleted', 'app_id': app_id}
@router.post("/platforms/{platform}/reorder")
@router.post('/platforms/{platform}/reorder')
async def reorder_apps(
platform: str,
request: ReorderAppsRequest,
@@ -310,15 +325,15 @@ async def reorder_apps(
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid platform: {platform}",
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get("platforms", {})
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}
apps_map = {app.get('id'): app for app in apps}
# Verify all IDs exist
for app_id in request.app_ids:
@@ -333,19 +348,19 @@ async def reorder_apps(
# 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:
if app.get('id') not in request.app_ids:
reordered_apps.append(app)
platforms[platform] = reordered_apps
config["platforms"] = platforms
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'")
logger.info('Admin reordered apps in platform', admin_id=admin.id, platform=platform)
return {"status": "reordered", "order": request.app_ids}
return {'status': 'reordered', 'order': request.app_ids}
@router.put("/branding", response_model=AppConfigBranding)
@router.put('/branding', response_model=AppConfigBranding)
async def update_branding(
request: UpdateBrandingRequest,
admin: User = Depends(get_current_admin_user),
@@ -353,28 +368,28 @@ async def update_branding(
"""Update branding configuration."""
config = _load_config()
if "config" not in config:
config["config"] = {}
if 'config' not in config:
config['config'] = {}
config["config"]["branding"] = request.branding.model_dump()
config['config']['branding'] = request.branding.model_dump()
_save_config(config)
logger.info(f"Admin {admin.id} updated branding")
logger.info('Admin updated branding', admin_id=admin.id)
return request.branding
@router.get("/branding", response_model=AppConfigBranding)
@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", {})
branding = config.get('config', {}).get('branding', {})
return branding
@router.post("/platforms/{platform}/copy/{app_id}")
@router.post('/platforms/{platform}/copy/{app_id}')
async def copy_app_to_platform(
platform: str,
app_id: str,
@@ -385,17 +400,17 @@ async def copy_app_to_platform(
if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid platform(s)",
detail='Invalid platform(s)',
)
config = _load_config()
platforms = config.get("platforms", {})
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:
if app.get('id') == app_id:
source_app = app.copy()
break
@@ -407,44 +422,55 @@ async def copy_app_to_platform(
# Generate new ID for copied app
import time
new_id = f"{app_id}-copy-{int(time.time())}"
source_app["id"] = new_id
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
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'")
logger.info(
'Admin copied app from to as',
admin_id=admin.id,
app_id=app_id,
platform=platform,
target_platform=target_platform,
new_id=new_id,
)
return {"status": "copied", "new_id": new_id, "target_platform": target_platform}
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: Optional[str] = None
config_uuid: str | None = None
class UpdateRemnaWaveUuidRequest(BaseModel):
"""Request to update RemnaWave config UUID."""
uuid: Optional[str] = None
uuid: str | None = None
def _get_remnawave_config_uuid() -> Optional[str]:
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")
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)
@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus)
async def get_remnawave_config_status(
admin: User = Depends(get_current_admin_user),
):
@@ -456,7 +482,7 @@ async def get_remnawave_config_status(
)
@router.put("/remnawave/uuid", response_model=RemnaWaveConfigStatus)
@router.put('/remnawave/uuid', response_model=RemnaWaveConfigStatus)
async def set_remnawave_config_uuid(
request: UpdateRemnaWaveUuidRequest,
admin: User = Depends(get_current_admin_user),
@@ -468,24 +494,23 @@ async def set_remnawave_config_uuid(
# 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}$'
)
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",
detail='Invalid UUID format',
)
try:
await bot_configuration_service.set_value(db, "CABINET_REMNA_SUB_CONFIG", uuid_value)
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}'")
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG to', admin_id=admin.id, uuid_value=uuid_value)
except Exception as e:
logger.error(f"Error saving RemnaWave config UUID: {e}")
logger.error('Error saving RemnaWave config UUID', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to save configuration",
detail='Failed to save configuration',
)
return RemnaWaveConfigStatus(
@@ -494,7 +519,7 @@ async def set_remnawave_config_uuid(
)
@router.get("/remnawave/config")
@router.get('/remnawave/config')
async def get_remnawave_subscription_config(
admin: User = Depends(get_current_admin_user),
):
@@ -506,7 +531,7 @@ async def get_remnawave_subscription_config(
if not config_uuid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="CABINET_REMNA_SUB_CONFIG is not configured",
detail='CABINET_REMNA_SUB_CONFIG is not configured',
)
try:
@@ -521,22 +546,22 @@ async def get_remnawave_subscription_config(
# Return the raw config data from RemnaWave
return {
"uuid": config.uuid,
"name": config.name,
"view_position": config.view_position,
"config": config.config,
'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}")
logger.error('Error fetching RemnaWave config', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch config from RemnaWave: {str(e)}",
detail=f'Failed to fetch config from RemnaWave: {e!s}',
)
@router.get("/remnawave/configs")
@router.get('/remnawave/configs')
async def list_remnawave_subscription_configs(
admin: User = Depends(get_current_admin_user),
):
@@ -547,15 +572,15 @@ async def list_remnawave_subscription_configs(
configs = await api.get_subscription_page_configs()
return [
{
"uuid": c.uuid,
"name": c.name,
"view_position": c.view_position,
'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}")
logger.error('Error listing RemnaWave configs', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch configs from RemnaWave: {str(e)}",
detail=f'Failed to fetch configs from RemnaWave: {e!s}',
)
File diff suppressed because it is too large Load Diff
+412 -119
View File
@@ -1,21 +1,22 @@
"""Admin routes for broadcasts in cabinet."""
import logging
from datetime import datetime
from typing import List, Optional
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy import distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import BroadcastHistory, Tariff, User, Subscription, SubscriptionStatus
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 app.handlers.admin.messages import get_target_users_count
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.broadcasts import (
@@ -29,71 +30,98 @@ from ..schemas.broadcasts import (
BroadcastPreviewResponse,
BroadcastResponse,
BroadcastTariffsResponse,
CombinedBroadcastCreateRequest,
EmailFilterItem,
EmailFiltersResponse,
EmailPreviewRequest,
EmailPreviewResponse,
TariffFilter,
TariffForBroadcast,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/broadcasts", tags=["Cabinet Admin Broadcasts"])
logger = structlog.get_logger(__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": "Триальные с нулевым трафиком",
'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",
'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_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",
'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."""
blocked = broadcast.blocked_count or 0
progress = 0.0
if broadcast.total_count > 0:
progress = round((broadcast.sent_count + broadcast.failed_count) / broadcast.total_count * 100, 1)
progress = round((broadcast.sent_count + broadcast.failed_count + blocked) / broadcast.total_count * 100, 1)
return BroadcastResponse(
id=broadcast.id,
@@ -106,25 +134,87 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
total_count=broadcast.total_count,
sent_count=broadcast.sent_count,
failed_count=broadcast.failed_count,
blocked_count=blocked,
status=broadcast.status,
admin_id=broadcast.admin_id,
admin_name=broadcast.admin_name,
created_at=broadcast.created_at,
completed_at=broadcast.completed_at,
progress_percent=progress,
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")
)
select(Subscription.tariff_id, func.count(func.distinct(Subscription.user_id)).label('count'))
.join(User, User.id == Subscription.user_id)
.where(
User.status == "active",
User.status == 'active',
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
.group_by(Subscription.tariff_id)
@@ -138,26 +228,24 @@ def _validate_target(target: str, tariff_ids: set) -> bool:
return True
if target in CUSTOM_FILTER_LABELS:
return True
if target.startswith("tariff_"):
if target.startswith('tariff_'):
try:
tariff_id = int(target.split("_")[1])
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:
def _validate_buttons(buttons: list[str]) -> bool:
"""Validate button keys."""
for button in buttons:
if button not in BROADCAST_BUTTONS:
return False
return True
return all(button in BROADCAST_BUTTONS for button in buttons)
# ============ Endpoints ============
@router.get("/filters", response_model=BroadcastFiltersResponse)
@router.get('/filters', response_model=BroadcastFiltersResponse)
async def get_filters(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -169,14 +257,16 @@ async def get_filters(
try:
count = await get_target_users_count(db, key)
except Exception as e:
logger.warning(f"Failed to get count for filter {key}: {e}")
logger.warning('Failed to get count for filter', key=key, error=e)
count = 0
filters.append(BroadcastFilter(
key=key,
label=label,
count=count,
group=FILTER_GROUPS.get(key),
))
filters.append(
BroadcastFilter(
key=key,
label=label,
count=count,
group=FILTER_GROUPS.get(key),
)
)
# Custom filters
custom_filters = []
@@ -184,30 +274,32 @@ async def get_filters(
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}")
logger.warning('Failed to get count for custom filter', key=key, error=e)
count = 0
custom_filters.append(BroadcastFilter(
key=key,
label=label,
count=count,
group=CUSTOM_FILTER_GROUPS.get(key),
))
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)
)
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),
))
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,
@@ -216,16 +308,14 @@ async def get_filters(
)
@router.get("/tariffs", response_model=BroadcastTariffsResponse)
@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)
)
result = await db.execute(select(Tariff).where(Tariff.is_active == True).order_by(Tariff.name))
tariffs = result.scalars().all()
return BroadcastTariffsResponse(
@@ -233,7 +323,7 @@ async def get_tariffs(
TariffForBroadcast(
id=t.id,
name=t.name,
filter_key=f"tariff_{t.id}",
filter_key=f'tariff_{t.id}',
active_users_count=tariff_counts.get(t.id, 0),
)
for t in tariffs
@@ -241,7 +331,7 @@ async def get_tariffs(
)
@router.get("/buttons", response_model=BroadcastButtonsResponse)
@router.get('/buttons', response_model=BroadcastButtonsResponse)
async def get_buttons(
admin: User = Depends(get_current_admin_user),
) -> BroadcastButtonsResponse:
@@ -249,15 +339,17 @@ async def get_buttons(
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,
))
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)
@router.post('/preview', response_model=BroadcastPreviewResponse)
async def preview_broadcast(
request: BroadcastPreviewRequest,
admin: User = Depends(get_current_admin_user),
@@ -271,22 +363,22 @@ async def preview_broadcast(
if not _validate_target(request.target, tariff_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid target: {request.target}",
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}")
logger.error('Failed to get count for target', target=request.target, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to count recipients",
detail='Failed to count recipients',
)
return BroadcastPreviewResponse(target=request.target, count=count)
@router.post("", response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
@router.post('', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_broadcast(
request: BroadcastCreateRequest,
admin: User = Depends(get_current_admin_user),
@@ -300,21 +392,21 @@ async def create_broadcast(
if not _validate_target(request.target, tariff_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid target: {request.target}",
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",
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",
detail='Message text must not be empty',
)
media_payload = request.media
@@ -330,9 +422,9 @@ async def create_broadcast(
total_count=0,
sent_count=0,
failed_count=0,
status="queued",
status='queued',
admin_id=admin.id,
admin_name=admin.username or f"Admin #{admin.id}",
admin_name=admin.username or f'Admin #{admin.id}',
)
db.add(broadcast)
await db.commit()
@@ -353,19 +445,21 @@ async def create_broadcast(
message_text=message_text,
selected_buttons=request.selected_buttons,
media=media_config,
initiator_name=admin.username or f"Admin #{admin.id}",
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}'")
logger.info(
'Admin created broadcast for target', admin_id=admin.id, broadcast_id=broadcast.id, target=request.target
)
return _serialize_broadcast(broadcast)
@router.get("", response_model=BroadcastListResponse)
@router.get('', response_model=BroadcastListResponse)
async def list_broadcasts(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -376,10 +470,7 @@ async def list_broadcasts(
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)
select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit)
)
broadcasts = result.scalars().all()
@@ -391,7 +482,201 @@ async def list_broadcasts(
)
@router.get("/{broadcast_id}", response_model=BroadcastResponse)
# ============ 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('Failed to get count for email filter', key=key, error=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('Failed to get email count for target', target=request.target, error=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(
'Admin created broadcast for target',
admin_id=admin.id,
channel=request.channel,
broadcast_id=broadcast.id,
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),
@@ -402,42 +687,50 @@ async def get_broadcast(
if not broadcast:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Broadcast not found",
detail='Broadcast not found',
)
return _serialize_broadcast(broadcast)
@router.post("/{broadcast_id}/stop", response_model=BroadcastResponse)
@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."""
"""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",
detail='Broadcast not found',
)
if broadcast.status not in {"queued", "in_progress", "cancelling"}:
if broadcast.status not in {'queued', 'in_progress', 'cancelling'}:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Broadcast is not running",
detail='Broadcast is not running',
)
is_running = await broadcast_service.request_stop(broadcast_id)
# 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"
broadcast.status = 'cancelling'
else:
broadcast.status = "cancelled"
broadcast.completed_at = datetime.utcnow()
broadcast.status = 'cancelled'
broadcast.completed_at = datetime.now(UTC)
await db.commit()
await db.refresh(broadcast)
logger.info(f"Admin {admin.id} stopped broadcast {broadcast_id}")
logger.info('Admin stopped broadcast', admin_id=admin.id, broadcast_id=broadcast_id)
return _serialize_broadcast(broadcast)
+255
View File
@@ -0,0 +1,255 @@
"""Admin routes for per-section cabinet button style configuration."""
import json
import structlog
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 = structlog.get_logger(__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 updated button styles for sections', telegram_id=admin.telegram_id, changed_sections=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 reset button styles to defaults', telegram_id=admin.telegram_id)
return _build_response(DEFAULT_BUTTON_STYLES)
+209 -134
View File
@@ -1,20 +1,13 @@
"""Admin routes for managing advertising campaigns in cabinet."""
import logging
from typing import List, Optional
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.config import settings
from app.database.models import (
User,
AdvertisingCampaign,
AdvertisingCampaignRegistration,
Subscription,
Tariff,
)
from app.database.crud.campaign import (
create_campaign,
delete_campaign,
@@ -28,38 +21,64 @@ from app.database.crud.campaign import (
)
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import get_all_tariffs
from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
PartnerStatus,
Subscription,
Tariff,
User,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.campaigns import (
CampaignListResponse,
CampaignListItem,
CampaignDetailResponse,
AvailablePartnerItem,
CampaignCreateRequest,
CampaignUpdateRequest,
CampaignToggleResponse,
CampaignStatisticsResponse,
CampaignDetailResponse,
CampaignListItem,
CampaignListResponse,
CampaignRegistrationItem,
CampaignRegistrationsResponse,
CampaignsOverviewResponse,
TariffInfo,
CampaignStatisticsResponse,
CampaignToggleResponse,
CampaignUpdateRequest,
ServerSquadInfo,
TariffInfo,
)
from ..schemas.tariffs import TariffListItem
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/campaigns", tags=["Cabinet Admin Campaigns"])
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/campaigns', tags=['Cabinet Admin Campaigns'])
def _get_deep_link(start_parameter: str) -> str:
"""Generate deep link for campaign."""
bot_username = settings.get_bot_username()
if bot_username:
return f"https://t.me/{bot_username}?start={start_parameter}"
return f"?start={start_parameter}"
return f'https://t.me/{bot_username}?start={start_parameter}'
return f'?start={start_parameter}'
@router.get("/overview", response_model=CampaignsOverviewResponse)
def _get_web_link(start_parameter: str) -> str | None:
"""Generate web link for campaign."""
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
if base_url:
return f'{base_url}/?campaign={start_parameter}'
return None
def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
"""Get partner display name from campaign."""
if not campaign.partner_user_id or not campaign.partner:
return None
partner = campaign.partner
return partner.first_name or partner.username or f'#{partner.id}'
@router.get('/overview', response_model=CampaignsOverviewResponse)
async def get_overview(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -70,24 +89,24 @@ async def get_overview(
# Count tariff bonuses
tariff_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.bonus_type == "tariff"
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=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])
@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),
@@ -105,7 +124,7 @@ async def get_available_servers(
]
@router.get("/available-tariffs", response_model=List[TariffListItem])
@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),
@@ -134,7 +153,27 @@ async def get_available_tariffs(
]
@router.get("", response_model=CampaignListResponse)
@router.get('/available-partners', response_model=list[AvailablePartnerItem])
async def get_available_partners(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of approved partners for campaign partner selector."""
result = await db.execute(
select(User).where(User.partner_status == PartnerStatus.APPROVED.value).order_by(User.first_name, User.username)
)
partners = result.scalars().all()
return [
AvailablePartnerItem(
user_id=p.id,
username=p.username,
first_name=p.first_name,
)
for p in partners
]
@router.get('', response_model=CampaignListResponse)
async def list_campaigns(
include_inactive: bool = True,
offset: int = Query(0, ge=0),
@@ -143,31 +182,33 @@ async def list_campaigns(
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
)
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,
))
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'],
partner_user_id=campaign.partner_user_id,
partner_name=_get_partner_name(campaign),
created_at=campaign.created_at,
)
)
return CampaignListResponse(campaigns=items, total=total)
@router.get("/{campaign_id}", response_model=CampaignDetailResponse)
@router.get('/{campaign_id}', response_model=CampaignDetailResponse)
async def get_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
@@ -178,7 +219,7 @@ async def get_campaign(
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Campaign not found",
detail='Campaign not found',
)
tariff_info = None
@@ -203,14 +244,17 @@ async def get_campaign(
tariff_id=campaign.tariff_id,
tariff_duration_days=campaign.tariff_duration_days,
tariff=tariff_info,
partner_user_id=campaign.partner_user_id,
partner_name=_get_partner_name(campaign),
created_by=campaign.created_by,
created_at=campaign.created_at,
updated_at=campaign.updated_at,
deep_link=_get_deep_link(campaign.start_parameter),
web_link=_get_web_link(campaign.start_parameter),
)
@router.get("/{campaign_id}/stats", response_model=CampaignStatisticsResponse)
@router.get('/{campaign_id}/stats', response_model=CampaignStatisticsResponse)
async def get_campaign_stats(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
@@ -221,7 +265,7 @@ async def get_campaign_stats(
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Campaign not found",
detail='Campaign not found',
)
stats = await get_campaign_statistics(db, campaign_id)
@@ -232,28 +276,29 @@ async def get_campaign_stats(
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"],
registrations=stats['registrations'],
balance_issued_kopeks=stats['balance_issued'],
balance_issued_rubles=stats['balance_issued'] / 100,
subscription_issued=stats['subscription_issued'],
last_registration=stats['last_registration'],
total_revenue_kopeks=stats['total_revenue_kopeks'],
total_revenue_rubles=stats['total_revenue_kopeks'] / 100,
avg_revenue_per_user_kopeks=stats['avg_revenue_per_user_kopeks'],
avg_revenue_per_user_rubles=stats['avg_revenue_per_user_kopeks'] / 100,
avg_first_payment_kopeks=stats['avg_first_payment_kopeks'],
avg_first_payment_rubles=stats['avg_first_payment_kopeks'] / 100,
trial_users_count=stats['trial_users_count'],
active_trials_count=stats['active_trials_count'],
conversion_count=stats['conversion_count'],
paid_users_count=stats['paid_users_count'],
conversion_rate=stats['conversion_rate'],
trial_conversion_rate=stats['trial_conversion_rate'],
deep_link=_get_deep_link(campaign.start_parameter),
web_link=_get_web_link(campaign.start_parameter),
)
@router.get("/{campaign_id}/registrations", response_model=CampaignRegistrationsResponse)
@router.get('/{campaign_id}/registrations', response_model=CampaignRegistrationsResponse)
async def get_campaign_registrations(
campaign_id: int,
page: int = Query(1, ge=1),
@@ -266,7 +311,7 @@ async def get_campaign_registrations(
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Campaign not found",
detail='Campaign not found',
)
offset = (page - 1) * per_page
@@ -284,40 +329,46 @@ async def get_campaign_registrations(
# Count total
count_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id))
.where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.campaign_id == campaign_id
)
)
total = count_result.scalar() or 0
# Batch query: find which users have active subscriptions (avoids N+1)
user_ids = [user.id for _reg, user in rows]
active_sub_user_ids: set[int] = set()
if user_ids:
sub_result = await db.execute(
select(Subscription.user_id)
.where(
Subscription.user_id.in_(user_ids),
Subscription.status == 'active',
)
.distinct()
)
active_sub_user_ids = set(sub_result.scalars().all())
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",
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=user.id in active_sub_user_ids,
has_paid=user.has_had_paid_subscription or False,
)
.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,
@@ -327,7 +378,7 @@ async def get_campaign_registrations(
)
@router.post("", response_model=CampaignDetailResponse)
@router.post('', response_model=CampaignDetailResponse)
async def create_new_campaign(
request: CampaignCreateRequest,
admin: User = Depends(get_current_admin_user),
@@ -343,20 +394,27 @@ async def create_new_campaign(
)
# Validate tariff exists if tariff bonus type
if request.bonus_type == "tariff":
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",
detail='Tariff ID is required for tariff bonus type',
)
tariff_result = await db.execute(
select(Tariff).where(Tariff.id == request.tariff_id)
)
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",
detail='Tariff not found',
)
# Validate partner exists and is approved
if request.partner_user_id is not None:
partner_user = await db.get(User, request.partner_user_id)
if not partner_user or partner_user.partner_status != 'approved':
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Partner not found or not approved',
)
campaign = await create_campaign(
@@ -373,17 +431,18 @@ async def create_new_campaign(
tariff_id=request.tariff_id,
tariff_duration_days=request.tariff_duration_days,
is_active=request.is_active,
partner_user_id=request.partner_user_id,
)
# Reload to get tariff relationship
campaign = await get_campaign_by_id(db, campaign.id)
logger.info(f"Admin {admin.id} created campaign {campaign.id}: {campaign.name}")
logger.info('Admin created campaign', admin_id=admin.id, campaign_id=campaign.id, campaign_name=campaign.name)
return await get_campaign(campaign.id, admin, db)
@router.put("/{campaign_id}", response_model=CampaignDetailResponse)
@router.put('/{campaign_id}', response_model=CampaignDetailResponse)
async def update_existing_campaign(
campaign_id: int,
request: CampaignUpdateRequest,
@@ -395,7 +454,7 @@ async def update_existing_campaign(
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Campaign not found",
detail='Campaign not found',
)
# Check if start_parameter is unique (if changing)
@@ -408,53 +467,69 @@ async def update_existing_campaign(
)
# Validate tariff if changing to tariff bonus type
if request.bonus_type == "tariff" or (campaign.bonus_type == "tariff" and request.tariff_id):
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_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",
detail='Tariff not found',
)
# Build updates
updates = {}
if request.name is not None:
updates["name"] = request.name
updates['name'] = request.name
if request.start_parameter is not None:
updates["start_parameter"] = request.start_parameter
updates['start_parameter'] = request.start_parameter
if request.bonus_type is not None:
updates["bonus_type"] = request.bonus_type
updates['bonus_type'] = request.bonus_type
if request.is_active is not None:
updates["is_active"] = request.is_active
updates['is_active'] = request.is_active
if request.balance_bonus_kopeks is not None:
updates["balance_bonus_kopeks"] = request.balance_bonus_kopeks
updates['balance_bonus_kopeks'] = request.balance_bonus_kopeks
if request.subscription_duration_days is not None:
updates["subscription_duration_days"] = request.subscription_duration_days
updates['subscription_duration_days'] = request.subscription_duration_days
if request.subscription_traffic_gb is not None:
updates["subscription_traffic_gb"] = request.subscription_traffic_gb
updates['subscription_traffic_gb'] = request.subscription_traffic_gb
if request.subscription_device_limit is not None:
updates["subscription_device_limit"] = request.subscription_device_limit
updates['subscription_device_limit'] = request.subscription_device_limit
if request.subscription_squads is not None:
updates["subscription_squads"] = request.subscription_squads
updates['subscription_squads'] = request.subscription_squads
if request.tariff_id is not None:
updates["tariff_id"] = request.tariff_id
updates['tariff_id'] = request.tariff_id
if request.tariff_duration_days is not None:
updates["tariff_duration_days"] = request.tariff_duration_days
updates['tariff_duration_days'] = request.tariff_duration_days
# Handle partner_user_id separately (allows explicit None to unassign)
partner_changed = False
if 'partner_user_id' in request.model_fields_set:
new_partner_id = request.partner_user_id
if new_partner_id is not None:
partner_user = await db.get(User, new_partner_id)
if not partner_user or partner_user.partner_status != 'approved':
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Partner not found or not approved',
)
campaign.partner_user_id = new_partner_id
campaign.updated_at = datetime.now(UTC)
partner_changed = True
if updates:
await update_campaign(db, campaign, **updates)
elif partner_changed:
await db.commit()
await db.refresh(campaign)
logger.info(f"Admin {admin.id} updated campaign {campaign_id}")
logger.info('Admin updated campaign', admin_id=admin.id, campaign_id=campaign_id)
return await get_campaign(campaign_id, admin, db)
@router.delete("/{campaign_id}")
@router.delete('/{campaign_id}')
async def delete_existing_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
@@ -465,7 +540,7 @@ async def delete_existing_campaign(
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Campaign not found",
detail='Campaign not found',
)
# Check if campaign has registrations
@@ -473,16 +548,16 @@ async def delete_existing_campaign(
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.",
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}")
logger.info('Admin deleted campaign', admin_id=admin.id, campaign_id=campaign_id, campaign_name=campaign.name)
return {"message": "Campaign deleted successfully"}
return {'message': 'Campaign deleted successfully'}
@router.post("/{campaign_id}/toggle", response_model=CampaignToggleResponse)
@router.post('/{campaign_id}/toggle', response_model=CampaignToggleResponse)
async def toggle_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
@@ -493,17 +568,17 @@ async def toggle_campaign(
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Campaign 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}")
status_text = 'activated' if new_status else 'deactivated'
logger.info('Admin campaign', admin_id=admin.id, status_text=status_text, campaign_id=campaign_id)
return CampaignToggleResponse(
id=campaign_id,
is_active=new_status,
message=f"Campaign {status_text}",
message=f'Campaign {status_text}',
)
+677
View File
@@ -0,0 +1,677 @@
"""Admin routes for managing email notification templates."""
import asyncio
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.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 = structlog.get_logger(__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(
'Админ обновил email шаблон /', admin_id=admin.id, notification_type=notification_type, language=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(
'Админ сбросил email шаблон / к дефолту',
admin_id=admin.id,
notification_type=notification_type,
language=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', e=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(
'Админ отправил тестовый email / на',
admin_id=admin.id,
notification_type=notification_type,
language=language,
to_email=to_email,
)
return {'status': 'ok', 'sent_to': to_email}
+582
View File
@@ -0,0 +1,582 @@
"""Admin routes for managing partners in cabinet."""
from datetime import UTC, datetime
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import desc, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import (
AdvertisingCampaign,
PartnerApplication,
PartnerStatus,
ReferralEarning,
User,
)
from app.services.partner_application_service import partner_application_service
from app.services.partner_stats_service import PartnerStatsService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.partners import (
AdminApproveRequest,
AdminPartnerApplicationItem,
AdminPartnerApplicationsResponse,
AdminPartnerDetailResponse,
AdminPartnerItem,
AdminPartnerListResponse,
AdminRejectRequest,
AdminUpdateCommissionRequest,
CampaignSummary,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/partners', tags=['Cabinet Admin Partners'])
# ==================== Settings ====================
class PartnerSettingsResponse(BaseModel):
withdrawal_enabled: bool
withdrawal_min_amount_kopeks: int
withdrawal_cooldown_days: int
withdrawal_requisites_text: str
partner_section_visible: bool
referral_program_enabled: bool
class PartnerSettingsUpdateRequest(BaseModel):
withdrawal_enabled: bool | None = None
withdrawal_min_amount_kopeks: int | None = Field(None, ge=0, le=100_000_000)
withdrawal_cooldown_days: int | None = Field(None, ge=0, le=365)
withdrawal_requisites_text: str | None = Field(None, max_length=2000)
partner_section_visible: bool | None = None
referral_program_enabled: bool | None = None
def _build_partner_settings_response() -> PartnerSettingsResponse:
return PartnerSettingsResponse(
withdrawal_enabled=settings.REFERRAL_WITHDRAWAL_ENABLED,
withdrawal_min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
withdrawal_cooldown_days=settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS,
withdrawal_requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
referral_program_enabled=settings.REFERRAL_PROGRAM_ENABLED,
)
@router.get('/settings', response_model=PartnerSettingsResponse)
async def get_partner_settings(
admin: User = Depends(get_current_admin_user),
):
"""Get partner system settings."""
return _build_partner_settings_response()
@router.patch('/settings', response_model=PartnerSettingsResponse)
async def update_partner_settings(
request: PartnerSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update partner system settings."""
from pathlib import Path
# Update in-memory settings
if request.withdrawal_enabled is not None:
settings.REFERRAL_WITHDRAWAL_ENABLED = request.withdrawal_enabled
if request.withdrawal_min_amount_kopeks is not None:
settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS = request.withdrawal_min_amount_kopeks
if request.withdrawal_cooldown_days is not None:
settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS = request.withdrawal_cooldown_days
if request.withdrawal_requisites_text is not None:
settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT = request.withdrawal_requisites_text
if request.partner_section_visible is not None:
settings.REFERRAL_PARTNER_SECTION_VISIBLE = request.partner_section_visible
if request.referral_program_enabled is not None:
settings.REFERRAL_PROGRAM_ENABLED = request.referral_program_enabled
# Persist to .env file
try:
env_file = Path('.env')
if env_file.exists():
lines = env_file.read_text().splitlines()
updates: dict[str, str] = {}
if request.withdrawal_enabled is not None:
updates['REFERRAL_WITHDRAWAL_ENABLED'] = str(request.withdrawal_enabled).lower()
if request.withdrawal_min_amount_kopeks is not None:
updates['REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS'] = str(request.withdrawal_min_amount_kopeks)
if request.withdrawal_cooldown_days is not None:
updates['REFERRAL_WITHDRAWAL_COOLDOWN_DAYS'] = str(request.withdrawal_cooldown_days)
if request.withdrawal_requisites_text is not None:
# Sanitize: replace newlines to prevent .env injection
sanitized = (
request.withdrawal_requisites_text.replace('\r\n', ' ').replace('\n', ' ').replace('\r', ' ')
)
updates['REFERRAL_WITHDRAWAL_REQUISITES_TEXT'] = sanitized
if request.partner_section_visible is not None:
updates['REFERRAL_PARTNER_SECTION_VISIBLE'] = str(request.partner_section_visible).lower()
if request.referral_program_enabled is not None:
updates['REFERRAL_PROGRAM_ENABLED'] = str(request.referral_program_enabled).lower()
new_lines = []
updated_keys: set[str] = set()
for line in lines:
updated = False
for key, value in updates.items():
if line.startswith(f'{key}='):
new_lines.append(f'{key}={value}')
updated_keys.add(key)
updated = True
break
if not updated:
new_lines.append(line)
for key, value in updates.items():
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n')
logger.info('Updated partner settings in .env file', admin_id=admin.id)
except Exception as e:
logger.warning('Failed to update .env file', error=e)
return _build_partner_settings_response()
# ==================== Applications (static paths first) ====================
@router.get('/applications', response_model=AdminPartnerApplicationsResponse)
async def list_applications(
application_status: Literal['pending', 'approved', 'rejected', 'none'] | None = Query(None, alias='status'),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List partner applications."""
applications, total = await partner_application_service.get_all_applications(
db, status=application_status, limit=limit, offset=offset
)
# Batch-fetch users to avoid N+1
user_ids = list({app.user_id for app in applications})
if user_ids:
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
else:
users_map = {}
items = []
for app in applications:
user = users_map.get(app.user_id)
items.append(
AdminPartnerApplicationItem(
id=app.id,
user_id=app.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
company_name=app.company_name,
website_url=app.website_url,
telegram_channel=app.telegram_channel,
description=app.description,
expected_monthly_referrals=app.expected_monthly_referrals,
status=app.status,
admin_comment=app.admin_comment,
approved_commission_percent=app.approved_commission_percent,
created_at=app.created_at,
processed_at=app.processed_at,
)
)
return AdminPartnerApplicationsResponse(items=items, total=total)
@router.post('/applications/{application_id}/approve')
async def approve_application(
application_id: int,
request: AdminApproveRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a partner application."""
success, error = await partner_application_service.approve_application(
db,
application_id=application_id,
admin_id=admin.id,
commission_percent=request.commission_percent,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about approval
try:
from aiogram import Bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
application = await db.get(PartnerApplication, application_id)
user = await db.get(User, application.user_id) if application else None
if user:
comment_text = f'\n{request.comment}' if request.comment else ''
tg_message = (
f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}'
)
bot = Bot(token=settings.BOT_TOKEN)
try:
await notification_delivery_service.notify_partner_approved(
user=user,
commission_percent=request.commission_percent,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send partner approval notification', error=e)
return {'success': True}
@router.post('/applications/{application_id}/reject')
async def reject_application(
application_id: int,
request: AdminRejectRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a partner application."""
success, error = await partner_application_service.reject_application(
db,
application_id=application_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about rejection
try:
from aiogram import Bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
application = await db.get(PartnerApplication, application_id)
user = await db.get(User, application.user_id) if application else None
if user:
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
try:
await notification_delivery_service.notify_partner_rejected(
user=user,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send partner rejection notification', error=e)
return {'success': True}
# ==================== Stats (static paths) ====================
@router.get('/stats')
async def get_partner_stats(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get overall partner statistics."""
total_partners = await db.execute(
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
)
pending_apps = await db.execute(
select(func.count())
.select_from(PartnerApplication)
.where(PartnerApplication.status == PartnerStatus.PENDING.value)
)
total_referrals = await db.execute(select(func.count()).select_from(User).where(User.referred_by_id.isnot(None)))
total_earnings = await db.execute(select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
return {
'total_partners': total_partners.scalar() or 0,
'pending_applications': pending_apps.scalar() or 0,
'total_referrals': total_referrals.scalar() or 0,
'total_earnings_kopeks': total_earnings.scalar() or 0,
}
# ==================== Partners list ====================
@router.get('', response_model=AdminPartnerListResponse)
async def list_partners(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List approved partners."""
count_result = await db.execute(
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
)
total = count_result.scalar() or 0
result = await db.execute(
select(User)
.where(User.partner_status == PartnerStatus.APPROVED.value)
.order_by(desc(User.created_at))
.offset(offset)
.limit(limit)
)
partners = result.scalars().all()
# Batch-fetch earnings and referral counts to avoid N+1
partner_ids = [u.id for u in partners]
earnings_map: dict[int, int] = {}
referral_count_map: dict[int, int] = {}
if partner_ids:
earnings_result = await db.execute(
select(ReferralEarning.user_id, func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.user_id.in_(partner_ids))
.group_by(ReferralEarning.user_id)
)
earnings_map = {row[0]: int(row[1]) for row in earnings_result.all()}
referral_result = await db.execute(
select(User.referred_by_id, func.count())
.where(User.referred_by_id.in_(partner_ids))
.group_by(User.referred_by_id)
)
referral_count_map = {row[0]: row[1] for row in referral_result.all()}
items = []
for user in partners:
items.append(
AdminPartnerItem(
user_id=user.id,
username=user.username,
first_name=user.first_name,
telegram_id=user.telegram_id,
commission_percent=user.referral_commission_percent,
total_referrals=referral_count_map.get(user.id, 0),
total_earnings_kopeks=earnings_map.get(user.id, 0),
balance_kopeks=user.balance_kopeks,
partner_status=user.partner_status,
created_at=user.created_at,
)
)
return AdminPartnerListResponse(items=items, total=total)
# ==================== Partner detail (parametric paths last) ====================
@router.get('/{user_id}', response_model=AdminPartnerDetailResponse)
async def get_partner_detail(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed partner info."""
user = await db.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Пользователь не найден',
)
stats = await PartnerStatsService.get_referrer_detailed_stats(db, user_id)
# Get assigned campaigns
campaigns_result = await db.execute(
select(AdvertisingCampaign).where(AdvertisingCampaign.partner_user_id == user_id)
)
campaigns = campaigns_result.scalars().all()
campaign_list = [
CampaignSummary(
id=c.id,
name=c.name,
start_parameter=c.start_parameter,
is_active=c.is_active,
)
for c in campaigns
]
summary = stats['summary']
earnings = stats['earnings']
return AdminPartnerDetailResponse(
user_id=user.id,
username=user.username,
first_name=user.first_name,
telegram_id=user.telegram_id,
commission_percent=user.referral_commission_percent,
partner_status=user.partner_status,
balance_kopeks=user.balance_kopeks,
total_referrals=summary['total_referrals'],
paid_referrals=summary['paid_referrals'],
active_referrals=summary['active_referrals'],
earnings_all_time=earnings['all_time_kopeks'],
earnings_today=earnings['today_kopeks'],
earnings_week=earnings['week_kopeks'],
earnings_month=earnings['month_kopeks'],
conversion_to_paid=summary['conversion_to_paid_percent'],
campaigns=campaign_list,
created_at=user.created_at,
)
@router.patch('/{user_id}/commission')
async def update_commission(
user_id: int,
request: AdminUpdateCommissionRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update partner commission percent."""
user = await db.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Пользователь не найден',
)
if user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Пользователь не является партнёром',
)
old_commission = user.referral_commission_percent
user.referral_commission_percent = request.commission_percent
await db.commit()
logger.info(
'Комиссия партнёра обновлена',
user_id=user_id,
old_commission=old_commission,
new_commission=request.commission_percent,
admin_id=admin.id,
)
return {'success': True, 'commission_percent': request.commission_percent}
@router.post('/{user_id}/revoke')
async def revoke_partner(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Revoke partner status."""
success, error = await partner_application_service.revoke_partner(db, user_id=user_id, admin_id=admin.id)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
return {'success': True}
@router.post('/{user_id}/campaigns/{campaign_id}/assign')
async def assign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Assign a campaign to a partner."""
campaign = await db.get(AdvertisingCampaign, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Кампания не найдена',
)
user = await db.get(User, user_id)
if not user or user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Пользователь не является партнёром',
)
# Atomic check-and-set to prevent race conditions
result = await db.execute(
update(AdvertisingCampaign)
.where(
AdvertisingCampaign.id == campaign_id,
or_(
AdvertisingCampaign.partner_user_id.is_(None),
AdvertisingCampaign.partner_user_id == user_id,
),
)
.values(partner_user_id=user_id, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Кампания уже привязана к другому партнёру',
)
await db.commit()
return {'success': True}
@router.post('/{user_id}/campaigns/{campaign_id}/unassign')
async def unassign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unassign a campaign from a partner."""
campaign = await db.get(AdvertisingCampaign, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Кампания не найдена',
)
if campaign.partner_user_id != user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Кампания не привязана к этому партнёру',
)
campaign.partner_user_id = None
campaign.updated_at = datetime.now(UTC)
await db.commit()
return {'success': True}
+228
View File
@@ -0,0 +1,228 @@
"""Admin routes for payment method configuration in cabinet."""
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.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 = structlog.get_logger(__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('Admin updated payment methods order', admin_id=admin.id, method_ids=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('Admin updated payment method config', admin_id=admin.id, method_id=method_id)
defaults = _get_method_defaults()
return _enrich_config(config, defaults)
+130 -120
View File
@@ -1,36 +1,38 @@
"""Admin routes for payment verification in cabinet."""
import logging
import math
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User, PaymentMethod
from app.database.models import PaymentMethod, User
from app.services.payment_service import PaymentService
from app.services.payment_verification_service import (
list_recent_pending_payments,
get_payment_record,
run_manual_check,
SUPPORTED_MANUAL_CHECK_METHODS,
method_display_name,
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"])
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/payments', tags=['Cabinet Admin Payments'])
# ============ Schemas ============
class PendingPaymentResponse(BaseModel):
"""Pending payment details."""
id: int
method: str
method_display: str
@@ -43,11 +45,11 @@ class PendingPaymentResponse(BaseModel):
is_paid: bool
is_checkable: bool
created_at: datetime
expires_at: Optional[datetime] = None
payment_url: Optional[str] = None
user_id: Optional[int] = None
user_telegram_id: Optional[int] = None
user_username: Optional[str] = None
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
@@ -55,7 +57,8 @@ class PendingPaymentResponse(BaseModel):
class PendingPaymentListResponse(BaseModel):
"""Paginated list of pending payments."""
items: List[PendingPaymentResponse]
items: list[PendingPaymentResponse]
total: int
page: int
per_page: int
@@ -64,120 +67,123 @@ class PendingPaymentListResponse(BaseModel):
class ManualCheckResponse(BaseModel):
"""Response after manual payment status check."""
success: bool
message: str
payment: Optional[PendingPaymentResponse] = None
payment: PendingPaymentResponse | None = None
status_changed: bool = False
old_status: Optional[str] = None
new_status: Optional[str] = None
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()
status_str = (record.status or '').lower()
if record.is_paid:
return "", "Оплачено"
return '', 'Оплачено'
if record.method == PaymentMethod.PAL24:
mapping = {
"new": ("", "Ожидает оплаты"),
"process": ("", "Обрабатывается"),
"success": ("", "Оплачено"),
"fail": ("", "Ошибка"),
"canceled": ("", "Отменено"),
'new': ('', 'Ожидает оплаты'),
'process': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'fail': ('', 'Ошибка'),
'canceled': ('', 'Отменено'),
}
return mapping.get(status_str, ("", "Неизвестно"))
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.MULENPAY:
mapping = {
"created": ("", "Ожидает оплаты"),
"processing": ("", "Обрабатывается"),
"hold": ("🔒", "На удержании"),
"success": ("", "Оплачено"),
"canceled": ("", "Отменено"),
"error": ("", "Ошибка"),
'created': ('', 'Ожидает оплаты'),
'processing': ('', 'Обрабатывается'),
'hold': ('🔒', 'На удержании'),
'success': ('', 'Оплачено'),
'canceled': ('', 'Отменено'),
'error': ('', 'Ошибка'),
}
return mapping.get(status_str, ("", "Неизвестно"))
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.WATA:
mapping = {
"opened": ("", "Ожидает оплаты"),
"pending": ("", "Ожидает оплаты"),
"processing": ("", "Обрабатывается"),
"paid": ("", "Оплачено"),
"closed": ("", "Оплачено"),
"declined": ("", "Отклонено"),
"canceled": ("", "Отменено"),
"expired": ("", "Истёк"),
'opened': ('', 'Ожидает оплаты'),
'pending': ('', 'Ожидает оплаты'),
'processing': ('', 'Обрабатывается'),
'paid': ('', 'Оплачено'),
'closed': ('', 'Оплачено'),
'declined': ('', 'Отклонено'),
'canceled': ('', 'Отменено'),
'expired': ('', 'Истёк'),
}
return mapping.get(status_str, ("", "Неизвестно"))
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.PLATEGA:
mapping = {
"pending": ("", "Ожидает оплаты"),
"inprogress": ("", "Обрабатывается"),
"confirmed": ("", "Оплачено"),
"failed": ("", "Ошибка"),
"canceled": ("", "Отменено"),
"expired": ("", "Истёк"),
'pending': ('', 'Ожидает оплаты'),
'inprogress': ('', 'Обрабатывается'),
'confirmed': ('', 'Оплачено'),
'failed': ('', 'Ошибка'),
'canceled': ('', 'Отменено'),
'expired': ('', 'Истёк'),
}
return mapping.get(status_str, ("", "Неизвестно"))
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 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": ("", "Отменено"),
'pending': ('', 'Ожидает оплаты'),
'waiting_for_capture': ('', 'Обрабатывается'),
'succeeded': ('', 'Оплачено'),
'canceled': ('', 'Отменено'),
}
return mapping.get(status_str, ("", "Неизвестно"))
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.CRYPTOBOT:
mapping = {
"active": ("", "Ожидает оплаты"),
"paid": ("", "Оплачено"),
"expired": ("", "Истёк"),
'active': ('', 'Ожидает оплаты'),
'paid': ('', 'Оплачено'),
'expired': ('', 'Истёк'),
}
return mapping.get(status_str, ("", "Неизвестно"))
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.CLOUDPAYMENTS:
mapping = {
"pending": ("", "Ожидает оплаты"),
"authorized": ("", "Авторизовано"),
"completed": ("", "Оплачено"),
"failed": ("", "Ошибка"),
'pending': ('', 'Ожидает оплаты'),
'authorized': ('', 'Авторизовано'),
'completed': ('', 'Оплачено'),
'failed': ('', 'Ошибка'),
}
return mapping.get(status_str, ("", "Неизвестно"))
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.FREEKASSA:
mapping = {
"pending": ("", "Ожидает оплаты"),
"success": ("", "Оплачено"),
"paid": ("", "Оплачено"),
"canceled": ("", "Отменено"),
"error": ("", "Ошибка"),
'pending': ('', 'Ожидает оплаты'),
'success': ('', 'Оплачено'),
'paid': ('', 'Оплачено'),
'canceled': ('', 'Отменено'),
'error': ('', 'Ошибка'),
}
return mapping.get(status_str, ("", "Неизвестно"))
return mapping.get(status_str, ('', 'Неизвестно'))
return "", "Неизвестно"
return '', 'Неизвестно'
def _is_checkable(record: PendingPayment) -> bool:
@@ -186,52 +192,50 @@ def _is_checkable(record: PendingPayment) -> bool:
return False
if not record.is_recent():
return False
status_str = (record.status or "").lower()
status_str = (record.status or '').lower()
if record.method == PaymentMethod.PAL24:
return status_str in {"new", "process"}
return status_str in {'new', 'process'}
if record.method == PaymentMethod.MULENPAY:
return status_str in {"created", "processing", "hold"}
return status_str in {'created', 'processing', 'hold'}
if record.method == PaymentMethod.WATA:
return status_str in {"opened", "pending", "processing", "inprogress", "in_progress"}
return status_str in {'opened', 'pending', 'processing', 'inprogress', 'in_progress'}
if record.method == PaymentMethod.PLATEGA:
return status_str in {"pending", "inprogress", "in_progress"}
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"}
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"}
return status_str in {'pending', 'waiting_for_capture'}
if record.method == PaymentMethod.CRYPTOBOT:
return status_str in {"active"}
return status_str in {'active'}
if record.method == PaymentMethod.CLOUDPAYMENTS:
return status_str in {"pending", "authorized"}
return status_str in {'pending', 'authorized'}
if record.method == PaymentMethod.FREEKASSA:
return status_str in {"pending", "created", "processing"}
return status_str in {'pending', 'created', 'processing'}
return False
def _get_payment_url(record: PendingPayment) -> Optional[str]:
def _get_payment_url(record: PendingPayment) -> str | None:
"""Extract payment URL from record."""
payment = record.payment
payment_url = getattr(payment, "payment_url", None)
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
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
payment_url = getattr(payment, 'url', None) or payment_url
elif record.method == PaymentMethod.YOOKASSA:
payment_url = getattr(payment, "confirmation_url", None) or payment_url
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)
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:
payment_url = getattr(payment, "payment_url", None) or payment_url
elif record.method == PaymentMethod.FREEKASSA:
payment_url = getattr(payment, "payment_url", None) or payment_url
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
@@ -246,7 +250,7 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
identifier=record.identifier,
amount_kopeks=record.amount_kopeks,
amount_rubles=record.amount_kopeks / 100,
status=record.status or "",
status=record.status or '',
status_emoji=status_emoji,
status_text=status_text,
is_paid=record.is_paid,
@@ -262,11 +266,12 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
# ============ Routes ============
@router.get("", response_model=PendingPaymentListResponse)
@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: Optional[str] = Query(None, description="Filter by payment method"),
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),
):
@@ -286,7 +291,7 @@ async def get_all_pending_payments(
# Paginate
start_idx = (page - 1) * per_page
page_payments = all_pending[start_idx:start_idx + per_page]
page_payments = all_pending[start_idx : start_idx + per_page]
items = [_record_to_response(p) for p in page_payments]
@@ -299,7 +304,7 @@ async def get_all_pending_payments(
)
@router.get("/stats", response_model=PaymentsStatsResponse)
@router.get('/stats', response_model=PaymentsStatsResponse)
async def get_payments_stats(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -320,7 +325,7 @@ async def get_payments_stats(
)
@router.get("/{method}/{payment_id}", response_model=PendingPaymentResponse)
@router.get('/{method}/{payment_id}', response_model=PendingPaymentResponse)
async def get_pending_payment_details(
method: str,
payment_id: int,
@@ -333,7 +338,7 @@ async def get_pending_payment_details(
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid payment method: {method}",
detail=f'Invalid payment method: {method}',
)
record = await get_payment_record(db, payment_method, payment_id)
@@ -341,13 +346,13 @@ async def get_pending_payment_details(
if not record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found",
detail='Payment not found',
)
return _record_to_response(record)
@router.post("/{method}/{payment_id}/check", response_model=ManualCheckResponse)
@router.post('/{method}/{payment_id}/check', response_model=ManualCheckResponse)
async def check_payment_status(
method: str,
payment_id: int,
@@ -360,7 +365,7 @@ async def check_payment_status(
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid payment method: {method}",
detail=f'Invalid payment method: {method}',
)
# Get current record
@@ -369,14 +374,14 @@ async def check_payment_status(
if not record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found",
detail='Payment not found',
)
# Check if manual check is available
if not _is_checkable(record):
return ManualCheckResponse(
success=False,
message="Ручная проверка недоступна для этого платежа",
message='Ручная проверка недоступна для этого платежа',
payment=_record_to_response(record),
status_changed=False,
)
@@ -391,7 +396,7 @@ async def check_payment_status(
if not updated:
return ManualCheckResponse(
success=False,
message="Не удалось проверить статус платежа",
message='Не удалось проверить статус платежа',
payment=_record_to_response(record),
status_changed=False,
)
@@ -400,12 +405,17 @@ async def check_payment_status(
if status_changed:
_, new_status_text = _get_status_info(updated)
message = f"Статус обновлён: {new_status_text}"
message = f'Статус обновлён: {new_status_text}'
logger.info(
f"Admin {admin.id} checked payment {method}/{payment_id}: {old_status} -> {updated.status}"
'Admin checked payment /',
admin_id=admin.id,
method=method,
payment_id=payment_id,
old_status=old_status,
status=updated.status,
)
else:
message = "Статус не изменился"
message = 'Статус не изменился'
return ManualCheckResponse(
success=True,
+412
View File
@@ -0,0 +1,412 @@
"""Admin routes for pinned messages in cabinet."""
import time
from datetime import UTC, datetime
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.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 = structlog.get_logger(__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(
'Admin created pinned message # (broadcast=)', admin_id=admin.id, message_id=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.now(UTC)
await db.commit()
await db.refresh(msg)
logger.info('Admin updated pinned message #', admin_id=admin.id, message_id=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.now(UTC)
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('Admin deactivated pinned message #', admin_id=admin.id, message_id=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(
'Admin unpinned active message: unpinned=, failed',
admin_id=admin.id,
unpinned_count=unpinned_count,
failed_count=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.now(UTC))
)
msg.is_active = True
msg.updated_at = datetime.now(UTC)
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(
'Admin activated pinned message # (broadcast=)', admin_id=admin.id, message_id=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(
'Admin broadcast pinned message #: sent=, failed',
admin_id=admin.id,
message_id=message_id,
sent_count=sent_count,
failed_count=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('Admin deleted pinned message #', admin_id=admin.id, message_id=message_id)
+281 -90
View File
@@ -2,13 +2,21 @@
from __future__ import annotations
import asyncio
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.discount_offer import (
count_discount_offers,
list_discount_offers,
@@ -21,45 +29,51 @@ from app.database.crud.promo_offer_template import (
list_promo_offer_templates,
update_promo_offer_template,
)
from app.database.crud.user import get_user_by_telegram_id
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
router = APIRouter(prefix="/admin/promo-offers", tags=["Admin Promo Offers"])
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/promo-offers', tags=['Admin Promo Offers'])
# ============== Schemas ==============
class PromoOfferUserInfo(BaseModel):
id: int
telegram_id: int
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
full_name: Optional[str] = None
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: Optional[int] = None
notification_type: Optional[str] = None
discount_percent: Optional[int] = None
bonus_amount_kopeks: Optional[int] = None
expires_at: Optional[datetime] = None
claimed_at: Optional[datetime] = None
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: Optional[str] = None
extra_data: Dict[str, Any] = Field(default_factory=dict)
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
user: Optional[PromoOfferUserInfo] = None
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]
items: list[PromoOfferResponse]
total: int
limit: int
offset: int
@@ -74,30 +88,30 @@ class PromoOfferTemplateResponse(BaseModel):
valid_hours: int
discount_percent: int
bonus_amount_kopeks: int
active_discount_hours: Optional[int] = None
test_duration_hours: Optional[int] = None
test_squad_uuids: List[str] = Field(default_factory=list)
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: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_by: int | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class PromoOfferTemplateListResponse(BaseModel):
items: List[PromoOfferTemplateResponse]
items: list[PromoOfferTemplateResponse]
class PromoOfferTemplateUpdateRequest(BaseModel):
name: Optional[str] = None
message_text: Optional[str] = None
button_text: Optional[str] = None
valid_hours: Optional[int] = Field(None, ge=1)
discount_percent: Optional[int] = Field(None, ge=0)
bonus_amount_kopeks: Optional[int] = Field(None, ge=0)
active_discount_hours: Optional[int] = Field(None, ge=1)
test_duration_hours: Optional[int] = Field(None, ge=1)
test_squad_uuids: Optional[List[str]] = None
is_active: Optional[bool] = None
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):
@@ -105,46 +119,53 @@ class PromoOfferBroadcastRequest(BaseModel):
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: Optional[str] = None
user_id: Optional[int] = None
telegram_id: Optional[int] = None
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: Optional[str] = None
user_ids: list[int]
target: str | None = None
notifications_sent: int = 0
notifications_failed: int = 0
class PromoOfferLogOfferInfo(BaseModel):
id: int
notification_type: Optional[str] = None
discount_percent: Optional[int] = None
bonus_amount_kopeks: Optional[int] = None
effect_type: Optional[str] = None
expires_at: Optional[datetime] = None
claimed_at: Optional[datetime] = None
is_active: Optional[bool] = None
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: Optional[int] = None
offer_id: Optional[int] = None
user_id: int | None = None
offer_id: int | None = None
action: str
source: Optional[str] = None
percent: Optional[int] = None
effect_type: Optional[str] = None
details: Dict[str, Any] = Field(default_factory=dict)
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: Optional[PromoOfferUserInfo] = None
offer: Optional[PromoOfferLogOfferInfo] = None
user: PromoOfferUserInfo | None = None
offer: PromoOfferLogOfferInfo | None = None
class PromoOfferLogListResponse(BaseModel):
items: List[PromoOfferLogResponse]
items: list[PromoOfferLogResponse]
total: int
limit: int
offset: int
@@ -152,16 +173,18 @@ class PromoOfferLogListResponse(BaseModel):
# ============== Helpers ==============
def _serialize_user(user: Optional[User]) -> Optional[PromoOfferUserInfo]:
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),
full_name=getattr(user, 'full_name', None),
)
@@ -180,7 +203,7 @@ def _serialize_offer(offer: DiscountOffer) -> PromoOfferResponse:
extra_data=offer.extra_data or {},
created_at=offer.created_at,
updated_at=offer.updated_at,
user=_serialize_user(getattr(offer, "user", None)),
user=_serialize_user(getattr(offer, 'user', None)),
)
@@ -205,10 +228,10 @@ def _serialize_template(template: PromoOfferTemplate) -> PromoOfferTemplateRespo
def _serialize_log(entry: PromoOfferLog) -> PromoOfferLogResponse:
user_info = _serialize_user(getattr(entry, "user", None))
user_info = _serialize_user(getattr(entry, 'user', None))
offer = getattr(entry, "offer", None)
offer_info: Optional[PromoOfferLogOfferInfo] = None
offer = getattr(entry, 'offer', None)
offer_info: PromoOfferLogOfferInfo | None = None
if offer:
offer_info = PromoOfferLogOfferInfo(
id=offer.id,
@@ -238,15 +261,16 @@ def _serialize_log(entry: PromoOfferLog) -> PromoOfferLogResponse:
async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
normalized = target.strip().lower()
if normalized.startswith("custom_"):
criteria = normalized[len("custom_"):]
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)
@router.get('/templates', response_model=PromoOfferTemplateListResponse)
async def list_templates(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -258,12 +282,10 @@ async def list_templates(
if not templates:
templates = await ensure_default_templates(db, created_by=admin.id)
return PromoOfferTemplateListResponse(
items=[_serialize_template(template) for template in templates]
)
return PromoOfferTemplateListResponse(items=[_serialize_template(template) for template in templates])
@router.get("/templates/{template_id}", response_model=PromoOfferTemplateResponse)
@router.get('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
async def get_template(
template_id: int,
admin: User = Depends(get_current_admin_user),
@@ -272,11 +294,11 @@ async def get_template(
"""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")
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found')
return _serialize_template(template)
@router.patch("/templates/{template_id}", response_model=PromoOfferTemplateResponse)
@router.patch('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
async def update_template(
template_id: int,
payload: PromoOfferTemplateUpdateRequest,
@@ -286,7 +308,7 @@ async def update_template(
"""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")
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()]
@@ -313,14 +335,15 @@ async def update_template(
# ============== Offer Endpoints ==============
@router.get("", response_model=PromoOfferListResponse)
@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: Optional[int] = Query(None, ge=1),
is_active: Optional[bool] = Query(None),
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(
@@ -344,13 +367,134 @@ async def list_offers(
)
@router.post("/broadcast", response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED)
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('Skipping promo notification for email-only user', user_id=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', telegram_id=user.telegram_id, exc=exc)
return False
except Exception as exc:
logger.error('Error sending promo notification to user', telegram_id=user.telegram_id, exc=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."""
"""Broadcast promo offer to users with optional Telegram notification."""
recipients: dict[int, User] = {}
# Resolve target segment
@@ -360,16 +504,28 @@ async def broadcast_offer(
# Resolve specific user
target_user_id = payload.user_id
user: Optional[User] = None
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")
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",
'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
@@ -377,17 +533,19 @@ async def broadcast_offer(
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")
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",
'No recipients: specify target or user',
)
# Create offers for all recipients
# 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,
@@ -402,24 +560,57 @@ async def broadcast_offer(
)
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)
@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: Optional[int] = Query(None, ge=1),
action: Optional[str] = Query(None, min_length=1),
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(
+175 -165
View File
@@ -2,13 +2,22 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
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,
@@ -19,24 +28,17 @@ from app.database.crud.promocode import (
get_promocodes_list,
update_promocode,
)
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.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"])
router = APIRouter(prefix='/admin/promocodes', tags=['Admin Promocodes'])
# ============== Schemas ==============
class PromoCodeResponse(BaseModel):
id: int
code: str
@@ -51,9 +53,9 @@ class PromoCodeResponse(BaseModel):
is_valid: bool
first_purchase_only: bool
valid_from: datetime
valid_until: Optional[datetime] = None
promo_group_id: Optional[int] = None
created_by: Optional[int] = None
valid_until: datetime | None = None
promo_group_id: int | None = None
created_by: int | None = None
created_at: datetime
updated_at: datetime
@@ -68,9 +70,9 @@ class PromoCodeListResponse(BaseModel):
class PromoCodeRecentUse(BaseModel):
id: int
user_id: int
user_username: Optional[str] = None
user_full_name: Optional[str] = None
user_telegram_id: Optional[int] = None
user_username: str | None = None
user_full_name: str | None = None
user_telegram_id: int | None = None
used_at: datetime
@@ -86,28 +88,29 @@ class PromoCodeCreateRequest(BaseModel):
balance_bonus_kopeks: int = 0
subscription_days: int = 0
max_uses: int = Field(default=1, ge=0)
valid_from: Optional[datetime] = None
valid_until: Optional[datetime] = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool = True
first_purchase_only: bool = False
promo_group_id: Optional[int] = None
promo_group_id: int | None = None
class PromoCodeUpdateRequest(BaseModel):
code: Optional[str] = Field(default=None, min_length=1, max_length=50)
type: Optional[PromoCodeType] = None
balance_bonus_kopeks: Optional[int] = None
subscription_days: Optional[int] = None
max_uses: Optional[int] = Field(default=None, ge=0)
valid_from: Optional[datetime] = None
valid_until: Optional[datetime] = None
is_active: Optional[bool] = None
first_purchase_only: Optional[bool] = None
promo_group_id: Optional[int] = None
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
@@ -115,12 +118,12 @@ class PromoGroupResponse(BaseModel):
traffic_discount_percent: int
device_discount_percent: int
period_discounts: dict[int, int] = Field(default_factory=dict)
auto_assign_total_spent_kopeks: Optional[int] = None
auto_assign_total_spent_kopeks: int | None = None
apply_discounts_to_addons: bool
is_default: bool
members_count: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_at: datetime | None = None
updated_at: datetime | None = None
class PromoGroupListResponse(BaseModel):
@@ -135,32 +138,33 @@ class PromoGroupCreateRequest(BaseModel):
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: Optional[dict[int, int]] = None
auto_assign_total_spent_kopeks: Optional[int] = None
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: Optional[str] = None
server_discount_percent: Optional[int] = None
traffic_discount_percent: Optional[int] = None
device_discount_percent: Optional[int] = None
period_discounts: Optional[dict[int, int]] = None
auto_assign_total_spent_kopeks: Optional[int] = None
apply_discounts_to_addons: Optional[bool] = None
is_default: Optional[bool] = None
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: Optional[datetime]) -> Optional[datetime]:
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(timezone.utc).replace(tzinfo=None)
return value.astimezone(UTC)
if value.tzinfo is not None:
return value.replace(tzinfo=None)
return value
return value
@@ -192,9 +196,9 @@ 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),
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,
)
@@ -223,54 +227,41 @@ def _serialize_promo_group(group: PromoGroup, members_count: int = 0) -> PromoGr
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),
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")
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"
)
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"
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"
)
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"
)
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"
)
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")
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Code must not be empty')
if payload.type is not None:
new_type = payload.type
@@ -278,74 +269,47 @@ def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCo
new_type = PromoCodeType(promocode.type)
balance_bonus = (
payload.balance_bonus_kopeks
if payload.balance_bonus_kopeks is not None
else promocode.balance_bonus_kopeks
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
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"
)
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"
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"
)
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"
)
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
)
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"
)
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"
)
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'max_uses cannot be less than current uses')
# ============== Promocode Endpoints ==============
@router.get("", response_model=PromoCodeListResponse)
@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: Optional[bool] = Query(default=None),
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
@@ -359,7 +323,7 @@ async def list_promocodes(
)
@router.get("/{promocode_id}", response_model=PromoCodeDetailResponse)
@router.get('/{promocode_id}', response_model=PromoCodeDetailResponse)
async def get_promocode(
promocode_id: int,
admin: User = Depends(get_current_admin_user),
@@ -368,24 +332,21 @@ async def get_promocode(
"""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")
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", [])
]
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),
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)
@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),
@@ -400,10 +361,10 @@ async def create_promocode_endpoint(
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"
)
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,
@@ -411,22 +372,22 @@ async def create_promocode_endpoint(
type=payload.type,
balance_bonus_kopeks=payload.balance_bonus_kopeks,
subscription_days=payload.subscription_days,
max_uses=payload.max_uses,
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
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
update_fields['is_active'] = payload.is_active
if normalized_valid_until is not None:
update_fields["valid_until"] = normalized_valid_until
update_fields['valid_until'] = normalized_valid_until
if payload.first_purchase_only:
update_fields["first_purchase_only"] = 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
update_fields['promo_group_id'] = payload.promo_group_id
if update_fields:
promocode = await update_promocode(db, promocode, **update_fields)
@@ -434,7 +395,7 @@ async def create_promocode_endpoint(
return _serialize_promocode(promocode)
@router.patch("/{promocode_id}", response_model=PromoCodeResponse)
@router.patch('/{promocode_id}', response_model=PromoCodeResponse)
async def update_promocode_endpoint(
promocode_id: int,
payload: PromoCodeUpdateRequest,
@@ -444,7 +405,7 @@ async def update_promocode_endpoint(
"""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")
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
_validate_update_payload(payload, promocode)
@@ -455,38 +416,35 @@ async def update_promocode_endpoint(
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
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
updates['type'] = payload.type.value
if payload.balance_bonus_kopeks is not None:
updates["balance_bonus_kopeks"] = payload.balance_bonus_kopeks
updates['balance_bonus_kopeks'] = payload.balance_bonus_kopeks
if payload.subscription_days is not None:
updates["subscription_days"] = payload.subscription_days
updates['subscription_days'] = payload.subscription_days
if payload.max_uses is not None:
updates["max_uses"] = payload.max_uses
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)
updates['valid_from'] = _normalize_datetime(payload.valid_from)
if payload.valid_until is not None:
updates["valid_until"] = _normalize_datetime(payload.valid_until)
updates['valid_until'] = _normalize_datetime(payload.valid_until)
if payload.is_active is not None:
updates["is_active"] = payload.is_active
updates['is_active'] = payload.is_active
if payload.first_purchase_only is not None:
updates["first_purchase_only"] = payload.first_purchase_only
updates['first_purchase_only'] = payload.first_purchase_only
if payload.promo_group_id is not None:
updates["promo_group_id"] = payload.promo_group_id
updates['promo_group_id'] = payload.promo_group_id
if not updates:
return _serialize_promocode(promocode)
@@ -496,7 +454,7 @@ async def update_promocode_endpoint(
@router.delete(
"/{promocode_id}",
'/{promocode_id}',
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
)
@@ -508,21 +466,76 @@ async def delete_promocode_endpoint(
"""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")
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")
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 = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo Groups'])
@promo_groups_router.get("", response_model=PromoGroupListResponse)
@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),
@@ -545,7 +558,7 @@ async def list_promo_groups(
)
@promo_groups_router.get("/{group_id}", response_model=PromoGroupResponse)
@promo_groups_router.get('/{group_id}', response_model=PromoGroupResponse)
async def get_promo_group(
group_id: int,
admin: User = Depends(get_current_admin_user),
@@ -554,13 +567,13 @@ async def get_promo_group(
"""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")
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)
@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),
@@ -585,13 +598,13 @@ async def create_promo_group_endpoint(
await db.rollback()
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Promo group with this name already exists",
'Promo group with this name already exists',
)
return _serialize_promo_group(group, members_count=0)
@promo_groups_router.patch("/{group_id}", response_model=PromoGroupResponse)
@promo_groups_router.patch('/{group_id}', response_model=PromoGroupResponse)
async def update_promo_group_endpoint(
group_id: int,
payload: PromoGroupUpdateRequest,
@@ -603,7 +616,7 @@ async def update_promo_group_endpoint(
group = await get_promo_group_by_id(db, group_id)
if not group:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found")
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo group not found')
try:
group = await update_promo_group(
@@ -622,14 +635,14 @@ async def update_promo_group_endpoint(
await db.rollback()
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Promo group with this name already exists",
'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)
@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),
@@ -638,13 +651,10 @@ async def delete_promo_group_endpoint(
"""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")
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"
)
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Cannot delete default promo group')
return Response(status_code=status.HTTP_204_NO_CONTENT)
File diff suppressed because it is too large Load Diff
+70 -71
View File
@@ -1,43 +1,41 @@
"""Admin routes for managing servers in cabinet."""
import logging
from typing import List
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import String, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, String
from sqlalchemy.orm import selectinload
from app.database.models import User, ServerSquad, Subscription, Tariff, PromoGroup
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,
sync_with_remnawave,
count_active_users_for_squad,
)
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 (
ServerListResponse,
ServerListItem,
PromoGroupInfo,
ServerDetailResponse,
ServerUpdateRequest,
ServerToggleResponse,
ServerTrialToggleResponse,
ServerListItem,
ServerListResponse,
ServerStatsResponse,
ServerSyncResponse,
PromoGroupInfo,
ServerToggleResponse,
ServerTrialToggleResponse,
ServerUpdateRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/servers", tags=["Cabinet Admin Servers"])
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/servers', tags=['Cabinet Admin Servers'])
async def _get_server_promo_groups(db: AsyncSession, server: ServerSquad) -> List[PromoGroupInfo]:
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()
@@ -54,7 +52,7 @@ async def _get_server_promo_groups(db: AsyncSession, server: ServerSquad) -> Lis
]
async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> List[str]:
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))
@@ -65,7 +63,7 @@ async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> List[s
return tariff_names
@router.get("", response_model=ServerListResponse)
@router.get('', response_model=ServerListResponse)
async def list_servers(
include_unavailable: bool = True,
admin: User = Depends(get_current_admin_user),
@@ -79,28 +77,30 @@ async def list_servers(
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,
))
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)
@router.get('/{server_id}', response_model=ServerDetailResponse)
async def get_server(
server_id: int,
admin: User = Depends(get_current_admin_user),
@@ -111,7 +111,7 @@ async def get_server(
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Server not found",
detail='Server not found',
)
promo_groups = await _get_server_promo_groups(db, server)
@@ -142,7 +142,7 @@ async def get_server(
)
@router.put("/{server_id}", response_model=ServerDetailResponse)
@router.put('/{server_id}', response_model=ServerDetailResponse)
async def update_existing_server(
server_id: int,
request: ServerUpdateRequest,
@@ -154,27 +154,27 @@ async def update_existing_server(
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Server not found",
detail='Server not found',
)
# Build updates dict
updates = {}
if request.display_name is not None:
updates["display_name"] = request.display_name
updates['display_name'] = request.display_name
if request.description is not None:
updates["description"] = request.description
updates['description'] = request.description
if request.country_code is not None:
updates["country_code"] = request.country_code
updates['country_code'] = request.country_code
if request.is_available is not None:
updates["is_available"] = request.is_available
updates['is_available'] = request.is_available
if request.is_trial_eligible is not None:
updates["is_trial_eligible"] = request.is_trial_eligible
updates['is_trial_eligible'] = request.is_trial_eligible
if request.price_kopeks is not None:
updates["price_kopeks"] = request.price_kopeks
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
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
updates['sort_order'] = request.sort_order
if updates:
await update_server_squad(db, server_id, **updates)
@@ -183,12 +183,12 @@ async def update_existing_server(
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}")
logger.info('Admin updated server', admin_id=admin.id, server_id=server_id)
return await get_server(server_id, admin, db)
@router.post("/{server_id}/toggle", response_model=ServerToggleResponse)
@router.post('/{server_id}/toggle', response_model=ServerToggleResponse)
async def toggle_server(
server_id: int,
admin: User = Depends(get_current_admin_user),
@@ -199,23 +199,23 @@ async def toggle_server(
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Server 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}")
status_text = 'enabled' if new_status else 'disabled'
logger.info('Admin server', admin_id=admin.id, status_text=status_text, server_id=server_id)
return ServerToggleResponse(
id=server_id,
is_available=new_status,
message=f"Server {status_text}",
message=f'Server {status_text}',
)
@router.post("/{server_id}/trial", response_model=ServerTrialToggleResponse)
@router.post('/{server_id}/trial', response_model=ServerTrialToggleResponse)
async def toggle_server_trial(
server_id: int,
admin: User = Depends(get_current_admin_user),
@@ -226,23 +226,23 @@ async def toggle_server_trial(
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Server 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}")
status_text = 'enabled for trial' if new_status else 'disabled for trial'
logger.info('Admin server', admin_id=admin.id, status_text=status_text, server_id=server_id)
return ServerTrialToggleResponse(
id=server_id,
is_trial_eligible=new_status,
message=f"Server {status_text}",
message=f'Server {status_text}',
)
@router.get("/{server_id}/stats", response_model=ServerStatsResponse)
@router.get('/{server_id}/stats', response_model=ServerStatsResponse)
async def get_server_stats(
server_id: int,
admin: User = Depends(get_current_admin_user),
@@ -253,7 +253,7 @@ async def get_server_stats(
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Server not found",
detail='Server not found',
)
active_subs = await count_active_users_for_squad(db, server.squad_uuid)
@@ -261,10 +261,9 @@ async def get_server_stats(
# 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(
select(func.count(Subscription.id)).where(
Subscription.is_trial == True,
Subscription.status == "active",
Subscription.status == 'active',
func.cast(Subscription.connected_squads, String).like(f'%"{server.squad_uuid}"%'),
)
)
@@ -286,7 +285,7 @@ async def get_server_stats(
)
@router.post("/sync", response_model=ServerSyncResponse)
@router.post('/sync', response_model=ServerSyncResponse)
async def sync_servers(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -297,7 +296,7 @@ async def sync_servers(
if not subscription_service.is_configured:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="RemnaWave is not configured",
detail='RemnaWave is not configured',
)
# Get squads from RemnaWave
@@ -305,26 +304,26 @@ async def sync_servers(
if squads is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to fetch squads from RemnaWave",
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}")
logger.info('Admin synced servers: + ~', admin_id=admin.id, created=created, updated=updated, removed=removed)
return ServerSyncResponse(
created=created,
updated=updated,
removed=removed,
message=f"Synced: {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}")
logger.error('Failed to sync servers', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Sync failed: {str(e)}",
detail=f'Sync failed: {e!s}',
)
+48 -38
View File
@@ -1,9 +1,9 @@
"""Admin settings routes for cabinet - system configuration management."""
import logging
from typing import Any, Optional, List
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status, Query
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,44 +15,51 @@ from app.services.system_settings_service import (
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/settings", tags=["Admin Settings"])
logger = structlog.get_logger(__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 = ""
description: str = ''
items: int
class SettingChoice(BaseModel):
"""Choice option for setting."""
value: Any
label: str
description: Optional[str] = None
description: str | None = None
class SettingHint(BaseModel):
"""Setting hints and guidance."""
description: str = ""
format: str = ""
example: str = ""
warning: str = ""
description: str = ''
format: str = ''
example: str = ''
warning: str = ''
class SettingDefinition(BaseModel):
"""Full setting definition with current state."""
key: str
name: str
category: SettingCategoryRef
@@ -62,17 +69,19 @@ class SettingDefinition(BaseModel):
original: Any = Field(default=None)
has_override: bool
read_only: bool = Field(default=False)
choices: List[SettingChoice] = Field(default_factory=list)
hint: Optional[SettingHint] = None
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)
@@ -80,7 +89,7 @@ def _coerce_value(key: str, value: Any) -> Any:
if value is None:
if definition.is_optional:
return None
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Value is required")
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Value is required')
python_type = definition.python_type
@@ -90,14 +99,14 @@ def _coerce_value(key: str, value: Any) -> Any:
normalized = value
elif isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"true", "1", "yes", "on", "да"}:
if lowered in {'true', '1', 'yes', 'on', 'да'}:
normalized = True
elif lowered in {"false", "0", "no", "off", "нет"}:
elif lowered in {'false', '0', 'no', 'off', 'нет'}:
normalized = False
else:
raise ValueError("invalid bool")
raise ValueError('invalid bool')
else:
raise ValueError("invalid bool")
raise ValueError('invalid bool')
elif python_type is int:
normalized = int(value)
@@ -106,16 +115,16 @@ def _coerce_value(key: str, value: Any) -> Any:
else:
normalized = str(value)
except ValueError:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid value type") from None
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)
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}",
detail=f'Value must be one of: {readable}',
)
return normalized
@@ -127,7 +136,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
original = bot_configuration_service.get_original_value(definition.key)
has_override = bot_configuration_service.has_override(definition.key)
choices: List[SettingChoice] = []
choices: list[SettingChoice] = []
if include_choices:
choices = [
SettingChoice(
@@ -141,10 +150,10 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
# 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", ""),
description=guidance.get('description', ''),
format=guidance.get('format', ''),
example=guidance.get('example', ''),
warning=guidance.get('warning', ''),
)
return SettingDefinition(
@@ -167,7 +176,8 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
# ============ Routes ============
@router.get("/categories", response_model=List[SettingCategorySummary])
@router.get('/categories', response_model=list[SettingCategorySummary])
async def list_categories(
admin: User = Depends(get_current_admin_user),
):
@@ -184,13 +194,13 @@ async def list_categories(
]
@router.get("", response_model=List[SettingDefinition])
@router.get('', response_model=list[SettingDefinition])
async def list_settings(
admin: User = Depends(get_current_admin_user),
category: Optional[str] = Query(default=None, alias="category_key"),
category: str | None = Query(default=None, alias='category_key'),
):
"""Get list of all settings or settings for a specific category."""
items: List[SettingDefinition] = []
items: list[SettingDefinition] = []
if category:
definitions = bot_configuration_service.get_settings_for_category(category)
@@ -204,7 +214,7 @@ async def list_settings(
return items
@router.get("/{key}", response_model=SettingDefinition)
@router.get('/{key}', response_model=SettingDefinition)
async def get_setting(
key: str,
admin: User = Depends(get_current_admin_user),
@@ -213,12 +223,12 @@ async def get_setting(
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
return _serialize_definition(definition)
@router.put("/{key}", response_model=SettingDefinition)
@router.put('/{key}', response_model=SettingDefinition)
async def update_setting(
key: str,
payload: SettingUpdateRequest,
@@ -229,7 +239,7 @@ async def update_setting(
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
value = _coerce_value(key, payload.value)
try:
@@ -238,11 +248,11 @@ async def update_setting(
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}")
logger.info('Admin updated setting to', telegram_id=admin.telegram_id, key=key, value=value)
return _serialize_definition(definition)
@router.delete("/{key}", response_model=SettingDefinition)
@router.delete('/{key}', response_model=SettingDefinition)
async def reset_setting(
key: str,
admin: User = Depends(get_current_admin_user),
@@ -252,7 +262,7 @@ async def reset_setting(
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
try:
await bot_configuration_service.reset_value(db, key)
@@ -260,5 +270,5 @@ async def reset_setting(
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
await db.commit()
logger.info(f"Admin {admin.telegram_id} reset setting {key}")
logger.info('Admin reset setting', telegram_id=admin.telegram_id, key=key)
return _serialize_definition(definition)
File diff suppressed because it is too large Load Diff
+141 -129
View File
@@ -1,51 +1,50 @@
"""Admin routes for managing tariffs in cabinet."""
import logging
from typing import List, Optional
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database.models import User, Tariff, Subscription, ServerSquad, PromoGroup, Transaction, TransactionType
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,
create_tariff,
update_tariff,
delete_tariff,
get_tariff_subscriptions_count,
set_tariff_promo_groups,
load_period_prices_from_db,
reorder_tariffs,
set_tariff_promo_groups,
update_tariff,
)
from app.database.crud.server_squad import get_all_server_squads
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 (
TariffListResponse,
TariffListItem,
TariffDetailResponse,
PeriodPrice,
PromoGroupInfo,
ServerInfo,
ServerTrafficLimit,
TariffCreateRequest,
TariffUpdateRequest,
TariffDetailResponse,
TariffListItem,
TariffListResponse,
TariffSortOrderRequest,
TariffStatsResponse,
TariffToggleResponse,
TariffTrialResponse,
TariffStatsResponse,
PeriodPrice,
ServerInfo,
PromoGroupInfo,
ServerTrafficLimit,
TariffUpdateRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/tariffs", tags=["Cabinet Admin Tariffs"])
logger = structlog.get_logger(__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]:
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 {}
@@ -60,18 +59,20 @@ async def _get_tariff_servers(
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,
))
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]:
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()
@@ -88,7 +89,7 @@ async def _get_tariff_promo_groups(db: AsyncSession, tariff: Tariff) -> List[Pro
]
def _period_prices_to_list(period_prices: dict) -> List[PeriodPrice]:
def _period_prices_to_list(period_prices: dict) -> list[PeriodPrice]:
"""Convert period_prices dict to list."""
if not period_prices:
return []
@@ -98,12 +99,12 @@ def _period_prices_to_list(period_prices: dict) -> List[PeriodPrice]:
]
def _period_prices_to_dict(period_prices: List[PeriodPrice]) -> dict:
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)
@router.get('', response_model=TariffListResponse)
async def list_tariffs(
include_inactive: bool = True,
admin: User = Depends(get_current_admin_user),
@@ -115,28 +116,30 @@ async def list_tariffs(
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,
))
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])
@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),
@@ -155,7 +158,22 @@ async def get_available_servers(
]
@router.get("/{tariff_id}", response_model=TariffDetailResponse)
@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('Admin updated tariff order', admin_id=admin.id, tariff_ids=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),
@@ -166,7 +184,7 @@ async def get_tariff(
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tariff not found",
detail='Tariff not found',
)
allowed_squads = tariff.allowed_squads or []
@@ -225,7 +243,7 @@ async def get_tariff(
)
@router.post("", response_model=TariffDetailResponse)
@router.post('', response_model=TariffDetailResponse)
async def create_new_tariff(
request: TariffCreateRequest,
admin: User = Depends(get_current_admin_user),
@@ -235,9 +253,11 @@ async def create_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 {}
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,
@@ -274,7 +294,7 @@ async def create_new_tariff(
traffic_reset_mode=request.traffic_reset_mode,
)
logger.info(f"Admin {admin.id} created tariff {tariff.id}: {tariff.name}")
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
@@ -283,7 +303,7 @@ async def create_new_tariff(
return await get_tariff(tariff.id, admin, db)
@router.put("/{tariff_id}", response_model=TariffDetailResponse)
@router.put('/{tariff_id}', response_model=TariffDetailResponse)
async def update_existing_tariff(
tariff_id: int,
request: TariffUpdateRequest,
@@ -295,81 +315,81 @@ async def update_existing_tariff(
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tariff not found",
detail='Tariff not found',
)
# Build updates dict
updates = {}
if request.name is not None:
updates["name"] = request.name
updates['name'] = request.name
if request.description is not None:
updates["description"] = request.description
updates['description'] = request.description
if request.is_active is not None:
updates["is_active"] = request.is_active
updates['is_active'] = request.is_active
if request.allow_traffic_topup is not None:
updates["allow_traffic_topup"] = request.allow_traffic_topup
updates['allow_traffic_topup'] = request.allow_traffic_topup
if request.traffic_topup_enabled is not None:
updates["traffic_topup_enabled"] = request.traffic_topup_enabled
updates['traffic_topup_enabled'] = request.traffic_topup_enabled
if request.traffic_topup_packages is not None:
updates["traffic_topup_packages"] = request.traffic_topup_packages
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
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
updates['traffic_limit_gb'] = request.traffic_limit_gb
if request.device_limit is not None:
updates["device_limit"] = request.device_limit
updates['device_limit'] = request.device_limit
if request.device_price_kopeks is not None:
updates["device_price_kopeks"] = request.device_price_kopeks
updates['device_price_kopeks'] = request.device_price_kopeks
if request.max_device_limit is not None:
updates["max_device_limit"] = request.max_device_limit
updates['max_device_limit'] = request.max_device_limit
if request.tier_level is not None:
updates["tier_level"] = request.tier_level
updates['tier_level'] = request.tier_level
if request.display_order is not None:
updates["display_order"] = request.display_order
updates['display_order'] = request.display_order
if request.period_prices is not None:
updates["period_prices"] = _period_prices_to_dict(request.period_prices)
updates['period_prices'] = _period_prices_to_dict(request.period_prices)
if request.allowed_squads is not None:
updates["allowed_squads"] = request.allowed_squads
updates['allowed_squads'] = request.allowed_squads
if request.server_traffic_limits is not None:
# Преобразуем ServerTrafficLimit в dict для хранения
updates["server_traffic_limits"] = {
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
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
updates['price_per_day_kopeks'] = request.price_per_day_kopeks
if request.min_days is not None:
updates["min_days"] = request.min_days
updates['min_days'] = request.min_days
if request.max_days is not None:
updates["max_days"] = request.max_days
updates['max_days'] = request.max_days
# Произвольный трафик при покупке
if request.custom_traffic_enabled is not None:
updates["custom_traffic_enabled"] = request.custom_traffic_enabled
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
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
updates['min_traffic_gb'] = request.min_traffic_gb
if request.max_traffic_gb is not None:
updates["max_traffic_gb"] = request.max_traffic_gb
updates['max_traffic_gb'] = request.max_traffic_gb
# Дневной тариф
if request.is_daily is not None:
updates["is_daily"] = request.is_daily
updates['is_daily'] = request.is_daily
if request.daily_price_kopeks is not None:
updates["daily_price_kopeks"] = request.daily_price_kopeks
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
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_id, request.promo_group_ids)
await set_tariff_promo_groups(db, tariff, request.promo_group_ids)
logger.info(f"Admin {admin.id} updated tariff {tariff_id}")
logger.info('Admin updated tariff', admin_id=admin.id, tariff_id=tariff_id)
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
@@ -377,7 +397,7 @@ async def update_existing_tariff(
return await get_tariff(tariff_id, admin, db)
@router.delete("/{tariff_id}")
@router.delete('/{tariff_id}')
async def delete_existing_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
@@ -388,27 +408,26 @@ async def delete_existing_tariff(
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tariff not found",
detail='Tariff not found',
)
# Check if tariff has subscriptions
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
if subs_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot delete tariff with {subs_count} active subscriptions",
)
await delete_tariff(db, tariff)
logger.info(f"Admin {admin.id} deleted tariff {tariff_id}: {tariff.name}")
logger.info(
'Admin deleted tariff (affected subscriptions: )',
admin_id=admin.id,
tariff_id=tariff_id,
tariff_name=tariff.name,
subs_count=subs_count,
)
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
return {"message": "Tariff deleted successfully"}
return {'message': 'Tariff deleted successfully', 'affected_subscriptions': subs_count}
@router.post("/{tariff_id}/toggle", response_model=TariffToggleResponse)
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
async def toggle_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
@@ -419,14 +438,14 @@ async def toggle_tariff(
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tariff 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}")
status_text = 'activated' if new_status else 'deactivated'
logger.info('Admin tariff', admin_id=admin.id, status_text=status_text, tariff_id=tariff_id)
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
@@ -434,11 +453,11 @@ async def toggle_tariff(
return TariffToggleResponse(
id=tariff_id,
is_active=new_status,
message=f"Tariff {status_text}",
message=f'Tariff {status_text}',
)
@router.post("/{tariff_id}/trial", response_model=TariffTrialResponse)
@router.post('/{tariff_id}/trial', response_model=TariffTrialResponse)
async def toggle_trial_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
@@ -453,7 +472,7 @@ async def toggle_trial_tariff(
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tariff not found",
detail='Tariff not found',
)
new_status = not tariff.is_trial_available
@@ -461,26 +480,24 @@ async def toggle_trial_tariff(
if new_status:
# При включении триала - снимаем флаг со ВСЕХ тарифов, затем ставим на текущий
# Это гарантирует, что триальным будет только один тариф
await db.execute(
Tariff.__table__.update().values(is_trial_available=False)
)
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}")
status_text = 'set as trial' if new_status else 'removed from trial'
logger.info('Admin tariff', admin_id=admin.id, status_text=status_text, tariff_id=tariff_id)
return TariffTrialResponse(
id=tariff_id,
is_trial_available=new_status,
message=f"Tariff {status_text}",
message=f'Tariff {status_text}',
)
@router.get("/{tariff_id}/stats", response_model=TariffStatsResponse)
@router.get('/{tariff_id}/stats', response_model=TariffStatsResponse)
async def get_tariff_stats(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
@@ -491,30 +508,25 @@ async def get_tariff_stats(
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tariff 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_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(
select(func.count(Subscription.id)).where(
Subscription.tariff_id == tariff_id,
Subscription.status == "active",
Subscription.status == 'active',
)
)
active_count = active_result.scalar() or 0
# Count trial subscriptions
trial_result = await db.execute(
select(func.count(Subscription.id))
.where(
select(func.count(Subscription.id)).where(
Subscription.tariff_id == tariff_id,
Subscription.is_trial == True,
)
@@ -523,7 +535,7 @@ async def get_tariff_stats(
# Calculate revenue from subscription payments for users on this tariff
revenue_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
.join(Subscription, Transaction.user_id == Subscription.user_id)
.where(
Subscription.tariff_id == tariff_id,
+215 -206
View File
@@ -1,38 +1,40 @@
"""Admin tickets routes for cabinet."""
import logging
import math
from datetime import datetime
from typing import Optional, List
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from sqlalchemy.orm import selectinload
import structlog
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.database.models import User, Ticket, TicketMessage
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.config import settings
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"])
logger = structlog.get_logger(__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
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
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
@@ -40,16 +42,17 @@ class AdminTicketUserInfo(BaseModel):
class AdminTicketResponse(BaseModel):
"""Ticket data for admin."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: Optional[datetime] = None
closed_at: datetime | None = None
messages_count: int = 0
user: Optional[AdminTicketUserInfo] = None
last_message: Optional[TicketMessageResponse] = None
user: AdminTicketUserInfo | None = None
last_message: TicketMessageResponse | None = None
class Config:
from_attributes = True
@@ -57,16 +60,17 @@ class AdminTicketResponse(BaseModel):
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: Optional[datetime] = None
closed_at: datetime | None = None
is_reply_blocked: bool = False
user: Optional[AdminTicketUserInfo] = None
messages: List[TicketMessageResponse] = []
user: AdminTicketUserInfo | None = None
messages: list[TicketMessageResponse] = []
class Config:
from_attributes = True
@@ -74,7 +78,8 @@ class AdminTicketDetailResponse(BaseModel):
class AdminTicketListResponse(BaseModel):
"""Paginated ticket list for admin."""
items: List[AdminTicketResponse]
items: list[AdminTicketResponse]
total: int
page: int
per_page: int
@@ -83,21 +88,25 @@ class AdminTicketListResponse(BaseModel):
class AdminReplyRequest(BaseModel):
"""Admin reply to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description="Reply message")
message: str = Field(..., 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")
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")
priority: str = Field(..., description='New priority: low, normal, high, urgent')
class AdminStatsResponse(BaseModel):
"""Ticket statistics for admin."""
total: int
open: int
pending: int
@@ -107,6 +116,7 @@ class AdminStatsResponse(BaseModel):
class TicketSettingsResponse(BaseModel):
"""Ticket system settings."""
sla_enabled: bool
sla_minutes: int
sla_check_interval_seconds: int
@@ -119,21 +129,24 @@ class TicketSettingsResponse(BaseModel):
class TicketSettingsUpdateRequest(BaseModel):
"""Update ticket settings."""
sla_enabled: Optional[bool] = None
sla_minutes: Optional[int] = Field(None, ge=1, le=1440, description="SLA time in minutes (1-1440)")
sla_check_interval_seconds: Optional[int] = Field(None, ge=30, le=600, description="Check interval (30-600 seconds)")
sla_reminder_cooldown_minutes: Optional[int] = Field(None, ge=1, le=120, description="Reminder cooldown (1-120 minutes)")
support_system_mode: Optional[str] = Field(None, description="Support mode: tickets, contact, both")
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: Optional[bool] = Field(None, description="Enable user notifications in cabinet")
cabinet_admin_notifications_enabled: Optional[bool] = Field(None, description="Enable admin notifications in cabinet")
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 "",
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,
@@ -148,6 +161,7 @@ def _user_to_info(user: User) -> AdminTicketUserInfo:
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,
@@ -169,9 +183,9 @@ def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) ->
return AdminTicketResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or "normal",
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
@@ -181,7 +195,7 @@ def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) ->
)
@router.get("/stats", response_model=AdminStatsResponse)
@router.get('/stats', response_model=AdminStatsResponse)
async def get_ticket_stats(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -193,36 +207,142 @@ async def get_ticket_stats(
# 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)
)
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),
open=statuses.get('open', 0),
pending=statuses.get('pending', 0),
answered=statuses.get('answered', 0),
closed=statuses.get('closed', 0),
)
@router.get("", response_model=AdminTicketListResponse)
@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:
SupportSettingsService.set_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('Failed to update .env file', error=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: Optional[str] = Query(None, alias="status", description="Filter by status"),
priority_filter: Optional[str] = Query(None, alias="priority", description="Filter by priority"),
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))
)
query = select(Ticket).options(selectinload(Ticket.messages), selectinload(Ticket.user))
# Build count query
count_query = select(func.count()).select_from(Ticket)
@@ -236,6 +356,10 @@ async def get_all_tickets(
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
@@ -259,7 +383,7 @@ async def get_all_tickets(
)
@router.get("/{ticket_id}", response_model=AdminTicketDetailResponse)
@router.get('/{ticket_id}', response_model=AdminTicketDetailResponse)
async def get_ticket_detail(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
@@ -267,9 +391,7 @@ async def get_ticket_detail(
):
"""Get ticket with all messages for admin."""
query = (
select(Ticket)
.where(Ticket.id == ticket_id)
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
@@ -278,7 +400,7 @@ async def get_ticket_detail(
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
detail='Ticket not found',
)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
@@ -290,19 +412,19 @@ async def get_ticket_detail(
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or "normal",
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,
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)
@router.post('/{ticket_id}/reply', response_model=TicketMessageResponse)
async def reply_to_ticket(
ticket_id: int,
request: AdminReplyRequest,
@@ -316,7 +438,7 @@ async def reply_to_ticket(
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
detail='Ticket not found',
)
# Create admin message
@@ -325,13 +447,13 @@ async def reply_to_ticket(
user_id=ticket.user_id,
message_text=request.message,
is_from_admin=True,
created_at=datetime.utcnow(),
created_at=datetime.now(UTC),
)
db.add(message)
# Update ticket status to answered
ticket.status = "answered"
ticket.updated_at = datetime.utcnow()
ticket.status = 'answered'
ticket.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(message)
@@ -348,13 +470,14 @@ async def reply_to_ticket(
)
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}")
logger.warning('Failed to notify user about ticket reply', error=e)
finally:
await bot.session.close()
except Exception as e:
logger.warning(f"Failed to send Telegram notification: {e}")
logger.warning('Failed to send Telegram notification', error=e)
# Уведомить пользователя в кабинете
try:
@@ -363,14 +486,14 @@ async def reply_to_ticket(
)
if notification:
# Отправить WebSocket уведомление
await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or "")[:100])
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}")
logger.warning('Failed to create cabinet notification for admin reply', error=e)
return _message_to_response(message)
@router.post("/{ticket_id}/status", response_model=AdminTicketDetailResponse)
@router.post('/{ticket_id}/status', response_model=AdminTicketDetailResponse)
async def update_ticket_status(
ticket_id: int,
request: AdminStatusUpdateRequest,
@@ -378,17 +501,15 @@ async def update_ticket_status(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket status."""
allowed_statuses = {"open", "pending", "answered", "closed"}
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)}",
detail=f'Invalid status. Allowed: {", ".join(allowed_statuses)}',
)
query = (
select(Ticket)
.where(Ticket.id == ticket_id)
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
@@ -397,13 +518,13 @@ async def update_ticket_status(
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
detail='Ticket not found',
)
ticket.status = request.status
ticket.updated_at = datetime.utcnow()
if request.status == "closed":
ticket.closed_at = datetime.utcnow()
ticket.updated_at = datetime.now(UTC)
if request.status == 'closed':
ticket.closed_at = datetime.now(UTC)
else:
ticket.closed_at = None
@@ -419,19 +540,19 @@ async def update_ticket_status(
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or "normal",
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,
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)
@router.post('/{ticket_id}/priority', response_model=AdminTicketDetailResponse)
async def update_ticket_priority(
ticket_id: int,
request: AdminPriorityUpdateRequest,
@@ -439,17 +560,15 @@ async def update_ticket_priority(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket priority."""
allowed_priorities = {"low", "normal", "high", "urgent"}
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)}",
detail=f'Invalid priority. Allowed: {", ".join(allowed_priorities)}',
)
query = (
select(Ticket)
.where(Ticket.id == ticket_id)
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
@@ -458,11 +577,11 @@ async def update_ticket_priority(
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
detail='Ticket not found',
)
ticket.priority = request.priority
ticket.updated_at = datetime.utcnow()
ticket.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(ticket)
@@ -476,123 +595,13 @@ async def update_ticket_priority(
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or "normal",
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,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
user=user_info,
messages=messages_response,
)
@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."""
import os
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(f"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(),
)
+694
View File
@@ -0,0 +1,694 @@
"""Admin routes for traffic usage statistics."""
import asyncio
import csv
import io
import time
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.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 = structlog.get_logger(__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', node_name=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(func.abs(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', telegram_id=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."""
from datetime import UTC, datetime, timedelta
import aiohttp
import structlog
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 = structlog.get_logger(__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(UTC) - _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(UTC)
logger.info('Fetched cabinet releases from GitHub', releases_count=len(releases))
return releases
logger.warning('GitHub API returned status for cabinet releases', response_status=response.status)
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', e=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
+63 -63
View File
@@ -1,46 +1,46 @@
"""
API роуты колеса удачи для администраторов.
"""
import logging
import math
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.database.crud.wheel import (
get_or_create_wheel_config,
update_wheel_config,
get_wheel_prizes,
get_wheel_prize_by_id,
create_wheel_prize,
update_wheel_prize,
delete_wheel_prize,
reorder_wheel_prizes,
get_all_spins,
get_wheel_statistics,
)
from app.services.wheel_service import wheel_service
from app.cabinet.dependencies import get_cabinet_db, get_current_admin_user
from app.cabinet.schemas.wheel import (
AdminWheelConfigResponse,
WheelPrizeAdminResponse,
UpdateWheelConfigRequest,
CreatePrizeRequest,
UpdatePrizeRequest,
ReorderPrizesRequest,
AdminSpinsResponse,
AdminSpinItem,
AdminSpinsResponse,
AdminWheelConfigResponse,
CreatePrizeRequest,
ReorderPrizesRequest,
UpdatePrizeRequest,
UpdateWheelConfigRequest,
WheelPrizeAdminResponse,
WheelStatisticsResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/wheel", tags=["Admin Fortune Wheel"])
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
@router.get("/config", response_model=AdminWheelConfigResponse)
logger = structlog.get_logger(__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),
@@ -90,7 +90,7 @@ async def get_admin_wheel_config(
)
@router.put("/config", response_model=AdminWheelConfigResponse)
@router.put('/config', response_model=AdminWheelConfigResponse)
async def update_admin_wheel_config(
request: UpdateWheelConfigRequest,
admin: User = Depends(get_current_admin_user),
@@ -102,12 +102,12 @@ async def update_admin_wheel_config(
if not update_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No fields to update",
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}")
logger.info('🎡 Admin updated wheel config', telegram_id=admin.telegram_id, update_data=update_data)
# Возвращаем полную конфигурацию
prizes = await get_wheel_prizes(db, config.id, active_only=False)
@@ -153,7 +153,7 @@ async def update_admin_wheel_config(
)
@router.get("/prizes", response_model=List[WheelPrizeAdminResponse])
@router.get('/prizes', response_model=list[WheelPrizeAdminResponse])
async def get_prizes(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -185,7 +185,7 @@ async def get_prizes(
]
@router.post("/prizes", response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED)
@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),
@@ -211,7 +211,7 @@ async def create_prize(
promo_traffic_gb=request.promo_traffic_gb,
)
logger.info(f"🎁 Admin {admin.telegram_id} created prize: {prize.display_name}")
logger.info('🎁 Admin created prize', telegram_id=admin.telegram_id, display_name=prize.display_name)
return WheelPrizeAdminResponse(
id=prize.id,
@@ -233,7 +233,7 @@ async def create_prize(
)
@router.put("/prizes/{prize_id}", response_model=WheelPrizeAdminResponse)
@router.put('/prizes/{prize_id}', response_model=WheelPrizeAdminResponse)
async def update_prize(
prize_id: int,
request: UpdatePrizeRequest,
@@ -244,13 +244,13 @@ async def update_prize(
update_data = request.model_dump(exclude_unset=True)
# Конвертируем enum в строку если есть
if 'prize_type' in update_data and update_data['prize_type']:
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",
detail='No fields to update',
)
prize = await update_wheel_prize(db, prize_id, **update_data)
@@ -258,10 +258,10 @@ async def update_prize(
if not prize:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Prize not found",
detail='Prize not found',
)
logger.info(f"🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}")
logger.info('🎁 Admin updated prize', telegram_id=admin.telegram_id, prize_id=prize_id, update_data=update_data)
return WheelPrizeAdminResponse(
id=prize.id,
@@ -283,7 +283,7 @@ async def update_prize(
)
@router.delete("/prizes/{prize_id}", status_code=status.HTTP_204_NO_CONTENT)
@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),
@@ -295,13 +295,13 @@ async def delete_prize_endpoint(
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Prize not found",
detail='Prize not found',
)
logger.info(f"🗑️ Admin {admin.telegram_id} deleted prize {prize_id}")
logger.info('🗑️ Admin deleted prize', telegram_id=admin.telegram_id, prize_id=prize_id)
@router.post("/prizes/reorder", status_code=status.HTTP_200_OK)
@router.post('/prizes/reorder', status_code=status.HTTP_200_OK)
async def reorder_prizes(
request: ReorderPrizesRequest,
admin: User = Depends(get_current_admin_user),
@@ -309,14 +309,14 @@ async def reorder_prizes(
):
"""Переупорядочить призы."""
await reorder_wheel_prizes(db, request.prize_ids)
logger.info(f"🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}")
return {"success": True}
logger.info('🔄 Admin reordered prizes', telegram_id=admin.telegram_id, prize_ids=request.prize_ids)
return {'success': True}
@router.get("/statistics", response_model=WheelStatisticsResponse)
@router.get('/statistics', response_model=WheelStatisticsResponse)
async def get_statistics(
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
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),
):
@@ -324,24 +324,24 @@ async def get_statistics(
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"],
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)
@router.get('/spins', response_model=AdminSpinsResponse)
async def get_all_spins_endpoint(
user_id: Optional[int] = Query(None),
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
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),
+302
View File
@@ -0,0 +1,302 @@
"""Admin routes for managing withdrawal requests in cabinet."""
import json
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
ReferralEarning,
User,
WithdrawalRequest,
WithdrawalRequestStatus,
)
from app.services.referral_withdrawal_service import referral_withdrawal_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.withdrawals import (
AdminApproveWithdrawalRequest,
AdminRejectWithdrawalRequest,
AdminWithdrawalDetailResponse,
AdminWithdrawalItem,
AdminWithdrawalListResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/withdrawals', tags=['Cabinet Admin Withdrawals'])
def _get_risk_level(risk_score: int) -> str:
"""Get risk level from score."""
if risk_score >= 70:
return 'critical'
if risk_score >= 50:
return 'high'
if risk_score >= 30:
return 'medium'
return 'low'
@router.get('', response_model=AdminWithdrawalListResponse)
async def list_withdrawals(
withdrawal_status: Literal['pending', 'approved', 'rejected', 'completed', 'cancelled'] | None = Query(
None, alias='status'
),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all withdrawal requests."""
query = select(WithdrawalRequest)
count_query = select(func.count()).select_from(WithdrawalRequest)
if withdrawal_status:
query = query.where(WithdrawalRequest.status == withdrawal_status)
count_query = count_query.where(WithdrawalRequest.status == withdrawal_status)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Pending stats
pending_count_result = await db.execute(
select(func.count())
.select_from(WithdrawalRequest)
.where(WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value)
)
pending_count = pending_count_result.scalar() or 0
pending_total_result = await db.execute(
select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value
)
)
pending_total = pending_total_result.scalar() or 0
query = query.order_by(desc(WithdrawalRequest.created_at)).offset(offset).limit(limit)
result = await db.execute(query)
withdrawals = result.scalars().all()
# Batch-fetch users to avoid N+1
user_ids = list({w.user_id for w in withdrawals})
if user_ids:
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
else:
users_map = {}
items = []
for w in withdrawals:
user = users_map.get(w.user_id)
items.append(
AdminWithdrawalItem(
id=w.id,
user_id=w.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
amount_kopeks=w.amount_kopeks,
amount_rubles=w.amount_kopeks / 100,
status=w.status,
risk_score=w.risk_score or 0,
risk_level=_get_risk_level(w.risk_score or 0),
payment_details=w.payment_details,
admin_comment=w.admin_comment,
created_at=w.created_at,
processed_at=w.processed_at,
)
)
return AdminWithdrawalListResponse(
items=items,
total=total,
pending_count=pending_count,
pending_total_kopeks=pending_total,
)
@router.get('/{withdrawal_id}', response_model=AdminWithdrawalDetailResponse)
async def get_withdrawal_detail(
withdrawal_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed withdrawal request with risk analysis."""
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Заявка не найдена',
)
user = await db.get(User, withdrawal.user_id)
# Parse risk analysis
risk_analysis = None
if withdrawal.risk_analysis:
try:
risk_analysis = json.loads(withdrawal.risk_analysis)
except (json.JSONDecodeError, TypeError):
pass
# Get referral stats
referral_count = await db.execute(
select(func.count()).select_from(User).where(User.referred_by_id == withdrawal.user_id)
)
total_earnings = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == withdrawal.user_id
)
)
return AdminWithdrawalDetailResponse(
id=withdrawal.id,
user_id=withdrawal.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
amount_kopeks=withdrawal.amount_kopeks,
amount_rubles=withdrawal.amount_kopeks / 100,
status=withdrawal.status,
risk_score=withdrawal.risk_score or 0,
risk_level=_get_risk_level(withdrawal.risk_score or 0),
risk_analysis=risk_analysis,
payment_details=withdrawal.payment_details,
admin_comment=withdrawal.admin_comment,
balance_kopeks=user.balance_kopeks if user else 0,
total_referrals=referral_count.scalar() or 0,
total_earnings_kopeks=total_earnings.scalar() or 0,
created_at=withdrawal.created_at,
processed_at=withdrawal.processed_at,
)
@router.post('/{withdrawal_id}/approve')
async def approve_withdrawal(
withdrawal_id: int,
request: AdminApproveWithdrawalRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a withdrawal request."""
success, error = await referral_withdrawal_service.approve_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about approval
try:
from aiogram import Bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
user = await db.get(User, withdrawal.user_id) if withdrawal else None
if user and withdrawal:
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
comment_text = f'\n{request.comment}' if request.comment else ''
tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
try:
await notification_delivery_service.notify_withdrawal_approved(
user=user,
amount_kopeks=withdrawal.amount_kopeks,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send withdrawal approval notification', error=e)
return {'success': True}
@router.post('/{withdrawal_id}/reject')
async def reject_withdrawal(
withdrawal_id: int,
request: AdminRejectWithdrawalRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a withdrawal request."""
success, error = await referral_withdrawal_service.reject_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error or 'Не удалось отклонить заявку',
)
# Notify user about rejection
try:
from aiogram import Bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
user = await db.get(User, withdrawal.user_id) if withdrawal else None
if user and withdrawal:
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
try:
await notification_delivery_service.notify_withdrawal_rejected(
user=user,
amount_kopeks=withdrawal.amount_kopeks,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send withdrawal rejection notification', error=e)
return {'success': True}
@router.post('/{withdrawal_id}/complete')
async def complete_withdrawal(
withdrawal_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark a withdrawal as completed (money transferred)."""
success, error = await referral_withdrawal_service.complete_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error or 'Не удалось завершить заявку',
)
return {'success': True}
+816 -144
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+320 -147
View File
@@ -1,160 +1,213 @@
"""Branding routes for cabinet - logo, project name, and theme colors management."""
import logging
import os
import json
import os
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
import structlog
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User, SystemSetting
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"])
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/branding', tags=['Branding'])
# Directory for storing branding assets
BRANDING_DIR = Path("data/branding")
LOGO_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".svg"]
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"
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"}
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: Optional[str] = None
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"
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: Optional[str] = None
darkBackground: Optional[str] = None
darkSurface: Optional[str] = None
darkText: Optional[str] = None
darkTextSecondary: Optional[str] = None
lightBackground: Optional[str] = None
lightSurface: Optional[str] = None
lightText: Optional[str] = None
lightTextSecondary: Optional[str] = None
success: Optional[str] = None
warning: Optional[str] = None
error: Optional[str] = None
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: Optional[bool] = None
light: Optional[bool] = None
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",
'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) -> Optional[str]:
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)
)
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)
)
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
@@ -166,14 +219,14 @@ async def set_setting_value(db: AsyncSession, key: str, value: str):
await db.commit()
def get_logo_path() -> Optional[Path]:
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}"
logo_path = BRANDING_DIR / f'logo{ext}'
if logo_path.exists():
return logo_path
@@ -187,7 +240,8 @@ def has_custom_logo() -> bool:
# ============ Routes ============
@router.get("", response_model=BrandingResponse)
@router.get('', response_model=BrandingResponse)
async def get_branding(
db: AsyncSession = Depends(get_cabinet_db),
):
@@ -198,24 +252,23 @@ async def get_branding(
# 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')
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"
logo_letter = name[0].upper() if name else 'V'
return BrandingResponse(
name=name,
logo_url="/cabinet/branding/logo" if custom_logo else None,
logo_url='/cabinet/branding/logo' if custom_logo else None,
logo_letter=logo_letter,
has_custom_logo=custom_logo,
)
@router.get("/logo")
@router.get('/logo')
async def get_logo():
"""
Get the custom logo image.
@@ -224,61 +277,51 @@ async def get_logo():
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"
)
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",
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
}
media_type = media_types.get(suffix, "image/png")
media_type = media_types.get(suffix, 'image/png')
return FileResponse(
logo_path,
media_type=media_type,
headers={"Cache-Control": "public, max-age=3600"}
)
return FileResponse(logo_path, media_type=media_type, headers={'Cache-Control': 'public, max-age=3600'})
@router.put("/name", response_model=BrandingResponse)
@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 ""
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)"
)
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}")
logger.info('Admin updated branding name to', telegram_id=admin.telegram_id, name=name)
# Return updated branding
custom_logo = has_custom_logo()
logo_letter = name[0].upper() if name else "C"
logo_letter = name[0].upper() if name else 'C'
return BrandingResponse(
name=name,
logo_url="/cabinet/branding/logo" if custom_logo else None,
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)
@router.post('/logo', response_model=BrandingResponse)
async def upload_logo(
file: UploadFile = File(...),
admin: User = Depends(get_current_admin_user),
@@ -288,8 +331,7 @@ async def upload_logo(
# Validate content type
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed: PNG, JPEG, WebP, SVG"
status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid file type. Allowed: PNG, JPEG, WebP, SVG'
)
# Read file content
@@ -299,7 +341,7 @@ async def upload_logo(
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"
detail=f'File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB',
)
# Ensure directory exists
@@ -307,65 +349,63 @@ async def upload_logo(
# Determine file extension from content type
ext_map = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/webp": ".webp",
"image/svg+xml": ".svg",
'image/png': '.png',
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/webp': '.webp',
'image/svg+xml': '.svg',
}
extension = ext_map.get(file.content_type, ".png")
extension = ext_map.get(file.content_type, '.png')
# Remove old logo files with any extension
for old_file in BRANDING_DIR.glob("logo.*"):
for old_file in BRANDING_DIR.glob('logo.*'):
old_file.unlink()
# Save new logo
logo_path = BRANDING_DIR / f"logo{extension}"
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")
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
logger.info(f"Admin {admin.telegram_id} uploaded new logo: {logo_path}")
logger.info('Admin uploaded new logo', telegram_id=admin.telegram_id, logo_path=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')
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
logo_letter = name[0].upper() if name else "C"
logo_letter = name[0].upper() if name else 'C'
return BrandingResponse(
name=name,
logo_url="/cabinet/branding/logo",
logo_url='/cabinet/branding/logo',
logo_letter=logo_letter,
has_custom_logo=True,
)
@router.delete("/logo", response_model=BrandingResponse)
@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.*"):
for old_file in BRANDING_DIR.glob('logo.*'):
old_file.unlink()
# Update setting
await set_setting_value(db, BRANDING_LOGO_KEY, "default")
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
logger.info(f"Admin {admin.telegram_id} deleted custom logo")
logger.info('Admin deleted custom logo', telegram_id=admin.telegram_id)
# 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')
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
logo_letter = name[0].upper() if name else "C"
logo_letter = name[0].upper() if name else 'C'
return BrandingResponse(
name=name,
@@ -377,11 +417,12 @@ async def delete_logo(
# ============ 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("#"):
if not color.startswith('#'):
return False
hex_part = color[1:]
if len(hex_part) not in (3, 6):
@@ -393,7 +434,7 @@ def validate_hex_color(color: str) -> bool:
return False
@router.get("/colors", response_model=ThemeColorsResponse)
@router.get('/colors', response_model=ThemeColorsResponse)
async def get_theme_colors(
db: AsyncSession = Depends(get_cabinet_db),
):
@@ -415,7 +456,7 @@ async def get_theme_colors(
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
@router.patch("/colors", response_model=ThemeColorsResponse)
@router.patch('/colors', response_model=ThemeColorsResponse)
async def update_theme_colors(
payload: ThemeColorsUpdate,
admin: User = Depends(get_current_admin_user),
@@ -438,22 +479,19 @@ async def update_theme_colors(
# 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}"
)
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())}")
logger.info('Admin updated theme colors', telegram_id=admin.telegram_id, value=list(update_data.keys()))
return ThemeColorsResponse(**current_colors)
@router.post("/colors/reset", response_model=ThemeColorsResponse)
@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),
@@ -462,17 +500,17 @@ async def reset_theme_colors(
# 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")
logger.info('Admin reset theme colors to defaults', telegram_id=admin.telegram_id)
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
# ============ Enabled Themes Routes ============
DEFAULT_ENABLED_THEMES = {"dark": True, "light": True}
DEFAULT_ENABLED_THEMES = {'dark': True, 'light': True}
@router.get("/themes", response_model=EnabledThemesResponse)
@router.get('/themes', response_model=EnabledThemesResponse)
async def get_enabled_themes(
db: AsyncSession = Depends(get_cabinet_db),
):
@@ -492,7 +530,7 @@ async def get_enabled_themes(
return EnabledThemesResponse(**DEFAULT_ENABLED_THEMES)
@router.patch("/themes", response_model=EnabledThemesResponse)
@router.patch('/themes', response_model=EnabledThemesResponse)
async def update_enabled_themes(
payload: EnabledThemesUpdate,
admin: User = Depends(get_current_admin_user),
@@ -514,23 +552,21 @@ async def update_enabled_themes(
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"
)
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}")
logger.info('Admin updated enabled themes', telegram_id=admin.telegram_id, current_themes=current_themes)
return EnabledThemesResponse(**current_themes)
# ============ Animation Routes ============
@router.get("/animation", response_model=AnimationEnabledResponse)
@router.get('/animation', response_model=AnimationEnabledResponse)
async def get_animation_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
@@ -541,14 +577,14 @@ async def get_animation_enabled(
animation_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
if animation_value is not None:
enabled = animation_value.lower() == "true"
enabled = animation_value.lower() == 'true'
return AnimationEnabledResponse(enabled=enabled)
# Default: enabled
return AnimationEnabledResponse(enabled=True)
@router.patch("/animation", response_model=AnimationEnabledResponse)
@router.patch('/animation', response_model=AnimationEnabledResponse)
async def update_animation_enabled(
payload: AnimationEnabledUpdate,
admin: User = Depends(get_current_admin_user),
@@ -557,14 +593,15 @@ async def update_animation_enabled(
"""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}")
logger.info('Admin set animation enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return AnimationEnabledResponse(enabled=payload.enabled)
# ============ Fullscreen Routes ============
@router.get("/fullscreen", response_model=FullscreenEnabledResponse)
@router.get('/fullscreen', response_model=FullscreenEnabledResponse)
async def get_fullscreen_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
@@ -575,14 +612,14 @@ async def get_fullscreen_enabled(
fullscreen_value = await get_setting_value(db, FULLSCREEN_ENABLED_KEY)
if fullscreen_value is not None:
enabled = fullscreen_value.lower() == "true"
enabled = fullscreen_value.lower() == 'true'
return FullscreenEnabledResponse(enabled=enabled)
# Default: disabled
return FullscreenEnabledResponse(enabled=False)
@router.patch("/fullscreen", response_model=FullscreenEnabledResponse)
@router.patch('/fullscreen', response_model=FullscreenEnabledResponse)
async def update_fullscreen_enabled(
payload: FullscreenEnabledUpdate,
admin: User = Depends(get_current_admin_user),
@@ -591,6 +628,142 @@ async def update_fullscreen_enabled(
"""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}")
logger.info('Admin set fullscreen enabled', telegram_id=admin.telegram_id, 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('Admin set email auth enabled', telegram_id=admin.telegram_id, 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('Admin updated analytics counters', telegram_id=admin.telegram_id)
# 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('Admin set lite mode enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return LiteModeEnabledResponse(enabled=payload.enabled)
+116 -112
View File
@@ -1,47 +1,50 @@
"""Contests routes for cabinet - user participation in games/contests."""
import logging
import random
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User, SubscriptionStatus
from app.database.crud.contest import (
create_attempt,
get_active_rounds,
get_attempt,
create_attempt,
increment_winner_count,
)
from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription
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_QUEST,
GAME_LOCKS,
GAME_CIPHER,
GAME_SERVER,
GAME_BLITZ,
GAME_EMOJI,
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"])
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/contests', tags=['Cabinet Contests'])
# ============ Schemas ============
class ContestInfo(BaseModel):
"""Contest/game info."""
id: int
slug: str
name: str
description: Optional[str] = None
description: str | None = None
prize_type: str
prize_value: str
is_available: bool
@@ -50,28 +53,32 @@ class ContestInfo(BaseModel):
class ContestGameData(BaseModel):
"""Data for playing a contest game."""
round_id: int
game_type: str
game_data: Dict[str, Any]
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: Optional[str] = None
prize_value: Optional[str] = None
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:
@@ -84,55 +91,57 @@ def _user_allowed(subscription) -> bool:
async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_value: str) -> str:
"""Award prize to winner."""
if prize_type == "days":
if prize_type == 'days':
try:
days = int(prize_value)
except ValueError:
return "Error: invalid prize value"
return 'Error: invalid prize value'
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return "Error: subscription not found"
return 'Error: subscription not found'
subscription.end_date = subscription.end_date + timedelta(days=days)
subscription.updated_at = datetime.utcnow()
subscription.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
logger.info(f"🎁 Extended subscription for user {user_id} by {days} days (contest prize)")
return f"Subscription extended by {days} days"
logger.info('🎁 Extended subscription for user by days (contest prize)', user_id=user_id, days=days)
return f'Subscription extended by {days} days'
elif prize_type == "balance":
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"
return 'Error: invalid prize value'
user = await get_user_by_id(db, user_id)
if not user:
return "Error: user not found"
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.info('🎁 Added to balance for user (contest prize)', amount=amount, user_id=user_id)
return f'Balance increased by {amount}'
else:
logger.warning(f"Unknown prize type: {prize_type}")
return f"Prize type '{prize_type}' not supported"
logger.warning('Unknown prize type', 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)
@router.get('/count', response_model=ContestsCountResponse)
async def get_contests_count(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -151,7 +160,7 @@ async def get_contests_count(
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 ""
tpl_slug = rnd.template.slug if rnd.template else ''
if tpl_slug in seen_templates:
continue
seen_templates.add(tpl_slug)
@@ -164,7 +173,7 @@ async def get_contests_count(
return ContestsCountResponse(count=count)
@router.get("", response_model=List[ContestInfo])
@router.get('', response_model=list[ContestInfo])
async def get_contests(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -175,7 +184,7 @@ async def get_contests(
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",
detail='Contests are only available for users with active or trial subscriptions',
)
active_rounds = await get_active_rounds(db)
@@ -185,7 +194,7 @@ async def get_contests(
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 ""
tpl_slug = rnd.template.slug if rnd.template else ''
if tpl_slug not in unique_templates:
unique_templates[tpl_slug] = rnd
@@ -194,21 +203,23 @@ async def get_contests(
# 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,
))
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)
@router.get('/{round_id}', response_model=ContestGameData)
async def get_contest_game(
round_id: int,
user: User = Depends(get_current_cabinet_user),
@@ -220,7 +231,7 @@ async def get_contest_game(
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",
detail='Contests are only available for users with active or trial subscriptions',
)
active_rounds = await get_active_rounds(db)
@@ -229,13 +240,13 @@ async def get_contest_game(
if not round_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Contest round not found or already finished",
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",
detail='This contest is disabled',
)
# Check if already played
@@ -243,80 +254,80 @@ async def get_contest_game(
if attempt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You have already played this round",
detail='You have already played this round',
)
tpl = round_obj.template
game_type = tpl.slug
game_data = {}
instructions = ""
instructions = ''
if game_type == GAME_QUEST:
rows = round_obj.payload.get("rows", 3)
cols = round_obj.payload.get("cols", 3)
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,
'rows': rows,
'cols': cols,
'secret': secret,
'grid_size': rows * cols,
}
instructions = "Select one of the nodes in the grid. Find the hidden server!"
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)
total = round_obj.payload.get('total', 20)
secret = random.randint(0, total - 1)
game_data = {
"total": total,
"secret": secret,
'total': total,
'secret': secret,
}
instructions = "Find the unlocked button among the locks!"
instructions = 'Find the unlocked button among the locks!'
elif game_type == GAME_SERVER:
flags = round_obj.payload.get("flags") or []
flags = round_obj.payload.get('flags') or []
shuffled_flags = flags.copy()
random.shuffle(shuffled_flags)
game_data = {
"flags": shuffled_flags,
'flags': shuffled_flags,
}
instructions = "Choose a server by clicking on a flag!"
instructions = 'Choose a server by clicking on a flag!'
elif game_type == GAME_CIPHER:
question = round_obj.payload.get("question", "")
question = round_obj.payload.get('question', '')
game_data = {
"question": question,
"input_type": "text",
'question': question,
'input_type': 'text',
}
instructions = "Decrypt the cipher and enter the answer!"
instructions = 'Decrypt the cipher and enter the answer!'
elif game_type == GAME_EMOJI:
question = round_obj.payload.get("question", "🤔")
question = round_obj.payload.get('question', '🤔')
emoji_list = question.split()
random.shuffle(emoji_list)
game_data = {
"question": " ".join(emoji_list),
"input_type": "text",
'question': ' '.join(emoji_list),
'input_type': 'text',
}
instructions = "Guess the service by emojis!"
instructions = 'Guess the service by emojis!'
elif game_type == GAME_ANAGRAM:
letters = round_obj.payload.get("letters", "")
letters = round_obj.payload.get('letters', '')
game_data = {
"letters": letters,
"input_type": "text",
'letters': letters,
'input_type': 'text',
}
instructions = "Make a word from the given letters!"
instructions = 'Make a word from the given letters!'
elif game_type == GAME_BLITZ:
game_data = {
"button_text": "I'm here!",
'button_text': "I'm here!",
}
instructions = "Click the button as fast as you can!"
instructions = 'Click the button as fast as you can!'
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unknown contest type",
detail='Unknown contest type',
)
return ContestGameData(
@@ -327,7 +338,7 @@ async def get_contest_game(
)
@router.post("/{round_id}/answer", response_model=ContestResult)
@router.post('/{round_id}/answer', response_model=ContestResult)
async def submit_contest_answer(
round_id: int,
request: ContestAnswerRequest,
@@ -340,7 +351,7 @@ async def submit_contest_answer(
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",
detail='Contests are only available for users with active or trial subscriptions',
)
active_rounds = await get_active_rounds(db)
@@ -349,7 +360,7 @@ async def submit_contest_answer(
if not round_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Contest round not found or already finished",
detail='Contest round not found or already finished',
)
# Check if already played
@@ -357,7 +368,7 @@ async def submit_contest_answer(
if attempt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You have already played this round",
detail='You have already played this round',
)
tpl = round_obj.template
@@ -366,14 +377,14 @@ async def submit_contest_answer(
# 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 ""
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("_")
parts = answer.split('_')
if len(parts) >= 2:
idx = int(parts[0])
secret = int(parts[1])
@@ -382,38 +393,31 @@ async def submit_contest_answer(
is_winner = False
elif tpl.slug == GAME_BLITZ:
is_winner = answer.lower() == "blitz"
is_winner = answer.lower() == 'blitz'
elif tpl.slug in {GAME_CIPHER, GAME_EMOJI, GAME_ANAGRAM}:
correct = (round_obj.payload.get("answer") or "").upper()
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
)
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}",
message=f'🎉 Congratulations! You won! {prize_text}',
prize_type=tpl.prize_type,
prize_value=tpl.prize_value,
)
else:
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),
)
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),
)
+92 -56
View File
@@ -1,31 +1,55 @@
"""Info pages routes for cabinet - FAQ, rules, privacy policy, etc."""
import logging
from typing import List, Optional, Dict, Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
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 app.database.crud.rules import get_rules_by_language, get_current_rules_content
from ..dependencies import get_cabinet_db, get_current_cabinet_user, get_optional_cabinet_user
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/info", tags=["Cabinet Info"])
logger = structlog.get_logger(__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
@@ -34,44 +58,50 @@ class FaqPageResponse(BaseModel):
class RulesResponse(BaseModel):
"""Service rules."""
content: str
updated_at: Optional[str] = None
updated_at: str | None = None
class PrivacyPolicyResponse(BaseModel):
"""Privacy policy."""
content: str
updated_at: Optional[str] = None
updated_at: str | None = None
class PublicOfferResponse(BaseModel):
"""Public offer."""
content: str
updated_at: Optional[str] = None
updated_at: str | None = None
class ServiceInfoResponse(BaseModel):
"""General service info."""
name: str
description: Optional[str] = None
support_email: Optional[str] = None
support_telegram: Optional[str] = None
website: Optional[str] = None
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: Optional[str] = None
support_username: Optional[str] = None
support_url: str | None = None
support_username: str | None = None
# ============ Routes ============
@router.get("/faq", response_model=List[FaqPageResponse])
@router.get('/faq', response_model=list[FaqPageResponse])
async def get_faq_pages(
language: str = Query("ru", min_length=2, max_length=10),
language: str = Query('ru', min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of FAQ pages."""
@@ -87,17 +117,17 @@ async def get_faq_pages(
FaqPageResponse(
id=page.id,
title=page.title,
content=page.content or "",
content=page.content or '',
order=page.display_order or 0,
)
for page in pages
]
@router.get("/faq/{page_id}", response_model=FaqPageResponse)
@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),
language: str = Query('ru', min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get a specific FAQ page by ID."""
@@ -113,24 +143,24 @@ async def get_faq_page(
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="FAQ page not found",
detail='FAQ page not found',
)
return FaqPageResponse(
id=page.id,
title=page.title,
content=page.content or "",
content=page.content or '',
order=page.display_order or 0,
)
@router.get("/rules", response_model=RulesResponse)
@router.get('/rules', response_model=RulesResponse)
async def get_rules(
language: str = Query("ru", min_length=2, max_length=10),
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()
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)
@@ -144,9 +174,9 @@ async def get_rules(
return RulesResponse(content=content, updated_at=updated_at)
@router.get("/privacy-policy", response_model=PrivacyPolicyResponse)
@router.get('/privacy-policy', response_model=PrivacyPolicyResponse)
async def get_privacy_policy(
language: str = Query("ru", min_length=2, max_length=10),
language: str = Query('ru', min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get privacy policy."""
@@ -167,9 +197,9 @@ async def get_privacy_policy(
)
@router.get("/public-offer", response_model=PublicOfferResponse)
@router.get('/public-offer', response_model=PublicOfferResponse)
async def get_public_offer(
language: str = Query("ru", min_length=2, max_length=10),
language: str = Query('ru', min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get public offer."""
@@ -190,7 +220,7 @@ async def get_public_offer(
)
@router.get("/service", response_model=ServiceInfoResponse)
@router.get('/service', response_model=ServiceInfoResponse)
async def get_service_info():
"""Get general service information."""
return ServiceInfoResponse(
@@ -202,50 +232,56 @@ async def get_service_info():
)
@router.get("/languages")
@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": "ru", "name": "Русский", "flag": "🇷🇺"},
{"code": "en", "name": "English", "flag": "🇬🇧"},
'languages': [
{
'code': code,
'name': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[0],
'flag': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[1],
}
for code in codes
],
"default": getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru',
'default': default_language,
}
@router.get("/user/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"}
return {'language': user.language or 'ru'}
@router.patch("/user/language")
@router.patch('/user/language')
async def update_user_language(
request: Dict[str, str],
request: dict[str, str],
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user's language preference."""
language = request.get("language", "ru")
valid_languages = ["ru", "en"]
if language not in valid_languages:
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(valid_languages)}",
detail=f'Invalid language. Supported: {", ".join(available_languages)}',
)
user.language = language
user.language = requested_language
await db.commit()
await db.refresh(user)
return {"language": user.language}
return {'language': user.language}
@router.get("/support-config", response_model=SupportConfigResponse)
@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)
@@ -255,15 +291,15 @@ async def get_support_config():
# - "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":
if support_mode == 'tickets':
tickets_enabled = True
support_type = "tickets"
elif support_mode == "contact":
support_type = 'tickets'
elif support_mode == 'contact':
tickets_enabled = False
support_type = "profile"
support_type = 'profile'
else: # both
tickets_enabled = True
support_type = "tickets"
support_type = 'tickets'
return SupportConfigResponse(
tickets_enabled=tickets_enabled,
+41 -36
View File
@@ -1,35 +1,35 @@
"""Media upload/download routes for cabinet tickets."""
import logging
import mimetypes
from typing import Optional
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..dependencies import get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/media", tags=["Cabinet Media"])
logger = structlog.get_logger(__name__)
ALLOWED_MEDIA_TYPES = {"photo", "video", "document"}
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: Optional[str] = None
file_unique_id: str | None = None
media_url: str
@@ -45,31 +45,31 @@ def _resolve_target_chat_id() -> int:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No chat configured for file uploads",
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))
return str(request.url_for('cabinet_download_media', file_id=file_id))
@router.post("/upload", response_model=MediaUploadResponse, status_code=status.HTTP_201_CREATED)
@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"),
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()
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)}",
detail=f'Unsupported media type. Allowed: {", ".join(ALLOWED_MEDIA_TYPES)}',
)
# Read and validate file
@@ -77,26 +77,26 @@ async def upload_media(
if not file_bytes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File is empty",
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",
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 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",
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")
upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload')
bot = Bot(
token=settings.BOT_TOKEN,
@@ -104,13 +104,13 @@ async def upload_media(
)
try:
if media_type_normalized == "photo":
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":
elif media_type_normalized == 'video':
message = await bot.send_video(
chat_id=target_chat_id,
video=upload,
@@ -125,27 +125,32 @@ async def upload_media(
media_url = _build_media_url(request, media.file_id)
logger.info(f"User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}")
logger.info(
'User uploaded',
telegram_id=user.telegram_id,
media_type_normalized=media_type_normalized,
file_id=media.file_id,
)
return MediaUploadResponse(
media_type=media_type_normalized,
file_id=media.file_id,
file_unique_id=getattr(media, "file_unique_id", None),
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}")
logger.error('Failed to upload media for user', telegram_id=user.telegram_id, error=error)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to upload media",
detail='Failed to upload media',
) from error
finally:
await bot.session.close()
@router.get("/{file_id}", name="cabinet_download_media")
@router.get('/{file_id}', name='cabinet_download_media')
async def download_media(
file_id: str,
) -> Response:
@@ -163,34 +168,34 @@ async def download_media(
if not file.file_path:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Media file not found",
detail='Media file not found',
)
buffer = await bot.download_file(file.file_path)
if hasattr(buffer, "seek"):
if hasattr(buffer, 'seek'):
buffer.seek(0)
content = buffer.read() if hasattr(buffer, "read") else bytes(buffer)
filename = file.file_path.split("/")[-1]
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"
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
'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}")
logger.error('Failed to download media', file_id=file_id, error=error)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to download media",
detail='Failed to download media',
) from error
finally:
await bot.session.close()
+41 -35
View File
@@ -1,10 +1,10 @@
"""Notification settings routes for cabinet."""
import logging
from datetime import datetime
from typing import Optional, Dict, Any
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
import structlog
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,15 +12,18 @@ 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"])
logger = structlog.get_logger(__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
@@ -33,36 +36,38 @@ class NotificationSettingsResponse(BaseModel):
class NotificationSettingsUpdate(BaseModel):
"""Update notification settings."""
subscription_expiry_enabled: Optional[bool] = None
subscription_expiry_days: Optional[int] = Field(None, ge=1, le=30)
traffic_warning_enabled: Optional[bool] = None
traffic_warning_percent: Optional[int] = Field(None, ge=50, le=99)
balance_low_enabled: Optional[bool] = None
balance_low_threshold: Optional[int] = Field(None, ge=0)
news_enabled: Optional[bool] = None
promo_offers_enabled: Optional[bool] = None
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]:
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),
'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]:
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)
@@ -75,7 +80,8 @@ def _update_notification_settings(user: User, updates: Dict[str, Any]) -> Dict[s
# ============ Routes ============
@router.get("", response_model=NotificationSettingsResponse)
@router.get('', response_model=NotificationSettingsResponse)
async def get_notification_settings(
user: User = Depends(get_current_cabinet_user),
):
@@ -84,7 +90,7 @@ async def get_notification_settings(
return NotificationSettingsResponse(**settings)
@router.patch("", response_model=NotificationSettingsResponse)
@router.patch('', response_model=NotificationSettingsResponse)
async def update_notification_settings(
request: NotificationSettingsUpdate,
user: User = Depends(get_current_cabinet_user),
@@ -106,7 +112,7 @@ async def update_notification_settings(
user.notification_settings = {}
user.notification_settings = new_settings
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
@@ -114,7 +120,7 @@ async def update_notification_settings(
return NotificationSettingsResponse(**new_settings)
@router.post("/test")
@router.post('/test')
async def send_test_notification(
user: User = Depends(get_current_cabinet_user),
):
@@ -122,12 +128,12 @@ async def send_test_notification(
# 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.",
'success': True,
'message': 'Test notification request received. You will receive a test message shortly.',
}
@router.get("/history")
@router.get('/history')
async def get_notification_history(
limit: int = 20,
offset: int = 0,
@@ -138,8 +144,8 @@ async def get_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,
'notifications': [],
'total': 0,
'limit': limit,
'offset': offset,
}
+178
View File
@@ -0,0 +1,178 @@
"""OAuth 2.0 authentication routes for cabinet."""
from datetime import UTC, datetime
import structlog
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, _process_campaign_bonus, _store_refresh_token
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
async def _finalize_oauth_login(
db: AsyncSession,
user: User,
provider: str,
campaign_slug: str | None = None,
) -> AuthResponse:
"""Update last login, create tokens, store refresh token."""
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
auth_response = _create_auth_response(user)
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
if auth_response.campaign_bonus:
from .auth import _user_to_response
auth_response.user = _user_to_response(user)
return auth_response
# --- 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')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
# --- 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', provider=provider, exc=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', provider=provider, exc=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 for existing user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
# 6. Find user by email (if verified) and link provider
if user_info.email and user_info.email_verified:
user = await get_user_by_email(db, user_info.email)
if user:
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
# 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 with id', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
+162
View File
@@ -0,0 +1,162 @@
"""User-facing partner application routes for cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import AdvertisingCampaign, User
from app.services.partner_application_service import partner_application_service
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.partners import (
PartnerApplicationInfo,
PartnerApplicationRequest,
PartnerCampaignInfo,
PartnerStatusResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/referral/partner', tags=['Cabinet Partner'])
def _get_campaign_deep_link(start_parameter: str) -> str | None:
"""Generate Telegram deep link for campaign."""
bot_username = settings.get_bot_username()
if bot_username:
return f'https://t.me/{bot_username}?start={start_parameter}'
return None
def _get_campaign_web_link(start_parameter: str) -> str | None:
"""Generate web link for campaign."""
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
if base_url:
return f'{base_url}/?campaign={start_parameter}'
return None
@router.get('/status', response_model=PartnerStatusResponse)
async def get_partner_status(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get partner status and latest application for current user."""
latest_app = await partner_application_service.get_latest_application(db, user.id)
app_info = None
if latest_app:
app_info = PartnerApplicationInfo(
id=latest_app.id,
status=latest_app.status,
company_name=latest_app.company_name,
website_url=latest_app.website_url,
telegram_channel=latest_app.telegram_channel,
description=latest_app.description,
expected_monthly_referrals=latest_app.expected_monthly_referrals,
admin_comment=latest_app.admin_comment,
approved_commission_percent=latest_app.approved_commission_percent,
created_at=latest_app.created_at,
processed_at=latest_app.processed_at,
)
commission = user.referral_commission_percent
if commission is None and user.is_partner:
commission = settings.REFERRAL_COMMISSION_PERCENT
# Fetch campaigns assigned to this partner
campaigns: list[PartnerCampaignInfo] = []
if user.is_partner:
result = await db.execute(
select(AdvertisingCampaign).where(
AdvertisingCampaign.partner_user_id == user.id,
AdvertisingCampaign.is_active.is_(True),
)
)
for c in result.scalars().all():
campaigns.append(
PartnerCampaignInfo(
id=c.id,
name=c.name,
start_parameter=c.start_parameter,
bonus_type=c.bonus_type,
balance_bonus_kopeks=c.balance_bonus_kopeks or 0,
subscription_duration_days=c.subscription_duration_days,
subscription_traffic_gb=c.subscription_traffic_gb,
deep_link=_get_campaign_deep_link(c.start_parameter),
web_link=_get_campaign_web_link(c.start_parameter),
)
)
return PartnerStatusResponse(
partner_status=user.partner_status,
commission_percent=commission,
latest_application=app_info,
campaigns=campaigns,
)
@router.post('/apply', response_model=PartnerApplicationInfo)
async def apply_for_partner(
request: PartnerApplicationRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Submit partner application."""
application, error = await partner_application_service.submit_application(
db,
user_id=user.id,
company_name=request.company_name,
website_url=request.website_url,
telegram_channel=request.telegram_channel,
description=request.description,
expected_monthly_referrals=request.expected_monthly_referrals,
)
if not application:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Уведомляем админов о новой заявке
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_partner_application_notification(
user=user,
application_data={
'company_name': request.company_name,
'telegram_channel': request.telegram_channel,
'website_url': request.website_url,
'description': request.description,
'expected_monthly_referrals': request.expected_monthly_referrals,
},
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for partner application', error=e)
return PartnerApplicationInfo(
id=application.id,
status=application.status,
company_name=application.company_name,
website_url=application.website_url,
telegram_channel=application.telegram_channel,
description=application.description,
expected_monthly_referrals=application.expected_monthly_referrals,
admin_comment=application.admin_comment,
approved_commission_percent=application.approved_commission_percent,
created_at=application.created_at,
processed_at=application.processed_at,
)
+60 -48
View File
@@ -1,34 +1,36 @@
"""Polls routes for cabinet - user participation in polls/surveys."""
import logging
from datetime import datetime
from typing import List, Optional, Dict, Any
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import User, Poll, PollResponse, PollQuestion
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 app.config import settings
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/polls", tags=["Cabinet Polls"])
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/polls', tags=['Cabinet Polls'])
# ============ Schemas ============
class PollOptionResponse(BaseModel):
"""Poll option."""
id: int
text: str
order: int
@@ -36,26 +38,29 @@ class PollOptionResponse(BaseModel):
class PollQuestionResponse(BaseModel):
"""Poll question with options."""
id: int
text: str
order: int
options: List[PollOptionResponse]
options: list[PollOptionResponse]
class PollInfo(BaseModel):
"""Poll info for user."""
id: int
response_id: int
title: str
description: Optional[str] = None
description: str | None = None
total_questions: int
answered_questions: int
is_completed: bool
reward_amount: Optional[int] = None
reward_amount: int | None = None
class PollStartResponse(BaseModel):
"""Response when starting a poll."""
response_id: int
current_question_index: int
total_questions: int
@@ -64,22 +69,25 @@ class PollStartResponse(BaseModel):
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: Optional[PollQuestionResponse] = None
current_question_index: Optional[int] = None
next_question: PollQuestionResponse | None = None
current_question_index: int | None = None
total_questions: int
reward_granted: Optional[int] = None
message: Optional[str] = None
reward_granted: int | None = None
message: str | None = None
# ============ Helpers ============
def _question_to_response(question: PollQuestion) -> PollQuestionResponse:
"""Convert question model to response."""
options = [
@@ -100,12 +108,14 @@ def _question_to_response(question: PollQuestion) -> PollQuestionResponse:
# ============ Routes ============
class PollsCountResponse(BaseModel):
"""Count of available polls."""
count: int
@router.get("/count", response_model=PollsCountResponse)
@router.get('/count', response_model=PollsCountResponse)
async def get_polls_count(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -120,7 +130,7 @@ async def get_polls_count(
return PollsCountResponse(count=len(responses))
@router.get("", response_model=List[PollInfo])
@router.get('', response_model=list[PollInfo])
async def get_available_polls(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -151,21 +161,23 @@ async def get_available_polls(
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,
))
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)
@router.get('/{response_id}', response_model=PollInfo)
async def get_poll_details(
response_id: int,
user: User = Depends(get_current_cabinet_user),
@@ -177,13 +189,13 @@ async def get_poll_details(
if not response or response.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Poll not found",
detail='Poll not found',
)
if not response.poll:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Poll data not available",
detail='Poll data not available',
)
answered_count = len(response.answers) if response.answers else 0
@@ -206,7 +218,7 @@ async def get_poll_details(
)
@router.post("/{response_id}/start", response_model=PollStartResponse)
@router.post('/{response_id}/start', response_model=PollStartResponse)
async def start_poll(
response_id: int,
user: User = Depends(get_current_cabinet_user),
@@ -218,24 +230,24 @@ async def start_poll(
if not response or response.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Poll 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",
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",
detail='Poll is not available',
)
# Mark as started if not already
if not response.started_at:
response.started_at = datetime.utcnow()
response.started_at = datetime.now(UTC)
await db.commit()
# Get next unanswered question
@@ -244,7 +256,7 @@ async def start_poll(
if not question:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No questions available",
detail='No questions available',
)
return PollStartResponse(
@@ -255,7 +267,7 @@ async def start_poll(
)
@router.post("/{response_id}/questions/{question_id}/answer", response_model=AnswerResponse)
@router.post('/{response_id}/questions/{question_id}/answer', response_model=AnswerResponse)
async def answer_question(
response_id: int,
question_id: int,
@@ -269,19 +281,19 @@ async def answer_question(
if not response or response.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Poll 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",
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",
detail='Poll is not available',
)
# Find the question
@@ -289,7 +301,7 @@ async def answer_question(
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found",
detail='Question not found',
)
# Validate option
@@ -297,7 +309,7 @@ async def answer_question(
if not option:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid answer option",
detail='Invalid answer option',
)
# Record the answer
@@ -310,13 +322,13 @@ async def answer_question(
# Refresh to get updated answers
try:
await db.refresh(response, attribute_names=["answers"])
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",
detail='Failed to process answer',
)
# Get next question
@@ -334,15 +346,15 @@ async def answer_question(
)
# Poll completed
response.completed_at = datetime.utcnow()
response.completed_at = datetime.now(UTC)
await db.commit()
# Award reward if any
reward_amount = await reward_user_for_poll(db, response)
message = "Thank you for completing the poll!"
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."
message += f' Reward of {settings.format_price(reward_amount)} has been added to your balance.'
return AnswerResponse(
success=True,
+166 -57
View File
@@ -1,84 +1,118 @@
"""Promo offers routes for cabinet - personal discounts and offers."""
import logging
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from pydantic import BaseModel
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from app.database.models import User, DiscountOffer
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 app.config import settings
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/promo", tags=["Cabinet Promo"])
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/promo', tags=['Cabinet Promo'])
# ============ Schemas ============
class PromoOfferInfo(BaseModel):
"""Promo offer info."""
id: int
notification_type: str
discount_percent: Optional[int] = None
discount_percent: int | None = None
effect_type: str
expires_at: datetime
is_active: bool
is_claimed: bool
claimed_at: Optional[datetime] = None
extra_data: Optional[Dict[str, Any]] = None
claimed_at: datetime | None = None
extra_data: dict[str, Any] | None = None
class ActiveDiscountInfo(BaseModel):
"""User's active discount info."""
discount_percent: int
source: Optional[str] = None
expires_at: Optional[datetime] = None
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: Optional[int] = None
expires_at: Optional[datetime] = None
discount_percent: int | None = None
expires_at: datetime | None = None
class PromoGroupDiscounts(BaseModel):
"""User's promo group discounts."""
group_name: Optional[str] = None
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] = {}
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])
@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()
now = datetime.now(UTC)
result = await db.execute(
select(DiscountOffer)
@@ -95,9 +129,9 @@ async def get_promo_offers(
return [
PromoOfferInfo(
id=offer.id,
notification_type=offer.notification_type or "",
notification_type=offer.notification_type or '',
discount_percent=offer.discount_percent,
effect_type=offer.effect_type or "percent_discount",
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,
@@ -108,7 +142,7 @@ async def get_promo_offers(
]
@router.get("/active-discount", response_model=ActiveDiscountInfo)
@router.get('/active-discount', response_model=ActiveDiscountInfo)
async def get_active_discount(
user: User = Depends(get_current_cabinet_user),
):
@@ -117,7 +151,7 @@ async def get_active_discount(
expires_at = user.promo_offer_discount_expires_at
source = user.promo_offer_discount_source
now = datetime.utcnow()
now = datetime.now(UTC)
is_active = discount_percent > 0 and (expires_at is None or expires_at > now)
return ActiveDiscountInfo(
@@ -128,13 +162,13 @@ async def get_active_discount(
)
@router.get("/group-discounts", response_model=PromoGroupDiscounts)
@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_groups"])
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
@@ -143,7 +177,7 @@ async def get_promo_group_discounts(
# Get period discounts
period_discounts = {}
raw_period_discounts = getattr(promo_group, "period_discounts", None)
raw_period_discounts = getattr(promo_group, 'period_discounts', None)
if isinstance(raw_period_discounts, dict):
for key, value in raw_period_discounts.items():
try:
@@ -160,7 +194,82 @@ async def get_promo_group_discounts(
)
@router.post("/claim", response_model=ClaimOfferResponse)
@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),
@@ -172,15 +281,15 @@ async def claim_promo_offer(
if not offer or offer.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Offer not found",
detail='Offer not found',
)
now = datetime.utcnow()
now = datetime.now(UTC)
if offer.claimed_at is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This offer has already been claimed",
detail='This offer has already been claimed',
)
if not offer.is_active or offer.expires_at <= now:
@@ -188,14 +297,14 @@ async def claim_promo_offer(
await db.commit()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This offer has expired",
detail='This offer has expired',
)
effect_type = (offer.effect_type or "percent_discount").lower()
effect_type = (offer.effect_type or 'percent_discount').lower()
# Handle test access offers
if effect_type == "test_access":
await db.refresh(user, ["subscription"])
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,
@@ -204,29 +313,29 @@ async def claim_promo_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",
'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"),
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,
'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'}",
message=f'Test access activated until {expires_at.strftime("%Y-%m-%d %H:%M") if expires_at else "unlimited"}',
expires_at=expires_at,
)
@@ -235,7 +344,7 @@ async def claim_promo_offer(
if discount_percent <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid offer",
detail='Invalid offer',
)
user.promo_offer_discount_percent = discount_percent
@@ -244,10 +353,10 @@ async def claim_promo_offer(
# Calculate expiration
extra_data = offer.extra_data or {}
raw_duration = extra_data.get("active_discount_hours")
template_id = extra_data.get("template_id")
raw_duration = extra_data.get('active_discount_hours')
template_id = extra_data.get('template_id')
if raw_duration in (None, "") and 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):
@@ -271,26 +380,26 @@ async def claim_promo_offer(
db,
offer,
details={
"context": "discount_claim",
"discount_percent": discount_percent,
"discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None,
'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 = ""
expires_text = ''
if discount_expires_at:
expires_text = f" Valid until {discount_expires_at.strftime('%Y-%m-%d %H:%M')}"
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}",
message=f'Discount of {discount_percent}% activated!{expires_text}',
discount_percent=discount_percent,
expires_at=discount_expires_at,
)
@router.delete("/active-discount")
@router.delete('/active-discount')
async def clear_active_discount(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -299,8 +408,8 @@ async def clear_active_discount(
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
return {"message": "Active discount cleared"}
return {'message': 'Active discount cleared'}
+71 -25
View File
@@ -1,8 +1,6 @@
"""Promo code routes for cabinet."""
import logging
from typing import Dict, Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,18 +10,21 @@ 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"])
logger = structlog.get_logger(__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")
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
@@ -31,7 +32,16 @@ class PromocodeActivateResponse(BaseModel):
bonus_description: str | None = None
@router.post("/activate", response_model=PromocodeActivateResponse)
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),
@@ -40,36 +50,72 @@ async def activate_promocode(
"""Activate a promo code for the current user."""
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(
db=db,
user_id=user.id,
code=request.code.strip()
)
result = await promocode_service.activate_promocode(db=db, user_id=user.id, code=request.code.strip())
if result["success"]:
balance_before_rubles = result.get("balance_before_kopeks", 0) / 100
balance_after_rubles = result.get("balance_after_kopeks", 0) / 100
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",
message='Promo code activated successfully',
balance_before=balance_before_rubles,
balance_after=balance_after_rubles,
bonus_description=result.get("description"),
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",
"user_not_found": "User not found",
"server_error": "Server error occurred",
'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")
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,
+55 -39
View File
@@ -1,33 +1,33 @@
"""Referral program routes for cabinet."""
import logging
import math
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
import structlog
from fastapi import APIRouter, Depends, Query
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from sqlalchemy.orm import selectinload
from app.database.models import User, ReferralEarning
from app.config import settings
from app.database.models import AdvertisingCampaign, ReferralEarning, User
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.referral import (
ReferralEarningResponse,
ReferralEarningsListResponse,
ReferralInfoResponse,
ReferralItemResponse,
ReferralListResponse,
ReferralEarningResponse,
ReferralEarningsListResponse,
ReferralTermsResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/referral", tags=["Cabinet Referral"])
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/referral', tags=['Cabinet Referral'])
@router.get("", response_model=ReferralInfoResponse)
@router.get('', response_model=ReferralInfoResponse)
async def get_referral_info(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -49,9 +49,8 @@ async def get_referral_info(
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_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
@@ -62,11 +61,11 @@ async def get_referral_info(
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}"
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_code=user.referral_code or '',
referral_link=referral_link,
total_referrals=total_referrals,
active_referrals=active_referrals,
@@ -76,10 +75,10 @@ async def get_referral_info(
)
@router.get("/list", response_model=ReferralListResponse)
@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"),
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),
):
@@ -122,10 +121,10 @@ async def get_referral_list(
)
@router.get("/earnings", response_model=ReferralEarningsListResponse)
@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"),
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),
):
@@ -138,9 +137,8 @@ async def get_referral_earnings(
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_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
@@ -152,22 +150,39 @@ async def get_referral_earnings(
result = await db.execute(query)
earnings = result.scalars().all()
# Batch-fetch referral users to avoid N+1
referral_ids = list({e.referral_id for e in earnings if e.referral_id})
if referral_ids:
referral_users_result = await db.execute(select(User).where(User.id.in_(referral_ids)))
referral_users_map = {u.id: u for u in referral_users_result.scalars().all()}
else:
referral_users_map = {}
# Batch-fetch campaigns to avoid N+1
campaign_ids = list({e.campaign_id for e in earnings if e.campaign_id})
if campaign_ids:
campaigns_result = await db.execute(select(AdvertisingCampaign).where(AdvertisingCampaign.id.in_(campaign_ids)))
campaigns_map = {c.id: c for c in campaigns_result.scalars().all()}
else:
campaigns_map = {}
items = []
for e in earnings:
# Get referral user info
referral_query = select(User).where(User.id == e.referral_id)
referral_result = await db.execute(referral_query)
referral_user = referral_result.scalar_one_or_none()
referral_user = referral_users_map.get(e.referral_id) if e.referral_id else None
campaign = campaigns_map.get(e.campaign_id) if e.campaign_id else None
items.append(ReferralEarningResponse(
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,
))
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,
campaign_name=campaign.name if campaign else None,
created_at=e.created_at,
)
)
pages = math.ceil(total / per_page) if total > 0 else 1
@@ -182,7 +197,7 @@ async def get_referral_earnings(
)
@router.get("/terms", response_model=ReferralTermsResponse)
@router.get('/terms', response_model=ReferralTermsResponse)
async def get_referral_terms():
"""Get referral program terms."""
return ReferralTermsResponse(
@@ -194,4 +209,5 @@ async def get_referral_terms():
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
)
File diff suppressed because it is too large Load Diff
+37 -39
View File
@@ -1,35 +1,35 @@
"""Ticket notifications routes for cabinet."""
import logging
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_cabinet_user, get_current_admin_user
from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/tickets/notifications", tags=["Cabinet Ticket Notifications"])
admin_router = APIRouter(prefix="/admin/tickets/notifications", tags=["Cabinet Admin Ticket Notifications"])
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: Optional[str] = None
message: str | None = None
is_read: bool
created_at: datetime
read_at: Optional[datetime] = None
read_at: datetime | None = None
class Config:
from_attributes = True
@@ -37,19 +37,21 @@ class TicketNotificationResponse(BaseModel):
class TicketNotificationListResponse(BaseModel):
"""List of ticket notifications."""
items: List[TicketNotificationResponse]
items: list[TicketNotificationResponse]
unread_count: int
class UnreadCountResponse(BaseModel):
"""Unread notifications count."""
unread_count: int
# User endpoints
@router.get("", response_model=TicketNotificationListResponse)
@router.get('', response_model=TicketNotificationListResponse)
async def get_user_notifications(
unread_only: bool = Query(False, description="Only return unread 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),
@@ -67,7 +69,7 @@ async def get_user_notifications(
)
@router.get("/unread-count", response_model=UnreadCountResponse)
@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),
@@ -77,7 +79,7 @@ async def get_user_unread_count(
return UnreadCountResponse(unread_count=count)
@router.post("/{notification_id}/read")
@router.post('/{notification_id}/read')
async def mark_notification_as_read(
notification_id: int,
user: User = Depends(get_current_cabinet_user),
@@ -89,7 +91,7 @@ async def mark_notification_as_read(
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found",
detail='Notification not found',
)
# Check ownership: notification must belong to user and not be an admin notification
@@ -100,36 +102,34 @@ async def mark_notification_as_read(
)
await TicketNotificationCRUD.mark_as_read(db, notification_id)
return {"success": True}
return {'success': True}
@router.post("/read-all")
@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}
return {'success': True, 'marked_count': count}
@router.post("/ticket/{ticket_id}/read")
@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}
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)
@admin_router.get('', response_model=TicketNotificationListResponse)
async def get_admin_notifications(
unread_only: bool = Query(False, description="Only return unread 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),
@@ -147,7 +147,7 @@ async def get_admin_notifications(
)
@admin_router.get("/unread-count", response_model=UnreadCountResponse)
@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),
@@ -157,7 +157,7 @@ async def get_admin_unread_count(
return UnreadCountResponse(unread_count=count)
@admin_router.post("/{notification_id}/read")
@admin_router.post('/{notification_id}/read')
async def mark_admin_notification_as_read(
notification_id: int,
admin: User = Depends(get_current_admin_user),
@@ -169,38 +169,36 @@ async def mark_admin_notification_as_read(
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification 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",
detail='This is not an admin notification',
)
await TicketNotificationCRUD.mark_as_read(db, notification_id)
return {"success": True}
return {'success': True}
@admin_router.post("/read-all")
@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}
return {'success': True, 'marked_count': count}
@admin_router.post("/ticket/{ticket_id}/read")
@admin_router.post('/ticket/{ticket_id}/read')
async def mark_admin_ticket_notifications_as_read(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
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}
count = await TicketNotificationCRUD.mark_ticket_notifications_as_read(db, ticket_id, admin.id, is_admin=True)
return {'success': True, 'marked_count': count}
+54 -60
View File
@@ -1,41 +1,41 @@
"""Support tickets routes for cabinet."""
import logging
import math
from datetime import datetime
from typing import Optional
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from sqlalchemy.orm import selectinload
from app.database.models import User, Ticket, TicketMessage
from app.config import settings
from app.handlers.tickets import notify_admins_about_new_ticket, notify_admins_about_ticket_reply
from app.database.crud.ticket_notification import TicketNotificationCRUD
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 (
TicketResponse,
TicketCreateRequest,
TicketDetailResponse,
TicketListResponse,
TicketMessageResponse,
TicketCreateRequest,
TicketMessageCreateRequest,
TicketMessageResponse,
TicketResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tickets", tags=["Cabinet Tickets"])
logger = structlog.get_logger(__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 "",
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,
@@ -56,9 +56,9 @@ def _ticket_to_response(ticket: Ticket, include_last_message: bool = True) -> Ti
return TicketResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or "normal",
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
@@ -67,11 +67,11 @@ def _ticket_to_response(ticket: Ticket, include_last_message: bool = True) -> Ti
)
@router.get("", response_model=TicketListResponse)
@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: Optional[str] = Query(None, alias="status", description="Filter by status"),
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),
):
@@ -80,15 +80,11 @@ async def get_tickets(
if not settings.is_support_tickets_enabled():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Support tickets are disabled",
detail='Support tickets are disabled',
)
# Base query
query = (
select(Ticket)
.where(Ticket.user_id == user.id)
.options(selectinload(Ticket.messages))
)
query = select(Ticket).where(Ticket.user_id == user.id).options(selectinload(Ticket.messages))
# Filter by status
if status_filter:
@@ -121,7 +117,7 @@ async def get_tickets(
)
@router.post("", response_model=TicketDetailResponse)
@router.post('', response_model=TicketDetailResponse)
async def create_ticket(
request: TicketCreateRequest,
user: User = Depends(get_current_cabinet_user),
@@ -132,17 +128,17 @@ async def create_ticket(
if not settings.is_support_tickets_enabled():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Support tickets are disabled",
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(),
status='open',
priority='normal',
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
db.add(ticket)
await db.flush()
@@ -156,19 +152,19 @@ async def create_ticket(
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
created_at=datetime.utcnow(),
created_at=datetime.now(UTC),
)
db.add(message)
await db.commit()
# Refresh to get relationships
await db.refresh(ticket, ["messages"])
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}")
logger.error('Error notifying admins about new ticket from cabinet', error=e)
# Уведомить админов в кабинете
try:
@@ -177,7 +173,7 @@ async def create_ticket(
# Отправить 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}")
logger.error('Error creating cabinet notification for new ticket', error=e)
messages = [_message_to_response(m) for m in ticket.messages]
@@ -185,16 +181,16 @@ async def create_ticket(
id=ticket.id,
title=ticket.title,
status=ticket.status,
priority=ticket.priority or "normal",
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,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
messages=messages,
)
@router.get("/{ticket_id}", response_model=TicketDetailResponse)
@router.get('/{ticket_id}', response_model=TicketDetailResponse)
async def get_ticket(
ticket_id: int,
user: User = Depends(get_current_cabinet_user),
@@ -202,9 +198,7 @@ async def get_ticket(
):
"""Get ticket with all messages."""
query = (
select(Ticket)
.where(Ticket.id == ticket_id, Ticket.user_id == user.id)
.options(selectinload(Ticket.messages))
select(Ticket).where(Ticket.id == ticket_id, Ticket.user_id == user.id).options(selectinload(Ticket.messages))
)
result = await db.execute(query)
@@ -213,7 +207,7 @@ async def get_ticket(
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
detail='Ticket not found',
)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
@@ -221,18 +215,18 @@ async def get_ticket(
return TicketDetailResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or "normal",
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,
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)
@router.post('/{ticket_id}/messages', response_model=TicketMessageResponse)
async def add_ticket_message(
ticket_id: int,
request: TicketMessageCreateRequest,
@@ -248,21 +242,21 @@ async def add_ticket_message(
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
detail='Ticket not found',
)
# Check if ticket is closed
if ticket.status == "closed":
if ticket.status == 'closed':
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot add message to closed ticket",
detail='Cannot add message to closed ticket',
)
# Check if replies are blocked
if hasattr(ticket, "is_reply_blocked") and ticket.is_reply_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",
detail='Replies to this ticket are blocked',
)
# Create message with optional media
@@ -274,14 +268,14 @@ async def add_ticket_message(
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
created_at=datetime.utcnow(),
created_at=datetime.now(UTC),
)
db.add(message)
# Update ticket status and timestamp
if ticket.status == "answered":
ticket.status = "pending"
ticket.updated_at = datetime.utcnow()
if ticket.status == 'answered':
ticket.status = 'pending'
ticket.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(message)
@@ -290,7 +284,7 @@ async def add_ticket_message(
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}")
logger.error('Error notifying admins about ticket reply from cabinet', error=e)
# Уведомить админов в кабинете
try:
@@ -299,8 +293,8 @@ async def add_ticket_message(
)
if notification:
# Отправить WebSocket уведомление
await notify_admins_ticket_reply(ticket.id, (request.message or "")[:100], user.id)
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}")
logger.error('Error creating cabinet notification for user reply', error=e)
return _message_to_response(message)
+401 -63
View File
@@ -4,17 +4,17 @@ from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Dict, Set
import structlog
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.database.database import AsyncSessionLocal
from app.database.crud.user import get_user_by_id
from app.config import settings
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__)
logger = structlog.get_logger(__name__)
router = APIRouter()
@@ -24,9 +24,9 @@ class CabinetConnectionManager:
def __init__(self):
# user_id -> set of websocket connections
self._user_connections: Dict[int, Set[WebSocket]] = {}
self._user_connections: dict[int, set[WebSocket]] = {}
# admin user_ids -> set of websocket connections
self._admin_connections: Dict[int, Set[WebSocket]] = {}
self._admin_connections: dict[int, set[WebSocket]] = {}
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, user_id: int, is_admin: bool) -> None:
@@ -42,8 +42,10 @@ class CabinetConnectionManager:
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)
'Cabinet WS connected: user_id is_admin total_users',
user_id=user_id,
is_admin=is_admin,
user_connections_count=len(self._user_connections),
)
async def disconnect(self, websocket: WebSocket, user_id: int) -> None:
@@ -59,7 +61,7 @@ class CabinetConnectionManager:
if not self._admin_connections[user_id]:
del self._admin_connections[user_id]
logger.debug("Cabinet WS disconnected: user_id=%d", user_id)
logger.debug('Cabinet WS disconnected: user_id', user_id=user_id)
async def send_to_user(self, user_id: int, message: dict) -> None:
"""Отправить сообщение конкретному пользователю."""
@@ -77,7 +79,7 @@ class CabinetConnectionManager:
try:
await ws.send_text(data)
except Exception as e:
logger.warning("Failed to send to user %d: %s", user_id, e)
logger.warning('Failed to send to user', user_id=user_id, e=e)
disconnected.add(ws)
# Cleanup disconnected
@@ -93,20 +95,17 @@ class CabinetConnectionManager:
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()
]
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]] = {}
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)
logger.warning('Failed to send to admin', user_id=user_id, e=e)
if user_id not in disconnected_by_user:
disconnected_by_user[user_id] = set()
disconnected_by_user[user_id].add(ws)
@@ -133,55 +132,61 @@ async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]:
if not token:
return None, False
payload = get_token_payload(token, expected_type="access")
payload = get_token_payload(token, expected_type='access')
if not payload:
return None, False
try:
user_id = int(payload.get("sub"))
user_id = int(payload.get('sub'))
except (TypeError, ValueError):
return None, False
async with AsyncSessionLocal() as db:
user = await get_user_by_id(db, user_id)
if not user or user.status != "active":
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(user.telegram_id)
return user_id, is_admin
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', e=str(e)[:200])
return None, False
@router.websocket("/ws")
@router.websocket('/ws')
async def cabinet_websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint для real-time уведомлений кабинета."""
client_host = websocket.client.host if websocket.client else "unknown"
client_host = websocket.client.host if websocket.client else 'unknown'
# Получаем токен из query params
token = websocket.query_params.get("token")
token = websocket.query_params.get('token')
if not token:
logger.debug("Cabinet WS: No token from %s", client_host)
logger.debug('Cabinet WS: No token from', client_host=client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason="Unauthorized: No token")
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)
logger.debug('Cabinet WS: Invalid token from', client_host=client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason="Unauthorized: Invalid token")
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)
logger.debug('Cabinet WS accepted: user_id is_admin', user_id=user_id, is_admin=is_admin)
except Exception as e:
logger.error("Cabinet WS: Failed to accept from %s: %s", client_host, e)
logger.error('Cabinet WS: Failed to accept from', client_host=client_host, e=e)
return
# Регистрируем подключение
@@ -189,11 +194,13 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
try:
# Приветственное сообщение
await websocket.send_json({
"type": "connected",
"user_id": user_id,
"is_admin": is_admin,
})
await websocket.send_json(
{
'type': 'connected',
'user_id': user_id,
'is_admin': is_admin,
}
)
# Обрабатываем входящие сообщения
while True:
@@ -202,21 +209,21 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
message = json.loads(data)
# Ping/pong для keepalive
if message.get("type") == "ping":
await websocket.send_json({"type": "pong"})
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)
logger.warning('Cabinet WS: Invalid JSON from user', user_id=user_id)
except WebSocketDisconnect:
break
except Exception as e:
logger.exception("Cabinet WS error for user %d: %s", user_id, e)
logger.exception('Cabinet WS error for user', user_id=user_id, e=e)
break
except WebSocketDisconnect:
logger.debug("Cabinet WS disconnected: user_id=%d", user_id)
logger.debug('Cabinet WS disconnected: user_id', user_id=user_id)
except Exception as e:
logger.exception("Cabinet WS error: %s", e)
logger.exception('Cabinet WS error', e=e)
finally:
await cabinet_ws_manager.disconnect(websocket, user_id)
@@ -224,28 +231,359 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
# Функции для отправки уведомлений (используются из других модулей)
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,
})
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,
})
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,
})
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,
},
)
+69 -63
View File
@@ -1,41 +1,43 @@
"""
API роуты колеса удачи для пользователей.
"""
import logging
import math
import time
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from typing import Optional
from app.config import settings
from app.database.models import User
from app.database.crud.wheel import (
get_or_create_wheel_config,
get_wheel_prizes,
get_user_spins_today,
get_user_spin_history,
)
from app.services.wheel_service import wheel_service
import httpx
import structlog
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 (
WheelConfigResponse,
WheelPrizeDisplay,
SpinAvailabilityResponse,
SpinHistoryItem,
SpinHistoryResponse,
SpinRequest,
SpinResultResponse,
SpinHistoryResponse,
SpinHistoryItem,
WheelConfigResponse,
WheelPrizeDisplay,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/wheel", tags=["Fortune Wheel"])
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
@router.get("/config", response_model=WheelConfigResponse)
logger = structlog.get_logger(__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),
@@ -78,7 +80,7 @@ async def get_wheel_config(
)
@router.get("/availability", response_model=SpinAvailabilityResponse)
@router.get('/availability', response_model=SpinAvailabilityResponse)
async def check_spin_availability(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
@@ -99,7 +101,7 @@ async def check_spin_availability(
)
@router.post("/spin", response_model=SpinResultResponse)
@router.post('/spin', response_model=SpinResultResponse)
async def spin_wheel(
request: SpinRequest,
user: User = Depends(get_current_cabinet_user),
@@ -130,7 +132,7 @@ async def spin_wheel(
)
@router.get("/history", response_model=SpinHistoryResponse)
@router.get('/history', response_model=SpinHistoryResponse)
async def get_spin_history(
page: int = 1,
per_page: int = 20,
@@ -138,8 +140,7 @@ async def get_spin_history(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить историю спинов пользователя."""
if page < 1:
page = 1
page = max(page, 1)
if per_page < 1 or per_page > 100:
per_page = 20
@@ -150,24 +151,26 @@ async def get_spin_history(
items = []
for spin in spins:
# Получаем emoji и color из приза, если он есть
emoji = "🎁"
color = "#3B82F6"
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,
))
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
@@ -182,11 +185,12 @@ async def get_spin_history(
class StarsInvoiceResponse(BaseModel):
"""Ответ с ссылкой на Stars invoice."""
invoice_url: str
stars_amount: int
@router.post("/stars-invoice", response_model=StarsInvoiceResponse)
@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),
@@ -200,13 +204,13 @@ async def create_stars_invoice(
if not config.is_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Колесо удачи недоступно",
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 не включена",
detail='Оплата Stars не включена',
)
# Проверяем лимит спинов
@@ -214,7 +218,7 @@ async def create_stars_invoice(
if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Достигнут дневной лимит спинов",
detail='Достигнут дневной лимит спинов',
)
# Проверяем наличие призов
@@ -222,41 +226,43 @@ async def create_stars_invoice(
if not prizes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Призы не настроены",
detail='Призы не настроены',
)
stars_amount = config.spin_cost_stars
payload = f"wheel_spin_{user.id}_{int(time.time())}"
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"
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}],
'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}")
if not result.get('ok'):
logger.error('Telegram API error', result=result)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Ошибка создания инвойса",
detail='Ошибка создания инвойса',
)
invoice_url = result["result"]
logger.info(f"Created Stars invoice for wheel spin: user={user.id}, stars={stars_amount}")
invoice_url = result['result']
logger.info(
'Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount
)
return StarsInvoiceResponse(
invoice_url=invoice_url,
@@ -264,8 +270,8 @@ async def create_stars_invoice(
)
except httpx.HTTPError as e:
logger.error(f"HTTP error creating invoice: {e}")
logger.error('HTTP error creating invoice', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Ошибка соединения с Telegram",
detail='Ошибка соединения с Telegram',
)
+166
View File
@@ -0,0 +1,166 @@
"""User-facing withdrawal routes for cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User, WithdrawalRequest, WithdrawalRequestStatus
from app.services.referral_withdrawal_service import referral_withdrawal_service
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.withdrawals import (
WithdrawalBalanceResponse,
WithdrawalCreateRequest,
WithdrawalCreateResponse,
WithdrawalItemResponse,
WithdrawalListResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/referral/withdrawal', tags=['Cabinet Withdrawal'])
@router.get('/balance', response_model=WithdrawalBalanceResponse)
async def get_withdrawal_balance(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get withdrawal balance stats for current user."""
can_request, reason, stats = await referral_withdrawal_service.can_request_withdrawal(db, user.id)
return WithdrawalBalanceResponse(
total_earned=stats['total_earned'],
referral_spent=stats['referral_spent'],
withdrawn=stats['withdrawn'],
pending=stats['pending'],
available_referral=stats['available_referral'],
available_total=stats['available_total'],
only_referral_mode=stats['only_referral_mode'],
min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
is_withdrawal_enabled=settings.is_referral_withdrawal_enabled(),
can_request=can_request,
cannot_request_reason=reason if not can_request else None,
requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
)
@router.post('/create', response_model=WithdrawalCreateResponse)
async def create_withdrawal(
request: WithdrawalCreateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a withdrawal request."""
withdrawal, error = await referral_withdrawal_service.create_withdrawal_request(
db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
payment_details=request.payment_details,
)
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Уведомляем админов о запросе на вывод
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_withdrawal_request_notification(
user=user,
amount_kopeks=request.amount_kopeks,
payment_details=request.payment_details,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for withdrawal request', error=e)
return WithdrawalCreateResponse(
id=withdrawal.id,
amount_kopeks=withdrawal.amount_kopeks,
status=withdrawal.status,
)
@router.get('/history', response_model=WithdrawalListResponse)
async def get_withdrawal_history(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's withdrawal request history."""
count_result = await db.execute(
select(func.count()).select_from(WithdrawalRequest).where(WithdrawalRequest.user_id == user.id)
)
total = count_result.scalar() or 0
result = await db.execute(
select(WithdrawalRequest)
.where(WithdrawalRequest.user_id == user.id)
.order_by(desc(WithdrawalRequest.created_at))
.limit(50)
)
requests = result.scalars().all()
items = [
WithdrawalItemResponse(
id=r.id,
amount_kopeks=r.amount_kopeks,
amount_rubles=r.amount_kopeks / 100,
status=r.status,
payment_details=r.payment_details,
admin_comment=r.admin_comment,
created_at=r.created_at,
processed_at=r.processed_at,
)
for r in requests
]
return WithdrawalListResponse(items=items, total=total)
@router.post('/{request_id}/cancel')
async def cancel_withdrawal(
request_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Cancel a pending withdrawal request."""
result = await db.execute(
select(WithdrawalRequest)
.where(
WithdrawalRequest.id == request_id,
WithdrawalRequest.user_id == user.id,
)
.with_for_update()
)
withdrawal = result.scalar_one_or_none()
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Заявка не найдена',
)
if withdrawal.status != WithdrawalRequestStatus.PENDING.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Можно отменить только заявку в ожидании',
)
withdrawal.status = WithdrawalRequestStatus.CANCELLED.value
await db.commit()
return {'success': True}
+58 -57
View File
@@ -1,88 +1,89 @@
"""Cabinet Pydantic schemas."""
from .auth import (
TelegramAuthRequest,
TelegramWidgetAuthRequest,
AuthResponse,
EmailLoginRequest,
EmailRegisterRequest,
EmailVerifyRequest,
EmailLoginRequest,
RefreshTokenRequest,
PasswordForgotRequest,
PasswordResetRequest,
RefreshTokenRequest,
TelegramAuthRequest,
TelegramWidgetAuthRequest,
TokenResponse,
UserResponse,
AuthResponse,
)
from .subscription import (
SubscriptionResponse,
TrafficPurchaseInfo,
RenewalOptionResponse,
RenewalRequest,
TrafficPackageResponse,
TrafficPurchaseRequest,
DevicePurchaseRequest,
AutopayUpdateRequest,
)
from .balance import (
BalanceResponse,
TransactionResponse,
TransactionListResponse,
PaymentMethodResponse,
TopUpRequest,
TopUpResponse,
TransactionListResponse,
TransactionResponse,
)
from .referral import (
ReferralEarningResponse,
ReferralInfoResponse,
ReferralListResponse,
ReferralEarningResponse,
ReferralTermsResponse,
)
from .subscription import (
AutopayUpdateRequest,
DevicePurchaseRequest,
RenewalOptionResponse,
RenewalRequest,
SubscriptionResponse,
TrafficPackageResponse,
TrafficPurchaseInfo,
TrafficPurchaseRequest,
)
from .tickets import (
TicketResponse,
TicketListResponse,
TicketMessageResponse,
TicketCreateRequest,
TicketListResponse,
TicketMessageCreateRequest,
TicketMessageResponse,
TicketResponse,
)
__all__ = [
# Auth
"TelegramAuthRequest",
"TelegramWidgetAuthRequest",
"EmailRegisterRequest",
"EmailVerifyRequest",
"EmailLoginRequest",
"RefreshTokenRequest",
"PasswordForgotRequest",
"PasswordResetRequest",
"TokenResponse",
"UserResponse",
"AuthResponse",
# Subscription
"SubscriptionResponse",
"TrafficPurchaseInfo",
"RenewalOptionResponse",
"RenewalRequest",
"TrafficPackageResponse",
"TrafficPurchaseRequest",
"DevicePurchaseRequest",
"AutopayUpdateRequest",
'AuthResponse',
'AutopayUpdateRequest',
# Balance
"BalanceResponse",
"TransactionResponse",
"TransactionListResponse",
"PaymentMethodResponse",
"TopUpRequest",
"TopUpResponse",
'BalanceResponse',
'DevicePurchaseRequest',
'EmailLoginRequest',
'EmailRegisterRequest',
'EmailVerifyRequest',
'PasswordForgotRequest',
'PasswordResetRequest',
'PaymentMethodResponse',
'ReferralEarningResponse',
# Referral
"ReferralInfoResponse",
"ReferralListResponse",
"ReferralEarningResponse",
"ReferralTermsResponse",
'ReferralInfoResponse',
'ReferralListResponse',
'ReferralTermsResponse',
'RefreshTokenRequest',
'RenewalOptionResponse',
'RenewalRequest',
# Subscription
'SubscriptionResponse',
# Auth
'TelegramAuthRequest',
'TelegramWidgetAuthRequest',
'TicketCreateRequest',
'TicketListResponse',
'TicketMessageCreateRequest',
'TicketMessageResponse',
# Tickets
"TicketResponse",
"TicketListResponse",
"TicketMessageResponse",
"TicketCreateRequest",
"TicketMessageCreateRequest",
'TicketResponse',
'TokenResponse',
'TopUpRequest',
'TopUpResponse',
'TrafficPackageResponse',
'TrafficPurchaseInfo',
'TrafficPurchaseRequest',
'TransactionListResponse',
'TransactionResponse',
'UserResponse',
]
+100 -27
View File
@@ -1,90 +1,163 @@
"""Authentication schemas for cabinet."""
from datetime import datetime
from typing import Optional, Dict, Any
from pydantic import BaseModel, EmailStr, Field
class TelegramAuthRequest(BaseModel):
"""Request for Telegram WebApp initData authentication."""
init_data: str = Field(..., description="Telegram WebApp initData string")
init_data: str = Field(..., description='Telegram WebApp initData string')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
class TelegramWidgetAuthRequest(BaseModel):
"""Request for Telegram Login Widget authentication."""
id: int = Field(..., description="Telegram user ID")
id: int = Field(..., description='Telegram user ID')
first_name: str = Field(..., description="User's first name")
last_name: Optional[str] = Field(None, description="User's last name")
username: Optional[str] = Field(None, description="User's username")
photo_url: Optional[str] = Field(None, description="User's photo URL")
auth_date: int = Field(..., description="Unix timestamp of authentication")
hash: str = Field(..., description="Authentication hash")
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')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
class 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)")
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")
token: str = Field(..., description='Email verification token')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
class EmailLoginRequest(BaseModel):
"""Request to login with email and password."""
email: EmailStr = Field(..., description="Email address")
password: str = Field(..., description="Password")
email: EmailStr = Field(..., description='Email address')
password: str = Field(..., description='Password')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
class RefreshTokenRequest(BaseModel):
"""Request to refresh access token."""
refresh_token: str = Field(..., description="Refresh token")
refresh_token: str = Field(..., description='Refresh token')
class PasswordForgotRequest(BaseModel):
"""Request to initiate password reset."""
email: EmailStr = Field(..., description="Email address")
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)")
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")
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
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
email: Optional[str] = None
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: Optional[str] = None
language: str = "ru"
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 CampaignBonusInfo(BaseModel):
"""Info about campaign bonus applied during auth."""
campaign_name: str
bonus_type: str
balance_kopeks: int = 0
subscription_days: int | None = None
tariff_name: str | None = None
class AuthResponse(BaseModel):
"""Full authentication response with tokens and user."""
access_token: str
refresh_token: str
token_type: str = "bearer"
token_type: str = 'bearer'
expires_in: int
user: UserResponse
campaign_bonus: CampaignBonusInfo | None = None
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')
+35 -22
View File
@@ -1,27 +1,30 @@
"""Balance and payment schemas for cabinet."""
from datetime import datetime
from typing import Optional, List, Dict, Any
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: Optional[str] = None
payment_method: Optional[str] = None
description: str | None = None
payment_method: str | None = None
is_completed: bool
created_at: datetime
completed_at: Optional[datetime] = None
completed_at: datetime | None = None
class Config:
from_attributes = True
@@ -29,7 +32,8 @@ class TransactionResponse(BaseModel):
class TransactionListResponse(BaseModel):
"""Paginated transaction list."""
items: List[TransactionResponse]
items: list[TransactionResponse]
total: int
page: int
per_page: int
@@ -38,46 +42,52 @@ class TransactionListResponse(BaseModel):
class PaymentOptionResponse(BaseModel):
"""Payment method option (e.g. Platega sub-methods)."""
id: str
name: str
description: Optional[str] = None
description: str | None = None
class PaymentMethodResponse(BaseModel):
"""Available payment method."""
id: str
name: str
description: Optional[str] = None
description: str | None = None
min_amount_kopeks: int
max_amount_kopeks: int
is_available: bool = True
options: Optional[List[Dict[str, Any]]] = None
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: Optional[str] = Field(None, description="Payment option (e.g. Platega method code)")
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: Optional[datetime] = None
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)")
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
@@ -85,6 +95,7 @@ class StarsInvoiceResponse(BaseModel):
class PendingPaymentResponse(BaseModel):
"""Pending payment details for manual verification."""
id: int
method: str
method_display: str
@@ -97,11 +108,11 @@ class PendingPaymentResponse(BaseModel):
is_paid: bool
is_checkable: bool
created_at: datetime
expires_at: Optional[datetime] = None
payment_url: Optional[str] = None
user_id: Optional[int] = None
user_telegram_id: Optional[int] = None
user_username: Optional[str] = None
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
@@ -109,7 +120,8 @@ class PendingPaymentResponse(BaseModel):
class PendingPaymentListResponse(BaseModel):
"""Paginated list of pending payments."""
items: List[PendingPaymentResponse]
items: list[PendingPaymentResponse]
total: int
page: int
per_page: int
@@ -118,9 +130,10 @@ class PendingPaymentListResponse(BaseModel):
class ManualCheckResponse(BaseModel):
"""Response after manual payment status check."""
success: bool
message: str
payment: Optional[PendingPaymentResponse] = None
payment: PendingPaymentResponse | None = None
status_changed: bool = False
old_status: Optional[str] = None
new_status: Optional[str] = None
old_status: str | None = None
new_status: str | None = None
+110 -64
View File
@@ -1,22 +1,27 @@
"""Schemas for Ban System integration in cabinet."""
from datetime import datetime
from typing import List, Optional, Dict, Any
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
@@ -28,48 +33,53 @@ class BanSystemStatsResponse(BaseModel):
agents_online: int = 0
agents_total: int = 0
panel_connected: bool = False
uptime_seconds: Optional[int] = None
uptime_seconds: int | None = None
# === Users ===
class BanUserIPInfo(BaseModel):
"""User IP address information."""
ip: str
first_seen: Optional[datetime] = None
last_seen: Optional[datetime] = None
node: Optional[str] = None
first_seen: datetime | None = None
last_seen: datetime | None = None
node: str | None = None
request_count: int = 0
country_code: Optional[str] = None
country_name: Optional[str] = None
city: Optional[str] = None
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: Optional[str] = None
dest_port: Optional[int] = None
protocol: Optional[str] = None
action: Optional[str] = None
node: Optional[str] = None
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: Optional[int] = None
limit: int | None = None
is_over_limit: bool = False
blocked_count: int = 0
last_seen: Optional[datetime] = None
last_seen: datetime | None = None
class BanUsersListResponse(BaseModel):
"""Paginated list of users."""
users: List[BanUserListItem] = []
users: list[BanUserListItem] = []
total: int = 0
offset: int = 0
limit: int = 50
@@ -77,83 +87,95 @@ class BanUsersListResponse(BaseModel):
class BanUserDetailResponse(BaseModel):
"""Detailed user information."""
email: str
unique_ip_count: int = 0
total_requests: int = 0
limit: Optional[int] = None
limit: int | None = None
is_over_limit: bool = False
blocked_count: int = 0
ips: List[BanUserIPInfo] = []
recent_requests: List[BanUserRequestLog] = []
network_type: Optional[str] = None # wifi, mobile, mixed
ips: list[BanUserIPInfo] = []
recent_requests: list[BanUserRequestLog] = []
network_type: str | None = None # wifi, mobile, mixed
# === Punishments (Bans) ===
class BanPunishmentItem(BaseModel):
"""Punishment/ban entry."""
id: Optional[int] = None
id: int | None = None
user_id: str
uuid: Optional[str] = None
uuid: str | None = None
username: str
reason: Optional[str] = None
reason: str | None = None
punished_at: datetime
enable_at: Optional[datetime] = None
enable_at: datetime | None = None
ip_count: int = 0
limit: int = 0
enabled: bool = False
enabled_at: Optional[datetime] = None
node_name: Optional[str] = None
enabled_at: datetime | None = None
node_name: str | None = None
class BanPunishmentsListResponse(BaseModel):
"""List of active punishments."""
punishments: List[BanPunishmentItem] = []
punishments: list[BanPunishmentItem] = []
total: int = 0
class BanHistoryResponse(BaseModel):
"""Punishment history."""
items: List[BanPunishmentItem] = []
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: Optional[str] = Field(None, max_length=500)
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: Optional[str] = None
address: str | None = None
is_connected: bool = False
last_seen: Optional[datetime] = None
last_seen: datetime | None = None
users_count: int = 0
agent_stats: Optional[Dict[str, Any]] = None
agent_stats: dict[str, Any] | None = None
class BanNodesListResponse(BaseModel):
"""List of nodes."""
nodes: List[BanNodeItem] = []
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
@@ -166,13 +188,14 @@ class BanAgentItem(BaseModel):
dedup_skipped: int = 0
filter_checked: int = 0
filter_filtered: int = 0
health: str = "unknown" # healthy, warning, critical
health: str = 'unknown' # healthy, warning, critical
is_online: bool = False
last_report: Optional[datetime] = None
last_report: datetime | None = None
class BanAgentsSummary(BaseModel):
"""Agents summary statistics."""
total_agents: int = 0
online_agents: int = 0
total_sent: int = 0
@@ -185,16 +208,19 @@ class BanAgentsSummary(BaseModel):
class BanAgentsListResponse(BaseModel):
"""List of agents."""
agents: List[BanAgentItem] = []
summary: Optional[BanAgentsSummary] = None
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
@@ -204,22 +230,24 @@ class BanTrafficStats(BaseModel):
class BanTrafficUserItem(BaseModel):
"""User traffic information."""
username: str
email: Optional[str] = None
email: str | None = None
total_bytes: int = 0
upload_bytes: int = 0
download_bytes: int = 0
limit_bytes: Optional[int] = None
limit_bytes: int | None = None
is_over_limit: bool = False
class BanTrafficViolationItem(BaseModel):
"""Traffic limit violation entry."""
id: Optional[int] = None
id: int | None = None
username: str
email: Optional[str] = None
email: str | None = None
violation_type: str
description: Optional[str] = None
description: str | None = None
bytes_used: int = 0
bytes_limit: int = 0
detected_at: datetime
@@ -228,100 +256,117 @@ class BanTrafficViolationItem(BaseModel):
class BanTrafficViolationsResponse(BaseModel):
"""List of traffic violations."""
violations: List[BanTrafficViolationItem] = []
violations: list[BanTrafficViolationItem] = []
total: int = 0
class BanTrafficTopItem(BaseModel):
"""Top user by traffic."""
username: str
bytes_total: int = 0
bytes_limit: Optional[int] = None
bytes_limit: int | None = None
over_limit: bool = False
class BanTrafficResponse(BaseModel):
"""Full traffic statistics response."""
enabled: bool = False
stats: Optional[Dict[str, Any]] = None
top_users: List[BanTrafficTopItem] = []
recent_violations: List[BanTrafficViolationItem] = []
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: Optional[int] = None
max_value: Optional[int] = None
min_value: int | None = None
max_value: int | None = None
editable: bool = True
description: Optional[str] = None
category: Optional[str] = None
description: str | None = None
category: str | None = None
class BanSettingsResponse(BaseModel):
"""All settings response."""
settings: List[BanSettingDefinition] = []
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: Optional[Dict[str, Any]] = None
top_violators: List[BanReportTopViolator] = []
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: Optional[str] = None
details: Optional[Dict[str, Any]] = None
message: str | None = None
details: dict[str, Any] | None = None
class BanHealthResponse(BaseModel):
"""Health status response."""
status: str # healthy, degraded, unhealthy
uptime: Optional[int] = None
components: List[BanHealthComponent] = []
uptime: int | None = None
components: list[BanHealthComponent] = []
class BanHealthDetailedResponse(BaseModel):
"""Detailed health response."""
status: str
uptime: Optional[int] = None
components: Dict[str, Any] = {}
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
@@ -331,10 +376,11 @@ class BanAgentHistoryItem(BaseModel):
class BanAgentHistoryResponse(BaseModel):
"""Agent history response."""
node: str
hours: int = 24
records: int = 0
delta: Optional[Dict[str, Any]] = None
first: Optional[Dict[str, Any]] = None
last: Optional[Dict[str, Any]] = None
history: List[BanAgentHistoryItem] = []
delta: dict[str, Any] | None = None
first: dict[str, Any] | None = None
last: dict[str, Any] | None = None
history: list[BanAgentHistoryItem] = []
+105 -20
View File
@@ -1,23 +1,31 @@
"""Pydantic schemas for cabinet broadcasts."""
from datetime import datetime
from typing import List, Optional
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: Optional[int] = None
group: Optional[str] = None # basic, subscription, traffic, registration, source, activity
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
@@ -26,15 +34,18 @@ class TariffFilter(BaseModel):
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
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}
@@ -43,13 +54,16 @@ class TariffForBroadcast(BaseModel):
class BroadcastTariffsResponse(BaseModel):
"""Response with tariffs for filtering."""
tariffs: List[TariffForBroadcast]
tariffs: list[TariffForBroadcast]
# ============ Buttons ============
class BroadcastButton(BaseModel):
"""Single broadcast button."""
key: str
label: str
default: bool = False
@@ -57,56 +71,70 @@ class BroadcastButton(BaseModel):
class BroadcastButtonsResponse(BaseModel):
"""Response with available buttons."""
buttons: List[BroadcastButton]
buttons: list[BroadcastButton]
# ============ Media ============
class BroadcastMediaRequest(BaseModel):
"""Media attachment for broadcast."""
type: str = Field(..., pattern=r"^(photo|video|document)$")
type: str = Field(..., pattern=r'^(photo|video|document)$')
file_id: str
caption: Optional[str] = None
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: Optional[BroadcastMediaRequest] = None
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
message_text: str | None = None
has_media: bool
media_type: Optional[str] = None
media_file_id: Optional[str] = None
media_caption: Optional[str] = None
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
blocked_count: int = 0
status: str # queued|in_progress|completed|partial|failed|cancelled|cancelling
admin_id: Optional[int] = None
admin_name: Optional[str] = None
admin_id: int | None = None
admin_name: str | None = None
created_at: datetime
completed_at: Optional[datetime] = None
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]
items: list[BroadcastResponse]
total: int
limit: int
offset: int
@@ -114,12 +142,69 @@ class BroadcastListResponse(BaseModel):
# ============ 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
+73 -41
View File
@@ -1,21 +1,24 @@
"""Schemas for advertising campaigns management in cabinet."""
from datetime import datetime
from typing import List, Optional, Literal
from typing import Literal
from pydantic import BaseModel, Field
CampaignBonusType = Literal["balance", "subscription", "none", "tariff"]
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
@@ -24,6 +27,8 @@ class CampaignListItem(BaseModel):
registrations_count: int
total_revenue_kopeks: int = 0
conversion_rate: float = 0.0
partner_user_id: int | None = None
partner_name: str | None = None
created_at: datetime
class Config:
@@ -32,12 +37,14 @@ class CampaignListItem(BaseModel):
class CampaignListResponse(BaseModel):
"""Response with list of campaigns."""
campaigns: List[CampaignListItem]
campaigns: list[CampaignListItem]
total: int
class CampaignDetailResponse(BaseModel):
"""Detailed campaign response."""
id: int
name: str
start_parameter: str
@@ -47,20 +54,24 @@ class CampaignDetailResponse(BaseModel):
balance_bonus_kopeks: int = 0
balance_bonus_rubles: float = 0.0
# Subscription bonus
subscription_duration_days: Optional[int] = None
subscription_traffic_gb: Optional[int] = None
subscription_device_limit: Optional[int] = None
subscription_squads: List[str] = Field(default_factory=list)
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: Optional[int] = None
tariff_duration_days: Optional[int] = None
tariff: Optional[TariffInfo] = None
tariff_id: int | None = None
tariff_duration_days: int | None = None
tariff: TariffInfo | None = None
# Partner
partner_user_id: int | None = None
partner_name: str | None = None
# Meta
created_by: Optional[int] = None
created_by: int | None = None
created_at: datetime
updated_at: Optional[datetime] = None
updated_at: datetime | None = None
# Deep link
deep_link: Optional[str] = None
deep_link: str | None = None
web_link: str | None = None
class Config:
from_attributes = True
@@ -68,42 +79,49 @@ class CampaignDetailResponse(BaseModel):
class CampaignCreateRequest(BaseModel):
"""Request to create a campaign."""
name: str = Field(..., min_length=1, max_length=255)
start_parameter: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-zA-Z0-9_-]+$")
start_parameter: str = Field(..., min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$')
bonus_type: CampaignBonusType
is_active: bool = True
# Balance bonus
balance_bonus_kopeks: int = Field(0, ge=0)
# Subscription bonus
subscription_duration_days: Optional[int] = Field(None, ge=1)
subscription_traffic_gb: Optional[int] = Field(None, ge=0)
subscription_device_limit: Optional[int] = Field(None, ge=1)
subscription_squads: List[str] = Field(default_factory=list)
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: Optional[int] = None
tariff_duration_days: Optional[int] = Field(None, ge=1)
tariff_id: int | None = None
tariff_duration_days: int | None = Field(None, ge=1)
# Partner
partner_user_id: int | None = None
class CampaignUpdateRequest(BaseModel):
"""Request to update a campaign."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
start_parameter: Optional[str] = Field(None, min_length=1, max_length=100, pattern=r"^[a-zA-Z0-9_-]+$")
bonus_type: Optional[CampaignBonusType] = None
is_active: Optional[bool] = None
name: str | None = Field(None, min_length=1, max_length=255)
start_parameter: str | None = Field(None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$')
bonus_type: CampaignBonusType | None = None
is_active: bool | None = None
# Balance bonus
balance_bonus_kopeks: Optional[int] = Field(None, ge=0)
balance_bonus_kopeks: int | None = Field(None, ge=0)
# Subscription bonus
subscription_duration_days: Optional[int] = Field(None, ge=1)
subscription_traffic_gb: Optional[int] = Field(None, ge=0)
subscription_device_limit: Optional[int] = Field(None, ge=1)
subscription_squads: Optional[List[str]] = None
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: Optional[int] = None
tariff_duration_days: Optional[int] = Field(None, ge=1)
tariff_id: int | None = None
tariff_duration_days: int | None = Field(None, ge=1)
# Partner
partner_user_id: int | None = None
class CampaignToggleResponse(BaseModel):
"""Response after toggling campaign."""
id: int
is_active: bool
message: str
@@ -111,6 +129,7 @@ class CampaignToggleResponse(BaseModel):
class CampaignStatisticsResponse(BaseModel):
"""Detailed campaign statistics."""
id: int
name: str
start_parameter: str
@@ -121,7 +140,7 @@ class CampaignStatisticsResponse(BaseModel):
balance_issued_kopeks: int = 0
balance_issued_rubles: float = 0.0
subscription_issued: int = 0
last_registration: Optional[datetime] = None
last_registration: datetime | None = None
# Revenue stats
total_revenue_kopeks: int = 0
total_revenue_rubles: float = 0.0
@@ -137,21 +156,23 @@ class CampaignStatisticsResponse(BaseModel):
conversion_rate: float = 0.0
trial_conversion_rate: float = 0.0
# Deep link
deep_link: Optional[str] = None
deep_link: str | None = None
web_link: str | None = None
class CampaignRegistrationItem(BaseModel):
"""Campaign registration item."""
id: int
user_id: int
telegram_id: int
username: Optional[str] = None
first_name: Optional[str] = None
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: Optional[int] = None
tariff_id: Optional[int] = None
tariff_duration_days: Optional[int] = None
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
@@ -164,7 +185,8 @@ class CampaignRegistrationItem(BaseModel):
class CampaignRegistrationsResponse(BaseModel):
"""Response with campaign registrations."""
registrations: List[CampaignRegistrationItem]
registrations: list[CampaignRegistrationItem]
total: int
page: int
per_page: int
@@ -172,6 +194,7 @@ class CampaignRegistrationsResponse(BaseModel):
class CampaignsOverviewResponse(BaseModel):
"""Overview of all campaigns."""
total: int
active: int
inactive: int
@@ -182,9 +205,18 @@ class CampaignsOverviewResponse(BaseModel):
total_tariff_issued: int = 0
class AvailablePartnerItem(BaseModel):
"""Partner item for campaign partner selector."""
user_id: int
username: str | None = None
first_name: str | None = None
class ServerSquadInfo(BaseModel):
"""Server squad info for campaign selection."""
id: int
squad_uuid: str
display_name: str
country_code: Optional[str] = None
country_code: str | None = None
+162
View File
@@ -0,0 +1,162 @@
"""Partner system schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
# ==================== User-facing ====================
class PartnerApplicationRequest(BaseModel):
"""Request to apply for partner status."""
company_name: str | None = Field(None, max_length=255)
website_url: str | None = Field(None, max_length=500)
telegram_channel: str | None = Field(None, max_length=255)
description: str | None = Field(None, max_length=2000)
expected_monthly_referrals: int | None = Field(None, ge=0)
class PartnerApplicationInfo(BaseModel):
"""Application info for the user."""
id: int
status: str
company_name: str | None = None
website_url: str | None = None
telegram_channel: str | None = None
description: str | None = None
expected_monthly_referrals: int | None = None
admin_comment: str | None = None
approved_commission_percent: int | None = None
created_at: datetime
processed_at: datetime | None = None
class Config:
from_attributes = True
class PartnerCampaignInfo(BaseModel):
"""Campaign info visible to the partner."""
id: int
name: str
start_parameter: str
bonus_type: str
balance_bonus_kopeks: int = 0
subscription_duration_days: int | None = None
subscription_traffic_gb: int | None = None
deep_link: str | None = None
web_link: str | None = None
class PartnerStatusResponse(BaseModel):
"""Partner status for current user."""
partner_status: str
commission_percent: int | None = None
latest_application: PartnerApplicationInfo | None = None
campaigns: list[PartnerCampaignInfo] = []
# ==================== Admin-facing ====================
class AdminPartnerApplicationItem(BaseModel):
"""Partner application in admin list."""
id: int
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
company_name: str | None = None
website_url: str | None = None
telegram_channel: str | None = None
description: str | None = None
expected_monthly_referrals: int | None = None
status: str
admin_comment: str | None = None
approved_commission_percent: int | None = None
created_at: datetime
processed_at: datetime | None = None
class AdminPartnerApplicationsResponse(BaseModel):
"""List of partner applications."""
items: list[AdminPartnerApplicationItem]
total: int
class AdminApproveRequest(BaseModel):
"""Request to approve a partner application."""
commission_percent: int = Field(..., ge=1, le=100)
comment: str | None = Field(None, max_length=2000)
class AdminRejectRequest(BaseModel):
"""Request to reject a partner application."""
comment: str | None = Field(None, max_length=2000)
class AdminPartnerItem(BaseModel):
"""Partner in admin list."""
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
commission_percent: int | None = None
total_referrals: int = 0
total_earnings_kopeks: int = 0
balance_kopeks: int = 0
partner_status: str
created_at: datetime
class AdminPartnerListResponse(BaseModel):
"""List of partners for admin."""
items: list[AdminPartnerItem]
total: int
class CampaignSummary(BaseModel):
"""Campaign summary for partner detail."""
id: int
name: str
start_parameter: str
is_active: bool
class AdminPartnerDetailResponse(BaseModel):
"""Detailed partner info for admin."""
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
commission_percent: int | None = None
partner_status: str
balance_kopeks: int = 0
total_referrals: int = 0
paid_referrals: int = 0
active_referrals: int = 0
earnings_all_time: int = 0
earnings_today: int = 0
earnings_week: int = 0
earnings_month: int = 0
conversion_to_paid: float = 0.0
campaigns: list[CampaignSummary] = []
created_at: datetime
class AdminUpdateCommissionRequest(BaseModel):
"""Request to update partner commission."""
commission_percent: int = Field(..., ge=1, le=100)
+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
+15 -7
View File
@@ -1,12 +1,13 @@
"""Referral program schemas for cabinet."""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel
class ReferralInfoResponse(BaseModel):
"""Referral program info for current user."""
referral_code: str
referral_link: str
total_referrals: int
@@ -18,9 +19,10 @@ class ReferralInfoResponse(BaseModel):
class ReferralItemResponse(BaseModel):
"""Single referral info."""
id: int
username: Optional[str] = None
first_name: Optional[str] = None
username: str | None = None
first_name: str | None = None
created_at: datetime
has_subscription: bool
has_paid: bool
@@ -28,7 +30,8 @@ class ReferralItemResponse(BaseModel):
class ReferralListResponse(BaseModel):
"""Paginated referral list."""
items: List[ReferralItemResponse]
items: list[ReferralItemResponse]
total: int
page: int
per_page: int
@@ -37,12 +40,14 @@ class ReferralListResponse(BaseModel):
class ReferralEarningResponse(BaseModel):
"""Referral earning history item."""
id: int
amount_kopeks: int
amount_rubles: float
reason: str
referral_username: Optional[str] = None
referral_first_name: Optional[str] = None
referral_username: str | None = None
referral_first_name: str | None = None
campaign_name: str | None = None
created_at: datetime
class Config:
@@ -51,7 +56,8 @@ class ReferralEarningResponse(BaseModel):
class ReferralEarningsListResponse(BaseModel):
"""Paginated referral earnings list."""
items: List[ReferralEarningResponse]
items: list[ReferralEarningResponse]
total: int
total_amount_kopeks: int
total_amount_rubles: float
@@ -62,6 +68,7 @@ class ReferralEarningsListResponse(BaseModel):
class ReferralTermsResponse(BaseModel):
"""Referral program terms."""
is_enabled: bool
commission_percent: int
minimum_topup_kopeks: int
@@ -70,3 +77,4 @@ class ReferralTermsResponse(BaseModel):
first_topup_bonus_rubles: float
inviter_bonus_kopeks: int
inviter_bonus_rubles: float
partner_section_visible: bool = True
+137 -93
View File
@@ -1,33 +1,38 @@
"""Schemas for RemnaWave management in cabinet admin panel."""
from datetime import datetime, time
from typing import Any, Dict, List, Literal, Optional
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: Optional[str] = None
status_code: Optional[int] = None
system_info: Optional[Dict[str, Any]] = None
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: Optional[str] = None
connection: Optional[ConnectionStatus] = None
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
@@ -40,6 +45,7 @@ class SystemSummary(BaseModel):
class ServerInfo(BaseModel):
"""Server hardware info."""
cpu_cores: int
cpu_physical_cores: int
memory_total: int
@@ -51,6 +57,7 @@ class ServerInfo(BaseModel):
class Bandwidth(BaseModel):
"""Realtime bandwidth statistics."""
realtime_download: int
realtime_upload: int
realtime_total: int
@@ -58,13 +65,15 @@ class Bandwidth(BaseModel):
class TrafficPeriod(BaseModel):
"""Traffic statistics for a period."""
current: int
previous: int
difference: Optional[str] = None
difference: str | None = None
class TrafficPeriods(BaseModel):
"""Traffic statistics for multiple periods."""
last_2_days: TrafficPeriod
last_7_days: TrafficPeriod
last_30_days: TrafficPeriod
@@ -74,190 +83,212 @@ class TrafficPeriods(BaseModel):
class SystemStatsResponse(BaseModel):
"""Full system statistics response."""
system: SystemSummary
users_by_status: Dict[str, int]
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: Optional[datetime] = None
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: Optional[str] = None
country_code: str | None = None
is_connected: bool
is_disabled: bool
is_node_online: bool
is_xray_running: bool
users_online: Optional[int] = None
traffic_used_bytes: Optional[int] = None
traffic_limit_bytes: Optional[int] = None
last_status_change: Optional[datetime] = None
last_status_message: Optional[str] = None
xray_uptime: Optional[str] = None
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: Optional[int] = None
notify_percent: Optional[int] = None
traffic_reset_day: int | None = None
notify_percent: int | None = None
consumption_multiplier: float = 1.0
cpu_count: Optional[int] = None
cpu_model: Optional[str] = None
total_ram: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
provider_uuid: Optional[str] = None
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]
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]
nodes: list[NodeInfo]
class NodeStatisticsResponse(BaseModel):
"""Node statistics with usage history."""
node: NodeInfo
realtime: Optional[Dict[str, Any]] = None
usage_history: List[Dict[str, Any]] = Field(default_factory=list)
last_updated: Optional[datetime] = None
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)
items: list[dict[str, Any]] = Field(default_factory=list)
class NodeActionRequest(BaseModel):
"""Request to perform node action."""
action: Literal["enable", "disable", "restart"]
action: Literal['enable', 'disable', 'restart']
class NodeActionResponse(BaseModel):
"""Response after node action."""
success: bool
message: Optional[str] = None
is_disabled: Optional[bool] = None
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)
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)
inbounds: list[dict[str, Any]] = Field(default_factory=list)
# Local DB info
local_id: Optional[int] = None
display_name: Optional[str] = None
country_code: Optional[str] = None
is_available: Optional[bool] = None
is_trial_eligible: Optional[bool] = None
price_kopeks: Optional[int] = None
max_users: Optional[int] = None
current_users: Optional[int] = None
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]
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)
inbounds: list[dict[str, Any]] = Field(default_factory=list)
# Local DB info if synced
local_id: Optional[int] = None
display_name: Optional[str] = None
country_code: Optional[str] = None
description: Optional[str] = None
is_available: Optional[bool] = None
is_trial_eligible: Optional[bool] = None
price_kopeks: Optional[int] = None
max_users: Optional[int] = None
current_users: Optional[int] = None
sort_order: Optional[int] = None
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)
inbound_uuids: list[str] = Field(default_factory=list)
class SquadUpdateRequest(BaseModel):
"""Request to update a squad."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
inbound_uuids: Optional[List[str]] = None
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: Optional[str] = None
inbound_uuids: Optional[List[str]] = None
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: Optional[str] = None
data: Optional[Dict[str, Any]] = None
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: Optional[int] = None
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
@@ -270,83 +301,96 @@ class MigrationStats(BaseModel):
class MigrationResponse(BaseModel):
"""Response after migration."""
success: bool
message: Optional[str] = None
error: Optional[str] = None
data: Optional[MigrationStats] = None
message: str | None = None
error: str | None = None
data: MigrationStats | None = None
# ============ Inbounds ============
class InboundInfo(BaseModel):
"""Inbound information."""
uuid: str
tag: str
type: Optional[str] = None
network: Optional[str] = None
security: Optional[str] = None
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)
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: Optional[datetime] = None
times: list[str] = Field(default_factory=list) # HH:MM format
next_run: datetime | None = None
is_running: bool = False
last_run_started_at: Optional[datetime] = None
last_run_finished_at: Optional[datetime] = None
last_run_success: Optional[bool] = None
last_run_reason: Optional[str] = None
last_run_error: Optional[str] = None
last_user_stats: Optional[Dict[str, Any]] = None
last_server_stats: Optional[Dict[str, Any]] = None
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: Optional[bool] = None
error: Optional[str] = None
user_stats: Optional[Dict[str, Any]] = None
server_stats: Optional[Dict[str, Any]] = None
reason: Optional[str] = None
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"
mode: Literal['all', 'new_only', 'update_only'] = 'all'
class SyncResponse(BaseModel):
"""Response after sync operation."""
success: bool
message: Optional[str] = None
data: Optional[Dict[str, Any]] = None
message: str | None = None
data: dict[str, Any] | None = None
class SyncRecommendations(BaseModel):
"""Sync recommendations."""
success: bool
message: Optional[str] = None
data: Optional[Dict[str, Any]] = None
message: str | None = None
data: dict[str, Any] | None = None
+33 -23
View File
@@ -1,12 +1,13 @@
"""Schemas for server management in cabinet."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class PromoGroupInfo(BaseModel):
"""Promo group info for server."""
id: int
name: str
is_selected: bool = False
@@ -14,16 +15,17 @@ class PromoGroupInfo(BaseModel):
class ServerListItem(BaseModel):
"""Server item for list view."""
id: int
squad_uuid: str
display_name: str
original_name: Optional[str] = None
country_code: Optional[str] = None
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: Optional[int] = None
max_users: int | None = None
current_users: int
sort_order: int
is_full: bool
@@ -36,32 +38,34 @@ class ServerListItem(BaseModel):
class ServerListResponse(BaseModel):
"""Response with list of servers."""
servers: List[ServerListItem]
servers: list[ServerListItem]
total: int
class ServerDetailResponse(BaseModel):
"""Detailed server response."""
id: int
squad_uuid: str
display_name: str
original_name: Optional[str] = None
country_code: Optional[str] = None
description: Optional[str] = None
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: Optional[int] = None
max_users: int | None = None
current_users: int
sort_order: int
is_full: bool
availability_status: str
promo_groups: List[PromoGroupInfo]
promo_groups: list[PromoGroupInfo]
active_subscriptions: int
tariffs_using: List[str] # Names of tariffs using this server
tariffs_using: list[str] # Names of tariffs using this server
created_at: datetime
updated_at: Optional[datetime] = None
updated_at: datetime | None = None
class Config:
from_attributes = True
@@ -69,19 +73,21 @@ class ServerDetailResponse(BaseModel):
class ServerUpdateRequest(BaseModel):
"""Request to update a server."""
display_name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
country_code: Optional[str] = Field(None, max_length=5)
is_available: Optional[bool] = None
is_trial_eligible: Optional[bool] = None
price_kopeks: Optional[int] = Field(None, ge=0)
max_users: Optional[int] = Field(None, ge=0)
sort_order: Optional[int] = Field(None, ge=0)
promo_group_ids: Optional[List[int]] = None
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
@@ -89,6 +95,7 @@ class ServerToggleResponse(BaseModel):
class ServerTrialToggleResponse(BaseModel):
"""Response after toggling trial eligibility."""
id: int
is_trial_eligible: bool
message: str
@@ -96,18 +103,20 @@ class ServerTrialToggleResponse(BaseModel):
class ServerStatsResponse(BaseModel):
"""Server statistics."""
id: int
display_name: str
squad_uuid: str
current_users: int
max_users: Optional[int]
max_users: int | None
active_subscriptions: int
trial_subscriptions: int
usage_percent: Optional[float] = None
usage_percent: float | None = None
class ServerSyncResponse(BaseModel):
"""Response after syncing with RemnaWave."""
created: int
updated: int
removed: int
@@ -116,4 +125,5 @@ class ServerSyncResponse(BaseModel):
class ServerSyncRequest(BaseModel):
"""Request to sync servers."""
force: bool = False # Force sync even if recently synced
+53 -26
View File
@@ -1,19 +1,21 @@
"""Subscription schemas for cabinet."""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field
class ServerInfo(BaseModel):
"""Server info for display."""
uuid: str
name: str
country_code: Optional[str] = None
country_code: str | None = None
class TrafficPurchaseInfo(BaseModel):
"""Purchased traffic package info."""
id: int
traffic_gb: int
expires_at: datetime
@@ -22,8 +24,9 @@ class TrafficPurchaseInfo(BaseModel):
progress_percent: float
class SubscriptionResponse(BaseModel):
class SubscriptionData(BaseModel):
"""User subscription data."""
id: int
status: str
is_trial: bool
@@ -32,48 +35,63 @@ class SubscriptionResponse(BaseModel):
days_left: int
hours_left: int = 0
minutes_left: int = 0
time_left_display: str = "" # Human readable format like "2д 5ч" or "5ч 30м"
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
connected_squads: list[str] = []
servers: list[ServerInfo] = [] # Server display info
autopay_enabled: bool
autopay_days_before: int
subscription_url: Optional[str] = None
subscription_url: str | None = None
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
is_active: bool
is_expired: bool
traffic_purchases: List[TrafficPurchaseInfo] = []
traffic_purchases: list[TrafficPurchaseInfo] = []
# Daily tariff fields
is_daily: bool = False
is_daily_paused: bool = False
daily_price_kopeks: Optional[int] = None
next_daily_charge_at: Optional[datetime] = None # When next daily charge will happen
tariff_id: Optional[int] = None
tariff_name: Optional[str] = None
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
traffic_reset_mode: 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: Optional[int] = None
original_price_kopeks: int | None = None
class RenewalRequest(BaseModel):
"""Request to renew subscription."""
period_days: int = Field(..., description="Renewal period in days")
period_days: int = Field(..., description='Renewal period in days')
class TrafficPackageResponse(BaseModel):
"""Available traffic package."""
gb: int
price_kopeks: int
price_rubles: float
@@ -82,22 +100,26 @@ class TrafficPackageResponse(BaseModel):
class TrafficPurchaseRequest(BaseModel):
"""Request to purchase additional traffic."""
gb: int = Field(..., ge=0, description="GB to purchase (0 = unlimited)")
gb: int = Field(..., ge=0, description='GB to purchase (0 = unlimited)')
class DevicePurchaseRequest(BaseModel):
"""Request to purchase additional device slots."""
devices: int = Field(..., ge=1, description="Number of additional devices")
devices: int = Field(..., ge=1, description='Number of additional devices')
class AutopayUpdateRequest(BaseModel):
"""Request to update autopay settings."""
enabled: bool
days_before: Optional[int] = Field(None, ge=1, le=30, description="Days before expiration to charge")
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
@@ -105,29 +127,34 @@ class TrialInfoResponse(BaseModel):
requires_payment: bool = False
price_kopeks: int = 0
price_rubles: float = 0.0
reason_unavailable: Optional[str] = None
reason_unavailable: str | None = None
# ============ Purchase Options Schemas ============
class PurchaseSelectionRequest(BaseModel):
"""User's selection for subscription purchase."""
period_id: Optional[str] = Field(None, description="Period ID like 'days:30'")
period_days: Optional[int] = Field(None, description="Period in days")
traffic_value: Optional[int] = Field(None, description="Traffic in GB (0 = unlimited)")
servers: Optional[List[str]] = Field(default_factory=list, description="Server UUIDs")
devices: Optional[int] = Field(None, description="Device limit")
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: Optional[int] = Field(None, ge=0, description="Custom traffic in GB (for custom_traffic_enabled tariffs)")
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)')
+78 -58
View File
@@ -1,15 +1,16 @@
"""Schemas for tariff management in cabinet."""
from datetime import datetime
from typing import List, Optional, Dict
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: Optional[float] = None
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)
@@ -19,21 +20,24 @@ class PeriodPrice(BaseModel):
class ServerTrafficLimit(BaseModel):
"""Traffic limit for a specific server."""
traffic_limit_gb: int = Field(0, ge=0, description="0 = use default tariff limit")
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: Optional[str] = None
country_code: str | None = None
is_selected: bool = False
traffic_limit_gb: Optional[int] = None # Индивидуальный лимит для сервера
traffic_limit_gb: int | None = None # Индивидуальный лимит для сервера
class PromoGroupInfo(BaseModel):
"""Promo group info for tariff."""
id: int
name: str
is_selected: bool = False
@@ -41,9 +45,10 @@ class PromoGroupInfo(BaseModel):
class TariffListItem(BaseModel):
"""Tariff item for list view."""
id: int
name: str
description: Optional[str] = None
description: str | None = None
is_active: bool
is_trial_available: bool
is_daily: bool = False
@@ -63,32 +68,34 @@ class TariffListItem(BaseModel):
class TariffListResponse(BaseModel):
"""Response with list of tariffs."""
tariffs: List[TariffListItem]
tariffs: list[TariffListItem]
total: int
class TariffDetailResponse(BaseModel):
"""Detailed tariff response."""
id: int
name: str
description: Optional[str] = None
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)
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: Optional[int] = None
max_device_limit: Optional[int] = None
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]
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
@@ -104,9 +111,9 @@ class TariffDetailResponse(BaseModel):
is_daily: bool = False
daily_price_kopeks: int = 0
# Режим сброса трафика
traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
created_at: datetime
updated_at: Optional[datetime] = None
updated_at: datetime | None = None
class Config:
from_attributes = True
@@ -114,22 +121,25 @@ class TariffDetailResponse(BaseModel):
class TariffCreateRequest(BaseModel):
"""Request to create a tariff."""
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
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)
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")
traffic_limit_gb: int = Field(0, ge=0, description='0 = unlimited')
device_limit: int = Field(1, ge=1)
device_price_kopeks: Optional[int] = Field(None, ge=0)
max_device_limit: Optional[int] = 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 = 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)
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)
@@ -144,47 +154,55 @@ class TariffCreateRequest(BaseModel):
is_daily: bool = False
daily_price_kopeks: int = Field(0, ge=0)
# Режим сброса трафика
traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
class TariffUpdateRequest(BaseModel):
"""Request to update a tariff."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
is_active: Optional[bool] = None
allow_traffic_topup: Optional[bool] = None
traffic_topup_enabled: Optional[bool] = None
traffic_topup_packages: Optional[Dict[str, int]] = None
max_topup_traffic_gb: Optional[int] = Field(None, ge=0)
traffic_limit_gb: Optional[int] = Field(None, ge=0)
device_limit: Optional[int] = Field(None, ge=1)
device_price_kopeks: Optional[int] = Field(None, ge=0)
max_device_limit: Optional[int] = Field(None, ge=1)
tier_level: Optional[int] = Field(None, ge=1, le=10)
display_order: Optional[int] = Field(None, ge=0)
period_prices: Optional[List[PeriodPrice]] = None
allowed_squads: Optional[List[str]] = None
server_traffic_limits: Optional[Dict[str, ServerTrafficLimit]] = None
promo_group_ids: Optional[List[int]] = None
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: Optional[bool] = None
price_per_day_kopeks: Optional[int] = Field(None, ge=0)
min_days: Optional[int] = Field(None, ge=1)
max_days: Optional[int] = Field(None, ge=1)
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: Optional[bool] = None
traffic_price_per_gb_kopeks: Optional[int] = Field(None, ge=0)
min_traffic_gb: Optional[int] = Field(None, ge=1)
max_traffic_gb: Optional[int] = 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: Optional[bool] = None
daily_price_kopeks: Optional[int] = Field(None, ge=0)
is_daily: bool | None = None
daily_price_kopeks: int | None = Field(None, ge=0)
# Режим сброса трафика
traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
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
@@ -192,6 +210,7 @@ class TariffToggleResponse(BaseModel):
class TariffTrialResponse(BaseModel):
"""Response after setting trial tariff."""
id: int
is_trial_available: bool
message: str
@@ -199,6 +218,7 @@ class TariffTrialResponse(BaseModel):
class TariffStatsResponse(BaseModel):
"""Tariff statistics."""
id: int
name: str
subscriptions_count: int
+24 -18
View File
@@ -1,19 +1,20 @@
"""Support tickets schemas for cabinet."""
from datetime import datetime
from typing import Optional, List
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: Optional[str] = None
media_file_id: Optional[str] = None
media_caption: Optional[str] = None
media_type: str | None = None
media_file_id: str | None = None
media_caption: str | None = None
created_at: datetime
class Config:
@@ -22,15 +23,16 @@ class TicketMessageResponse(BaseModel):
class TicketResponse(BaseModel):
"""Ticket data."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: Optional[datetime] = None
closed_at: datetime | None = None
messages_count: int = 0
last_message: Optional[TicketMessageResponse] = None
last_message: TicketMessageResponse | None = None
class Config:
from_attributes = True
@@ -38,15 +40,16 @@ class TicketResponse(BaseModel):
class TicketDetailResponse(BaseModel):
"""Ticket with all messages."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: Optional[datetime] = None
closed_at: datetime | None = None
is_reply_blocked: bool = False
messages: List[TicketMessageResponse] = []
messages: list[TicketMessageResponse] = []
class Config:
from_attributes = True
@@ -54,7 +57,8 @@ class TicketDetailResponse(BaseModel):
class TicketListResponse(BaseModel):
"""Paginated ticket list."""
items: List[TicketResponse]
items: list[TicketResponse]
total: int
page: int
per_page: int
@@ -63,16 +67,18 @@ class TicketListResponse(BaseModel):
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: Optional[str] = Field(None, description="Media type: photo, video, document")
media_file_id: Optional[str] = Field(None, description="Telegram file_id of uploaded media")
media_caption: Optional[str] = Field(None, max_length=1000, description="Media caption")
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: Optional[str] = Field(None, description="Media type: photo, video, document")
media_file_id: Optional[str] = Field(None, description="Telegram file_id of uploaded media")
media_caption: Optional[str] = Field(None, max_length=1000, description="Media caption")
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
+356 -115
View File
@@ -1,58 +1,78 @@
"""Schemas for Admin Users management in cabinet."""
from datetime import datetime
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
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"
ACTIVE = 'active'
BLOCKED = 'blocked'
DELETED = 'deleted'
class SubscriptionStatusEnum(str, Enum):
"""Subscription status enum."""
TRIAL = "trial"
ACTIVE = "active"
EXPIRED = "expired"
DISABLED = "disabled"
PENDING = "pending"
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"
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: Optional[datetime] = None
end_date: Optional[datetime] = None
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: Optional[int] = None
tariff_name: Optional[str] = None
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
@@ -60,29 +80,31 @@ class UserPromoGroupInfo(BaseModel):
# === User List ===
class UserListItem(BaseModel):
"""User item in list."""
id: int
telegram_id: int
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
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: Optional[datetime] = None
last_activity: datetime | None = None
# Subscription summary
has_subscription: bool = False
subscription_status: Optional[str] = None
subscription_status: str | None = None
subscription_is_trial: bool = False
subscription_end_date: Optional[datetime] = None
subscription_end_date: datetime | None = None
# Promo group
promo_group_id: Optional[int] = None
promo_group_name: Optional[str] = None
promo_group_id: int | None = None
promo_group_name: str | None = None
# Stats
total_spent_kopeks: int = 0
@@ -96,7 +118,8 @@ class UserListItem(BaseModel):
class UsersListResponse(BaseModel):
"""Paginated list of users."""
users: List[UserListItem]
users: list[UserListItem]
total: int
offset: int = 0
limit: int = 50
@@ -104,35 +127,39 @@ class UsersListResponse(BaseModel):
# === User Detail ===
class UserTransactionItem(BaseModel):
"""User transaction."""
id: int
type: str
amount_kopeks: int
amount_rubles: float
description: Optional[str] = None
payment_method: Optional[str] = None
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: Optional[int] = None
referred_by_id: Optional[int] = None
referred_by_username: Optional[str] = None
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
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
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
@@ -140,20 +167,20 @@ class UserDetailResponse(BaseModel):
balance_rubles: float
# Email (cabinet)
email: Optional[str] = None
email: str | None = None
email_verified: bool = False
# Dates
created_at: datetime
updated_at: Optional[datetime] = None
last_activity: Optional[datetime] = None
cabinet_last_login: Optional[datetime] = None
updated_at: datetime | None = None
last_activity: datetime | None = None
cabinet_last_login: datetime | None = None
# Subscription
subscription: Optional[UserSubscriptionInfo] = None
subscription: UserSubscriptionInfo | None = None
# Promo group
promo_group: Optional[UserPromoGroupInfo] = None
promo_group: UserPromoGroupInfo | None = None
# Referral
referral: UserReferralInfo
@@ -168,28 +195,80 @@ class UserDetailResponse(BaseModel):
# Restrictions
restriction_topup: bool = False
restriction_subscription: bool = False
restriction_reason: Optional[str] = None
restriction_reason: str | None = None
# Promo offer
promo_offer_discount_percent: int = 0
promo_offer_discount_source: Optional[str] = None
promo_offer_discount_expires_at: Optional[datetime] = None
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] = []
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")
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
@@ -198,44 +277,56 @@ class UpdateBalanceResponse(BaseModel):
class UpdateSubscriptionRequest(BaseModel):
"""Request to update user subscription."""
action: str = Field(..., description="Action: extend, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel")
action: str = Field(
..., description='Action: extend, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
)
# For extend action
days: Optional[int] = Field(None, ge=1, le=3650, description="Days to extend")
days: int | None = Field(None, ge=1, le=3650, description='Days to extend')
# For set_end_date action
end_date: Optional[datetime] = Field(None, description="New end date")
end_date: datetime | None = Field(None, description='New end date')
# For change_tariff action
tariff_id: Optional[int] = Field(None, description="New tariff ID")
tariff_id: int | None = Field(None, description='New tariff ID')
# For set_traffic action
traffic_limit_gb: Optional[int] = Field(None, ge=0, description="New traffic limit in GB")
traffic_used_gb: Optional[float] = Field(None, ge=0, description="Set traffic used in GB")
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: Optional[bool] = Field(None, description="Enable/disable 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: Optional[bool] = Field(None, description="Is trial subscription")
device_limit: Optional[int] = Field(None, ge=1, description="Device limit")
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: Optional[UserSubscriptionInfo] = None
subscription: UserSubscriptionInfo | None = None
class UpdateUserStatusRequest(BaseModel):
"""Request to update user status."""
status: UserStatusEnum
reason: Optional[str] = Field(None, max_length=500, description="Reason for status change")
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
@@ -244,50 +335,108 @@ class UpdateUserStatusResponse(BaseModel):
class UpdateRestrictionsRequest(BaseModel):
"""Request to update user restrictions."""
restriction_topup: Optional[bool] = Field(None, description="Block balance top-up")
restriction_subscription: Optional[bool] = Field(None, description="Block subscription purchase/renewal")
restriction_reason: Optional[str] = Field(None, max_length=500, description="Reason for 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: Optional[str] = None
restriction_reason: str | None = None
message: str
class UpdatePromoGroupRequest(BaseModel):
"""Request to update user promo group."""
promo_group_id: Optional[int] = Field(None, description="New promo group ID (null to remove)")
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: Optional[int] = None
new_promo_group_id: Optional[int] = None
promo_group_name: Optional[str] = None
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: Optional[str] = Field(None, max_length=500, description="Reason for deletion")
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
@@ -315,20 +464,23 @@ class UsersStatsResponse(BaseModel):
# === 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"
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
@@ -336,9 +488,10 @@ class PeriodPriceInfo(BaseModel):
class UserAvailableTariffItem(BaseModel):
"""Tariff available for user."""
id: int
name: str
description: Optional[str] = None
description: str | None = None
is_active: bool = True
is_trial_available: bool = False
traffic_limit_gb: int = 0
@@ -347,7 +500,7 @@ class UserAvailableTariffItem(BaseModel):
display_order: int = 0
# Pricing
period_prices: List[PeriodPriceInfo] = []
period_prices: list[PeriodPriceInfo] = []
is_daily: bool = False
daily_price_kopeks: int = 0
@@ -357,6 +510,15 @@ class UserAvailableTariffItem(BaseModel):
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
@@ -364,92 +526,171 @@ class UserAvailableTariffItem(BaseModel):
class UserAvailableTariffsResponse(BaseModel):
"""List of tariffs available for user."""
user_id: int
promo_group_id: Optional[int] = None
promo_group_name: Optional[str] = None
tariffs: List[UserAvailableTariffItem] = []
promo_group_id: int | None = None
promo_group_name: str | None = None
tariffs: list[UserAvailableTariffItem] = []
total: int = 0
# Current subscription tariff
current_tariff_id: Optional[int] = None
current_tariff_name: Optional[str] = None
current_tariff_id: int | None = None
current_tariff_name: str | None = None
# === Panel Sync ===
class PanelUserInfo(BaseModel):
"""User info from panel."""
uuid: Optional[str] = None
short_uuid: Optional[str] = None
username: Optional[str] = None
status: Optional[str] = None
expire_at: Optional[datetime] = None
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: Optional[str] = None
active_squads: List[str] = []
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")
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: Optional[PanelUserInfo] = None
changes: Dict[str, Any] = {}
errors: List[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")
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: Optional[str] = None
changes: Dict[str, Any] = {}
errors: List[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
remnawave_uuid: Optional[str] = None
last_sync: Optional[datetime] = None
telegram_id: int | None = None
remnawave_uuid: str | None = None
last_sync: datetime | None = None
# Bot data
bot_subscription_status: Optional[str] = None
bot_subscription_end_date: Optional[datetime] = None
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] = []
bot_squads: list[str] = []
# Panel data (if available)
panel_found: bool = False
panel_status: Optional[str] = None
panel_expire_at: Optional[datetime] = None
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] = []
panel_squads: list[str] = []
# Differences
has_differences: bool = False
differences: List[str] = []
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
+82 -64
View File
@@ -1,27 +1,29 @@
"""Схемы для колеса удачи (Fortune Wheel)."""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field
from enum import Enum
from pydantic import BaseModel, Field
# ==================== ENUMS ====================
class WheelPaymentType(str, Enum):
"""Способы оплаты спина."""
TELEGRAM_STARS = "telegram_stars"
SUBSCRIPTION_DAYS = "subscription_days"
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"
SUBSCRIPTION_DAYS = 'subscription_days'
BALANCE_BONUS = 'balance_bonus'
TRAFFIC_GB = 'traffic_gb'
PROMOCODE = 'promocode'
NOTHING = 'nothing'
# ==================== USER SCHEMAS ====================
@@ -29,6 +31,7 @@ class WheelPrizeType(str, Enum):
class WheelPrizeDisplay(BaseModel):
"""Отображение приза для пользователя."""
id: int
display_name: str
emoji: str
@@ -41,17 +44,18 @@ class WheelPrizeDisplay(BaseModel):
class WheelConfigResponse(BaseModel):
"""Конфигурация колеса для пользователя."""
is_enabled: bool
name: str
spin_cost_stars: Optional[int] = None
spin_cost_days: Optional[int] = None
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]
prizes: list[WheelPrizeDisplay]
daily_limit: int
user_spins_today: int
can_spin: bool
can_spin_reason: Optional[str] = None
can_spin_reason: str | None = None
can_pay_stars: bool = False
can_pay_days: bool = False
user_balance_kopeks: int = 0
@@ -60,8 +64,9 @@ class WheelConfigResponse(BaseModel):
class SpinAvailabilityResponse(BaseModel):
"""Доступность спина."""
can_spin: bool
reason: Optional[str] = None
reason: str | None = None
spins_remaining_today: int
can_pay_stars: bool
can_pay_days: bool
@@ -73,34 +78,37 @@ class SpinAvailabilityResponse(BaseModel):
class SpinRequest(BaseModel):
"""Запрос на спин."""
payment_type: WheelPaymentType
class SpinResultResponse(BaseModel):
"""Результат спина."""
success: bool
prize_id: Optional[int] = None
prize_type: Optional[str] = None
prize_id: int | None = None
prize_type: str | None = None
prize_value: int = 0
prize_display_name: str = ""
emoji: str = "🎁"
color: str = "#3B82F6"
prize_display_name: str = ''
emoji: str = '🎁'
color: str = '#3B82F6'
rotation_degrees: float = 0.0
message: str = ""
promocode: Optional[str] = None
error: Optional[str] = None
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"
emoji: str = '🎁'
color: str = '#3B82F6'
prize_value_kopeks: int
created_at: datetime
@@ -110,7 +118,8 @@ class SpinHistoryItem(BaseModel):
class SpinHistoryResponse(BaseModel):
"""История спинов с пагинацией."""
items: List[SpinHistoryItem]
items: list[SpinHistoryItem]
total: int
page: int
per_page: int
@@ -122,6 +131,7 @@ class SpinHistoryResponse(BaseModel):
class WheelPrizeAdminResponse(BaseModel):
"""Полная информация о призе для админа."""
id: int
config_id: int
prize_type: str
@@ -131,13 +141,13 @@ class WheelPrizeAdminResponse(BaseModel):
color: str
prize_value_kopeks: int
sort_order: int
manual_probability: Optional[float] = None
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: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
@@ -145,6 +155,7 @@ class WheelPrizeAdminResponse(BaseModel):
class AdminWheelConfigResponse(BaseModel):
"""Полная конфигурация колеса для админа."""
id: int
is_enabled: bool
name: str
@@ -157,9 +168,9 @@ class AdminWheelConfigResponse(BaseModel):
min_subscription_days_for_day_payment: int
promo_prefix: str
promo_validity_days: int
prizes: List[WheelPrizeAdminResponse]
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
prizes: list[WheelPrizeAdminResponse]
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
@@ -167,29 +178,31 @@ class AdminWheelConfigResponse(BaseModel):
class UpdateWheelConfigRequest(BaseModel):
"""Запрос на обновление конфига колеса."""
is_enabled: Optional[bool] = None
name: Optional[str] = Field(None, min_length=1, max_length=255)
spin_cost_stars: Optional[int] = Field(None, ge=1, le=1000)
spin_cost_days: Optional[int] = Field(None, ge=1, le=30)
spin_cost_stars_enabled: Optional[bool] = None
spin_cost_days_enabled: Optional[bool] = None
rtp_percent: Optional[int] = Field(None, ge=0, le=100)
daily_spin_limit: Optional[int] = Field(None, ge=0, le=100)
min_subscription_days_for_day_payment: Optional[int] = Field(None, ge=1, le=30)
promo_prefix: Optional[str] = Field(None, min_length=1, max_length=20)
promo_validity_days: Optional[int] = Field(None, ge=1, le=365)
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}$')
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: Optional[float] = Field(None, ge=0, le=1)
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)
@@ -198,30 +211,33 @@ class CreatePrizeRequest(BaseModel):
class UpdatePrizeRequest(BaseModel):
"""Запрос на обновление приза."""
prize_type: Optional[WheelPrizeType] = None
prize_value: Optional[int] = Field(None, ge=0)
display_name: Optional[str] = Field(None, min_length=1, max_length=100)
emoji: Optional[str] = Field(None, max_length=10)
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
prize_value_kopeks: Optional[int] = Field(None, ge=0)
sort_order: Optional[int] = Field(None, ge=0)
manual_probability: Optional[float] = Field(None, ge=0, le=1)
is_active: Optional[bool] = None
promo_balance_bonus_kopeks: Optional[int] = Field(None, ge=0)
promo_subscription_days: Optional[int] = Field(None, ge=0)
promo_traffic_gb: Optional[int] = Field(None, ge=0)
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]
prize_ids: list[int]
class AdminSpinItem(BaseModel):
"""Спин для админки."""
id: int
user_id: int
username: Optional[str] = None
username: str | None = None
payment_type: str
payment_amount: int
payment_value_kopeks: int
@@ -238,7 +254,8 @@ class AdminSpinItem(BaseModel):
class AdminSpinsResponse(BaseModel):
"""Список спинов для админки с пагинацией."""
items: List[AdminSpinItem]
items: list[AdminSpinItem]
total: int
page: int
per_page: int
@@ -247,13 +264,14 @@ class AdminSpinsResponse(BaseModel):
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: Optional[str] = None
period_to: Optional[str] = None
prizes_distribution: list[dict]
top_wins: list[dict]
period_from: str | None = None
period_to: str | None = None
+129
View File
@@ -0,0 +1,129 @@
"""Withdrawal system schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
# ==================== User-facing ====================
class WithdrawalBalanceResponse(BaseModel):
"""Withdrawal balance info for user."""
total_earned: int
referral_spent: int
withdrawn: int
pending: int
available_referral: int
available_total: int
only_referral_mode: bool
min_amount_kopeks: int
is_withdrawal_enabled: bool
can_request: bool
cannot_request_reason: str | None = None
requisites_text: str = ''
class WithdrawalCreateRequest(BaseModel):
"""Request to create a withdrawal."""
amount_kopeks: int = Field(..., gt=0, le=10_000_000)
payment_details: str = Field(..., min_length=5, max_length=1000)
class WithdrawalItemResponse(BaseModel):
"""Withdrawal request item."""
id: int
amount_kopeks: int
amount_rubles: float
status: str
payment_details: str | None = None
admin_comment: str | None = None
created_at: datetime
processed_at: datetime | None = None
class Config:
from_attributes = True
class WithdrawalListResponse(BaseModel):
"""List of user's withdrawal requests."""
items: list[WithdrawalItemResponse]
total: int
class WithdrawalCreateResponse(BaseModel):
"""Response after creating withdrawal."""
id: int
amount_kopeks: int
status: str
# ==================== Admin-facing ====================
class AdminWithdrawalItem(BaseModel):
"""Withdrawal request in admin list."""
id: int
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
amount_kopeks: int
amount_rubles: float
status: str
risk_score: int = 0
risk_level: str = 'low'
payment_details: str | None = None
admin_comment: str | None = None
created_at: datetime
processed_at: datetime | None = None
class AdminWithdrawalListResponse(BaseModel):
"""List of withdrawal requests for admin."""
items: list[AdminWithdrawalItem]
total: int
pending_count: int = 0
pending_total_kopeks: int = 0
class AdminWithdrawalDetailResponse(BaseModel):
"""Detailed withdrawal request for admin."""
id: int
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
amount_kopeks: int
amount_rubles: float
status: str
risk_score: int = 0
risk_level: str = 'low'
risk_analysis: dict | None = None
payment_details: str | None = None
admin_comment: str | None = None
balance_kopeks: int = 0
total_referrals: int = 0
total_earnings_kopeks: int = 0
created_at: datetime
processed_at: datetime | None = None
class AdminApproveWithdrawalRequest(BaseModel):
"""Request to approve a withdrawal."""
comment: str | None = Field(None, max_length=2000)
class AdminRejectWithdrawalRequest(BaseModel):
"""Request to reject a withdrawal."""
comment: str | None = Field(None, max_length=2000)
+2 -1
View File
@@ -2,4 +2,5 @@
from .email_service import EmailService, email_service
__all__ = ["EmailService", "email_service"]
__all__ = ['EmailService', 'email_service']
+309 -47
View File
@@ -1,14 +1,15 @@
"""Email service for sending verification and password reset emails."""
import logging
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
from email.mime.text import MIMEText
import structlog
from app.config import settings
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class EmailService:
@@ -29,14 +30,19 @@ class EmailService:
def _get_smtp_connection(self) -> smtplib.SMTP:
"""Create and return SMTP connection."""
if self.use_tls:
smtp = smtplib.SMTP(self.host, self.port)
smtp.starttls()
else:
smtp = smtplib.SMTP(self.host, self.port)
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:
smtp.login(self.user, self.password)
if smtp.has_extn('auth'):
smtp.login(self.user, self.password)
else:
logger.debug('SMTP server does not support AUTH, skipping authentication', host=self.host)
return smtp
@@ -45,7 +51,7 @@ class EmailService:
to_email: str,
subject: str,
body_html: str,
body_text: Optional[str] = None,
body_text: str | None = None,
) -> bool:
"""
Send an email.
@@ -60,27 +66,28 @@ class EmailService:
True if email was sent successfully, False otherwise
"""
if not self.is_configured():
logger.warning("SMTP is not configured, cannot send email")
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
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")
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)
@@ -88,11 +95,11 @@ class EmailService:
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}")
logger.info('Email sent successfully to', to_email=to_email)
return True
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {e}")
logger.error('Failed to send email to', to_email=to_email, error=e)
return False
def send_verification_email(
@@ -100,7 +107,10 @@ class EmailService:
to_email: str,
verification_token: str,
verification_url: str,
username: Optional[str] = None,
username: str | None = None,
language: str = 'ru',
custom_subject: str | None = None,
custom_body_html: str | None = None,
) -> bool:
"""
Send email verification email.
@@ -110,14 +120,76 @@ class EmailService:
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
"""
full_url = f"{verification_url}?token={verification_token}"
greeting = f"Hello{', ' + username if username else ''}!"
if custom_subject and custom_body_html:
return self.send_email(to_email, custom_subject, custom_body_html)
subject = "Verify your email address"
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>
@@ -140,15 +212,15 @@ class EmailService:
</head>
<body>
<div class="container">
<h2>{greeting}</h2>
<p>Thank you for registering! Please verify your email address by clicking the button below:</p>
<a href="{full_url}" class="button">Verify Email</a>
<p>Or copy and paste this link in your browser:</p>
<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>This link will expire in {settings.get_cabinet_email_verification_expire_hours()} hours.</p>
<p>If you didn't create an account, you can safely ignore this email.</p>
<p>{t['expires']}</p>
<p>{t['ignore']}</p>
<div class="footer">
<p>Best regards,<br>{self.from_name}</p>
<p>{t['regards']}<br>{self.from_name}</p>
</div>
</div>
</body>
@@ -162,7 +234,10 @@ class EmailService:
to_email: str,
reset_token: str,
reset_url: str,
username: Optional[str] = None,
username: str | None = None,
language: str = 'ru',
custom_subject: str | None = None,
custom_body_html: str | None = None,
) -> bool:
"""
Send password reset email.
@@ -172,14 +247,76 @@ class EmailService:
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
"""
full_url = f"{reset_url}?token={reset_token}"
greeting = f"Hello{', ' + username if username else ''}!"
if custom_subject and custom_body_html:
return self.send_email(to_email, custom_subject, custom_body_html)
subject = "Reset your password"
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>
@@ -203,15 +340,140 @@ class EmailService:
</head>
<body>
<div class="container">
<h2>{greeting}</h2>
<p>We received a request to reset your password. Click the button below to set a new password:</p>
<a href="{full_url}" class="button">Reset Password</a>
<p>Or copy and paste this link in your browser:</p>
<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>This link will expire in {settings.get_cabinet_password_reset_expire_hours()} hour(s).</p>
<p class="warning">If you didn't request a password reset, please ignore this email or contact support if you're concerned.</p>
<p>{t['expires']}</p>
<p class="warning">{t['warning']}</p>
<div class="footer">
<p>Best regards,<br>{self.from_name}</p>
<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>
@@ -0,0 +1,222 @@
"""
Service for managing email template overrides stored in the database.
Custom templates override the hardcoded defaults from email_templates.py.
"""
from datetime import UTC, datetime
from typing import Any
import structlog
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.database import AsyncSessionLocal
logger = structlog.get_logger(__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 шаблона /', notification_type=notification_type, language=language, e=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.now(UTC)
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
+1054 -918
View File
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -8,16 +8,17 @@ from .database import (
get_db,
get_db_read_only,
get_pool_metrics,
init_db,
sync_postgres_sequences,
)
__all__ = [
"DatabaseManager",
"batch_ops",
"close_db",
"db_manager",
"get_db",
"get_db_read_only",
"get_pool_metrics",
"init_db",
'DatabaseManager',
'batch_ops',
'close_db',
'db_manager',
'get_db',
'get_db_read_only',
'get_pool_metrics',
'sync_postgres_sequences',
]
+81 -169
View File
@@ -1,8 +1,7 @@
import logging
from datetime import datetime
from typing import Dict, List, Optional
from datetime import UTC, datetime
from sqlalchemy import and_, func, select, update, delete
import structlog
from sqlalchemy import and_, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -17,7 +16,8 @@ from app.database.models import (
User,
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_campaign(
@@ -26,16 +26,17 @@ 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: Optional[int] = None,
tariff_duration_days: Optional[int] = None,
tariff_id: int | None = None,
tariff_duration_days: int | None = None,
is_active: bool = True,
partner_user_id: int | None = None,
) -> AdvertisingCampaign:
campaign = AdvertisingCampaign(
name=name,
@@ -50,6 +51,7 @@ async def create_campaign(
tariff_duration_days=tariff_duration_days,
created_by=created_by,
is_active=is_active,
partner_user_id=partner_user_id,
)
db.add(campaign)
@@ -57,22 +59,21 @@ async def create_campaign(
await db.refresh(campaign)
logger.info(
"📣 Создана рекламная кампания %s (start=%s, bonus=%s)",
campaign.name,
campaign.start_parameter,
campaign.bonus_type,
'📣 Создана рекламная кампания (start bonus=)',
campaign_name=campaign.name,
start_parameter=campaign.start_parameter,
bonus_type=campaign.bonus_type,
)
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),
selectinload(AdvertisingCampaign.tariff),
selectinload(AdvertisingCampaign.partner),
)
.where(AdvertisingCampaign.id == campaign_id)
)
@@ -84,10 +85,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))
@@ -101,12 +100,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),
selectinload(AdvertisingCampaign.tariff),
selectinload(AdvertisingCampaign.partner),
)
.order_by(AdvertisingCampaign.created_at.desc())
.offset(offset)
@@ -119,9 +119,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))
@@ -136,17 +134,18 @@ 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",
"tariff_id",
"tariff_duration_days",
"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',
'partner_user_id',
}
update_data = {}
@@ -157,33 +156,27 @@ async def update_campaign(
if not update_data:
return campaign
update_data["updated_at"] = datetime.utcnow()
update_data['updated_at'] = datetime.now(UTC)
await db.execute(
update(AdvertisingCampaign)
.where(AdvertisingCampaign.id == campaign.id)
.values(**update_data)
)
await db.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('✏️ Обновлена рекламная кампания', campaign_name=campaign.name, update_data=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('🗑️ Удалена рекламная кампания', campaign_name=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))
@@ -200,9 +193,9 @@ async def record_campaign_registration(
user_id: int,
bonus_type: str,
balance_bonus_kopeks: int = 0,
subscription_duration_days: Optional[int] = None,
tariff_id: Optional[int] = None,
tariff_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(
@@ -229,14 +222,14 @@ async def record_campaign_registration(
await db.commit()
await db.refresh(registration)
logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id)
logger.info('📈 Регистрируем пользователя в кампании', user_id=user_id, campaign_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
)
@@ -245,9 +238,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)
)
@@ -259,7 +250,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',
)
)
)
@@ -312,11 +303,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()
@@ -339,8 +326,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)
@@ -349,7 +336,7 @@ async def get_campaign_statistics(
first_payment_time_by_user[user_id] = converted_at
for user_id, amount_kopeks, created_at in subscription_payments:
amount_value = int(amount_kopeks or 0)
amount_value = abs(int(amount_kopeks or 0))
subscription_payments_total += amount_value
paid_users_from_transactions.add(user_id)
@@ -358,13 +345,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
@@ -376,75 +358,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)
)
conversion_rate = 0.0
if count:
conversion_rate = round((paid_users_count / count) * 100, 1)
trial_conversion_rate = 0.0
if trial_users_count:
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
avg_revenue_per_user = 0
if count:
avg_revenue_per_user = int(total_revenue / count)
deposits_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed.is_(True),
)
)
total_revenue = deposits_result.scalar() or 0
trials_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
Subscription.is_trial.is_(True),
)
)
trial_users_count = trials_result.scalar() or 0
active_trials_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
Subscription.is_trial.is_(True),
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
active_trials_count = active_trials_result.scalar() or 0
conversions_result = await db.execute(
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
)
)
conversion_count = conversions_result.scalar() or 0
paid_users_result = await db.execute(
select(func.count(User.id)).where(
User.id.in_(select(registrations_subquery.c.user_id)),
User.has_had_paid_subscription.is_(True),
)
)
paid_users_count = paid_users_result.scalar() or 0
avg_first_payment_result = await db.execute(
select(func.coalesce(func.avg(SubscriptionConversion.first_payment_amount_kopeks), 0)).where(
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
)
)
avg_first_payment = int(avg_first_payment_result.scalar() or 0)
avg_first_payment = int(sum(first_payment_amount_by_user.values()) / len(first_payment_amount_by_user))
conversion_rate = 0.0
if count:
@@ -459,50 +377,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,
}
+38 -47
View File
@@ -2,16 +2,17 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, update
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import CloudPaymentsPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_cloudpayments_payment(
@@ -20,11 +21,11 @@ async def create_cloudpayments_payment(
user_id: int,
invoice_id: str,
amount_kopeks: int,
description: Optional[str] = None,
currency: str = "RUB",
payment_url: Optional[str] = None,
email: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
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:
"""
@@ -51,7 +52,7 @@ async def create_cloudpayments_payment(
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status="pending",
status='pending',
is_paid=False,
payment_url=payment_url,
email=email,
@@ -64,10 +65,10 @@ async def create_cloudpayments_payment(
await db.refresh(payment)
logger.debug(
"Created CloudPayments payment: id=%s, invoice=%s, amount=%s",
payment.id,
invoice_id,
amount_kopeks,
'Created CloudPayments payment: id invoice amount',
payment_id=payment.id,
invoice_id=invoice_id,
amount_kopeks=amount_kopeks,
)
return payment
@@ -76,36 +77,28 @@ async def create_cloudpayments_payment(
async def get_cloudpayments_payment_by_invoice_id(
db: AsyncSession,
invoice_id: str,
) -> Optional[CloudPaymentsPayment]:
) -> CloudPaymentsPayment | None:
"""Get CloudPayments payment by invoice ID."""
result = await db.execute(
select(CloudPaymentsPayment).where(
CloudPaymentsPayment.invoice_id == 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,
) -> Optional[CloudPaymentsPayment]:
) -> CloudPaymentsPayment | None:
"""Get CloudPayments payment by internal ID."""
result = await db.execute(
select(CloudPaymentsPayment).where(CloudPaymentsPayment.id == payment_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,
) -> Optional[CloudPaymentsPayment]:
) -> CloudPaymentsPayment | None:
"""Get CloudPayments payment by CloudPayments transaction ID."""
result = await db.execute(
select(CloudPaymentsPayment).where(
CloudPaymentsPayment.transaction_id_cp == transaction_id_cp
)
select(CloudPaymentsPayment).where(CloudPaymentsPayment.transaction_id_cp == transaction_id_cp)
)
return result.scalars().first()
@@ -114,7 +107,7 @@ async def update_cloudpayments_payment(
db: AsyncSession,
payment_id: int,
**kwargs: Any,
) -> Optional[CloudPaymentsPayment]:
) -> CloudPaymentsPayment | None:
"""
Update CloudPayments payment record.
@@ -134,7 +127,7 @@ async def update_cloudpayments_payment(
if hasattr(payment, key):
setattr(payment, key, value)
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
@@ -145,15 +138,15 @@ async def mark_cloudpayments_payment_as_paid(
db: AsyncSession,
payment_id: int,
*,
transaction_id_cp: Optional[int] = None,
token: Optional[str] = None,
card_first_six: Optional[str] = None,
card_last_four: Optional[str] = None,
card_type: Optional[str] = None,
card_exp_date: Optional[str] = None,
email: Optional[str] = None,
callback_payload: Optional[Dict[str, Any]] = None,
) -> Optional[CloudPaymentsPayment]:
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.
@@ -176,9 +169,9 @@ async def mark_cloudpayments_payment_as_paid(
if not payment:
return None
payment.status = "completed"
payment.status = 'completed'
payment.is_paid = True
payment.paid_at = datetime.utcnow()
payment.paid_at = datetime.now(UTC)
if transaction_id_cp is not None:
payment.transaction_id_cp = transaction_id_cp
@@ -197,14 +190,12 @@ async def mark_cloudpayments_payment_as_paid(
if callback_payload:
payment.callback_payload = callback_payload
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
logger.info(
"Marked CloudPayments payment as paid: id=%s, invoice=%s",
payment.id,
payment.invoice_id,
'Marked CloudPayments payment as paid: id invoice', payment_id=payment.id, invoice_id=payment.invoice_id
)
return payment
@@ -214,7 +205,7 @@ async def link_cloudpayments_payment_to_transaction(
db: AsyncSession,
payment_id: int,
transaction_id: int,
) -> Optional[CloudPaymentsPayment]:
) -> CloudPaymentsPayment | None:
"""Link CloudPayments payment to internal transaction."""
payment = await get_cloudpayments_payment_by_id(db, payment_id)
if not payment:
+30 -33
View File
@@ -1,32 +1,29 @@
import logging
from datetime import datetime
from typing import List, Optional, Sequence, Tuple
from collections.abc import Sequence
from datetime import UTC, datetime
from sqlalchemy import and_, delete, desc, func, select
import structlog
from sqlalchemy import and_, delete, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import ContestTemplate, ContestRound, ContestAttempt, User
from app.database.models import ContestAttempt, ContestRound, ContestTemplate, User
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
# Templates
async def get_template_by_id(db: AsyncSession, template_id: int) -> Optional[ContestTemplate]:
result = await db.execute(
select(ContestTemplate).where(ContestTemplate.id == template_id)
)
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) -> Optional[ContestTemplate]:
result = await db.execute(
select(ContestTemplate).where(ContestTemplate.slug == slug)
)
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]:
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))
@@ -39,16 +36,16 @@ async def upsert_template(
*,
slug: str,
name: str,
description: str = "",
prize_type: str = "days",
prize_value: str = "1",
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: Optional[str] = None,
schedule_times: str | None = None,
cooldown_hours: int = 24,
payload: Optional[dict] = None,
is_enabled: Optional[bool] = None,
payload: dict | None = None,
is_enabled: bool | None = None,
) -> ContestTemplate:
template = await get_template_by_slug(db, slug)
if not template:
@@ -98,7 +95,7 @@ async def create_round(
template_id=template.id,
starts_at=starts_at,
ends_at=ends_at,
status="active",
status='active',
payload=payload,
max_winners=template.max_winners,
attempts_per_user=template.attempts_per_user,
@@ -109,14 +106,14 @@ async def create_round(
return round_obj
async def get_active_rounds(db: AsyncSession) -> List[ContestRound]:
now = datetime.utcnow()
async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
now = datetime.now(UTC)
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
.where(
and_(
ContestRound.status == "active",
ContestRound.status == 'active',
ContestRound.starts_at <= now,
ContestRound.ends_at >= now,
)
@@ -126,15 +123,15 @@ async def get_active_rounds(db: AsyncSession) -> List[ContestRound]:
return list(result.scalars().all())
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> Optional[ContestRound]:
now = datetime.utcnow()
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> ContestRound | None:
now = datetime.now(UTC)
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
.where(
and_(
ContestRound.template_id == template_id,
ContestRound.status == "active",
ContestRound.status == 'active',
ContestRound.starts_at <= now,
ContestRound.ends_at >= now,
)
@@ -145,7 +142,7 @@ async def get_active_round_by_template(db: AsyncSession, template_id: int) -> Op
async def finish_round(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
round_obj.status = "finished"
round_obj.status = 'finished'
await db.commit()
await db.refresh(round_obj)
return round_obj
@@ -159,7 +156,7 @@ async def increment_winner_count(db: AsyncSession, round_obj: ContestRound) -> C
# Attempts
async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> Optional[ContestAttempt]:
async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> ContestAttempt | None:
result = await db.execute(
select(ContestAttempt).where(
and_(
@@ -176,7 +173,7 @@ async def create_attempt(
*,
round_id: int,
user_id: int,
answer: Optional[str],
answer: str | None,
is_winner: bool,
) -> ContestAttempt:
attempt = ContestAttempt(
@@ -195,7 +192,7 @@ async def update_attempt(
db: AsyncSession,
attempt: ContestAttempt,
*,
answer: Optional[str] = None,
answer: str | None = None,
is_winner: bool = False,
) -> ContestAttempt:
"""Update existing attempt with answer and winner status."""
@@ -214,7 +211,7 @@ async def clear_attempts(db: AsyncSession, round_id: int) -> int:
return deleted_count
async def list_winners(db: AsyncSession, round_id: int) -> Sequence[Tuple[User, ContestAttempt]]:
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)
+49 -74
View File
@@ -1,13 +1,14 @@
import logging
from datetime import datetime
from typing import Optional, List
from sqlalchemy import select, and_
from datetime import UTC, datetime, timedelta
import structlog
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__)
logger = structlog.get_logger(__name__)
async def create_cryptobot_payment(
@@ -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,24 @@ 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(
'Создан CryptoBot платеж: на для пользователя',
invoice_id=invoice_id,
amount=amount,
asset=asset,
user_id=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 +60,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:
payment.updated_at = datetime.now(UTC)
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('Обновлен статус CryptoBot платежа', invoice_id=invoice_id, status=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()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(payment)
logger.info(f"Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}")
logger.info('Связан CryptoBot платеж с транзакцией', invoice_id=invoice_id, transaction_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 +119,13 @@ 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]:
from datetime import timedelta
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
async def get_pending_cryptobot_payments(db: AsyncSession, older_than_hours: int = 24) -> list[CryptoBotPayment]:
cutoff_time = datetime.now(UTC) - 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()
+44 -52
View File
@@ -1,9 +1,8 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import List, Optional
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -11,31 +10,32 @@ 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__)
logger = structlog.get_logger(__name__)
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."""
expires_at = datetime.utcnow() + timedelta(hours=valid_hours)
expires_at = datetime.now(UTC) + timedelta(hours=valid_hours)
result = await db.execute(
select(DiscountOffer)
.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,10 +113,10 @@ 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()
now = datetime.now(UTC)
stmt = (
select(DiscountOffer)
.options(
@@ -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,9 +159,9 @@ async def mark_offer_claimed(
db: AsyncSession,
offer: DiscountOffer,
*,
details: Optional[dict] = None,
details: dict | None = None,
) -> DiscountOffer:
offer.claimed_at = datetime.utcnow()
offer.claimed_at = datetime.now(UTC)
offer.is_active = False
await db.commit()
await db.refresh(offer)
@@ -171,34 +171,29 @@ 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,
details=details,
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer claim log for offer %s: %s",
offer.id,
exc,
)
logger.warning('Failed to record promo offer claim log for offer', offer_id=offer.id, exc=exc)
try:
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",
rollback_error,
'Failed to rollback session after promo offer claim log failure', rollback_error=rollback_error
)
return offer
async def deactivate_expired_offers(db: AsyncSession) -> int:
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(DiscountOffer).where(
DiscountOffer.is_active == True, # noqa: E712
DiscountOffer.is_active == True,
DiscountOffer.expires_at < now,
)
)
@@ -213,42 +208,39 @@ 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"),
exc,
'Failed to record promo offer disable log for offer', payload=payload.get('offer_id'), exc=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",
rollback_error,
'Failed to rollback session after promo offer disable log failure', rollback_error=rollback_error
)
return count
@@ -257,8 +249,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(
+22 -28
View File
@@ -1,19 +1,18 @@
import logging
from datetime import datetime
from typing import Iterable, Optional
from collections.abc import Iterable
from datetime import UTC, datetime
import structlog
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FaqPage, FaqSetting
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__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()
@@ -22,7 +21,7 @@ async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> Faq
if setting:
setting.is_enabled = bool(enabled)
setting.updated_at = datetime.utcnow()
setting.updated_at = datetime.now(UTC)
else:
setting = FaqSetting(
language=language,
@@ -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 для языка', page_id=page.id, language=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
@@ -120,12 +117,12 @@ async def update_faq_page(
if is_active is not None:
page.is_active = bool(is_active)
page.updated_at = datetime.utcnow()
page.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(page)
logger.info("✅ Страница FAQ %s обновлена", page.id)
logger.info('✅ Страница FAQ обновлена', page_id=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 удалена', page_id=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.now(UTC))
)
await db.commit()
+33 -47
View File
@@ -1,16 +1,16 @@
"""CRUD операции для платежей Freekassa."""
import json
import logging
from datetime import datetime
from typing import Optional, List
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FreekassaPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_freekassa_payment(
@@ -19,11 +19,11 @@ async def create_freekassa_payment(
user_id: int,
order_id: str,
amount_kopeks: int,
currency: str = "RUB",
description: Optional[str] = None,
payment_url: Optional[str] = None,
expires_at: Optional[datetime] = None,
metadata_json: Optional[str] = None,
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(
@@ -35,45 +35,31 @@ async def create_freekassa_payment(
payment_url=payment_url,
expires_at=expires_at,
metadata_json=json.loads(metadata_json) if metadata_json else None,
status="pending",
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}")
logger.info('Создан платеж Freekassa: order_id=, user_id', order_id=order_id, user_id=user_id)
return payment
async def get_freekassa_payment_by_order_id(
db: AsyncSession, order_id: str
) -> Optional[FreekassaPayment]:
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)
)
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
) -> Optional[FreekassaPayment]:
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
)
)
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
) -> Optional[FreekassaPayment]:
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)
)
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.id == payment_id))
return result.scalar_one_or_none()
@@ -83,18 +69,18 @@ async def update_freekassa_payment_status(
*,
status: str,
is_paid: bool = False,
freekassa_order_id: Optional[str] = None,
payment_system_id: Optional[int] = None,
callback_payload: Optional[dict] = None,
transaction_id: Optional[int] = None,
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()
payment.updated_at = datetime.now(UTC)
if is_paid:
payment.paid_at = datetime.utcnow()
payment.paid_at = datetime.now(UTC)
if freekassa_order_id:
payment.freekassa_order_id = freekassa_order_id
if payment_system_id is not None:
@@ -107,20 +93,20 @@ async def update_freekassa_payment_status(
await db.commit()
await db.refresh(payment)
logger.info(
f"Обновлен статус платежа Freekassa: order_id={payment.order_id}, "
f"status={status}, is_paid={is_paid}"
'Обновлен статус платежа Freekassa: order_id=, status=, is_paid',
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]:
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.status == 'pending',
FreekassaPayment.is_paid == False,
)
)
@@ -132,7 +118,7 @@ async def get_user_freekassa_payments(
user_id: int,
limit: int = 10,
offset: int = 0,
) -> List[FreekassaPayment]:
) -> list[FreekassaPayment]:
"""Получает платежи пользователя с пагинацией."""
result = await db.execute(
select(FreekassaPayment)
@@ -146,12 +132,12 @@ async def get_user_freekassa_payments(
async def get_expired_pending_payments(
db: AsyncSession,
) -> List[FreekassaPayment]:
) -> list[FreekassaPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(FreekassaPayment).where(
FreekassaPayment.status == "pending",
FreekassaPayment.status == 'pending',
FreekassaPayment.is_paid == False,
FreekassaPayment.expires_at < now,
)
+44 -53
View File
@@ -1,14 +1,15 @@
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from datetime import UTC, datetime
from typing import Any
import structlog
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__)
logger = structlog.get_logger(__name__)
async def create_heleket_payment(
@@ -20,13 +21,13 @@ async def create_heleket_payment(
amount: str,
currency: str,
status: str,
payer_amount: Optional[str] = None,
payer_currency: Optional[str] = None,
exchange_rate: Optional[float] = None,
discount_percent: Optional[int] = None,
payment_url: Optional[str] = None,
expires_at: Optional[datetime] = None,
metadata: Optional[Dict[str, Any]] = None,
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,
@@ -49,12 +50,12 @@ async def create_heleket_payment(
await db.refresh(payment)
logger.info(
"Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s",
uuid,
order_id,
amount,
currency,
user_id,
'Создан Heleket платеж: uuid= order_id= amount= для пользователя',
uuid=uuid,
order_id=order_id,
amount=amount,
currency=currency,
user_id=user_id,
)
return payment
@@ -63,11 +64,9 @@ async def create_heleket_payment(
async def get_heleket_payment_by_uuid(
db: AsyncSession,
uuid: str,
) -> Optional[HeleketPayment]:
) -> HeleketPayment | None:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.uuid == uuid)
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.uuid == uuid)
)
return result.scalar_one_or_none()
@@ -75,11 +74,9 @@ async def get_heleket_payment_by_uuid(
async def get_heleket_payment_by_order_id(
db: AsyncSession,
order_id: str,
) -> Optional[HeleketPayment]:
) -> HeleketPayment | None:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.order_id == order_id)
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.order_id == order_id)
)
return result.scalar_one_or_none()
@@ -87,11 +84,9 @@ async def get_heleket_payment_by_order_id(
async def get_heleket_payment_by_id(
db: AsyncSession,
payment_id: int,
) -> Optional[HeleketPayment]:
) -> HeleketPayment | None:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.id == payment_id)
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.id == payment_id)
)
return result.scalar_one_or_none()
@@ -100,19 +95,19 @@ async def update_heleket_payment(
db: AsyncSession,
uuid: str,
*,
status: Optional[str] = None,
payer_amount: Optional[str] = None,
payer_currency: Optional[str] = None,
exchange_rate: Optional[float] = None,
discount_percent: Optional[int] = None,
paid_at: Optional[datetime] = None,
payment_url: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[HeleketPayment]:
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)
logger.error('Heleket платеж с uuid= не найден', uuid=uuid)
return None
if status is not None:
@@ -134,17 +129,17 @@ async def update_heleket_payment(
if paid_at is not None:
payment.paid_at = paid_at
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(payment)
logger.info(
"Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s",
uuid,
payment.status,
payment.payer_amount,
payment.payer_currency,
'Обновлен Heleket платеж : статус= payer_amount',
uuid=uuid,
payment_status=payment.status,
payer_amount=payment.payer_amount,
payer_currency=payment.payer_currency,
)
return payment
@@ -154,23 +149,19 @@ async def link_heleket_payment_to_transaction(
db: AsyncSession,
uuid: str,
transaction_id: int,
) -> Optional[HeleketPayment]:
) -> HeleketPayment | None:
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error("Не найден Heleket платеж для связи с транзакцией: %s", uuid)
logger.error('Не найден Heleket платеж для связи с транзакцией', uuid=uuid)
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(payment)
logger.info(
"Heleket платеж %s связан с транзакцией %s",
uuid,
transaction_id,
)
logger.info('Heleket платеж связан с транзакцией', uuid=uuid, transaction_id=transaction_id)
return payment
+147
View File
@@ -0,0 +1,147 @@
"""CRUD операции для платежей KassaAI."""
import json
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import KassaAiPayment
logger = structlog.get_logger(__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('Создан платеж KassaAI: order_id=, user_id', 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.now(UTC)
if is_paid:
payment.paid_at = datetime.now(UTC)
if kassa_ai_order_id:
payment.kassa_ai_order_id = kassa_ai_order_id
if payment_system_id is not None:
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(
'Обновлен статус платежа KassaAI: order_id=, status=, is_paid',
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.now(UTC)
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:
+27 -42
View File
@@ -1,15 +1,14 @@
import logging
from datetime import datetime
from typing import Optional
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import MulenPayPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_mulenpay_payment(
@@ -19,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,
@@ -42,43 +41,29 @@ async def create_mulenpay_payment(
await db.refresh(payment)
logger.info(
"Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
settings.get_mulenpay_display_name(),
payment.mulen_payment_id,
uuid,
amount_kopeks,
user_id,
'Создан платеж # (uuid=) на сумму копеек для пользователя',
get_mulenpay_display_name=settings.get_mulenpay_display_name(),
mulen_payment_id=payment.mulen_payment_id,
uuid=uuid,
amount_kopeks=amount_kopeks,
user_id=user_id,
)
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()
@@ -87,11 +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,
metadata: Optional[dict] = 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:
@@ -105,7 +90,7 @@ async def update_mulenpay_payment_status(
if metadata is not None:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(payment)
return payment
@@ -118,7 +103,7 @@ async def update_mulenpay_payment_metadata(
metadata: dict,
) -> MulenPayPayment:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(payment)
return payment
@@ -131,7 +116,7 @@ async def link_mulenpay_payment_to_transaction(
transaction_id: int,
) -> MulenPayPayment:
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(payment)
return payment

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