Compare commits

..

363 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Changes in all registration paths:
- cmd_start: store referrer_id from campaign.partner_user_id
- language/rules/privacy handlers: skip referral code prompt when
  referrer_id already set from campaign
- channel check: pick up referrer_id from state instead of hardcoding None
2026-02-17 11:22:38 +03:00
Fringg d39063b22f fix: unassign all campaigns when revoking partner status
Previously revoke_partner only changed partner_status and commission,
leaving campaigns orphaned with invalid partner_user_id. Now sets
partner_user_id=NULL on all campaigns belonging to the revoked partner.
2026-02-17 11:11:25 +03:00
Fringg ea5d932476 feat: include partner campaigns in /partner/status response
Return assigned active campaigns with bonus info, deep_link and
web_link so the partner's referral page can display shareable links.
2026-02-17 10:45:11 +03:00
Fringg acc1323a54 fix: move PartnerStatus enum before User class to fix NameError
PartnerStatus was defined after the User class that references it,
causing a NameError on startup.
2026-02-17 09:56:11 +03:00
Fringg 58bfaeaddb feat: add partner system and withdrawal management to cabinet
- Partner application flow: user applies, admin reviews/approves/rejects
- Individual commission % per partner with admin management
- Campaign assignment/unassignment to partners
- Withdrawal system: balance check, create request, cancel
- Admin withdrawal management with risk scoring and fraud analysis
- Database migration: partner_applications table, user partner fields, campaign partner_user_id
- Pydantic schemas with proper validation bounds
- Batch user fetching to prevent N+1 queries
- Row locking on cancel to prevent race conditions
2026-02-17 09:51:36 +03:00
Fringg df5415f30b fix: reorder button_click_logs migration to nullify before ALTER TYPE
ALTER COLUMN user_id TYPE INTEGER failed with "integer out of range"
because the column contained telegram_id values (BIGINT) exceeding
INTEGER max. Swapped order: SET NULL first, then ALTER TYPE.
2026-02-17 08:19:21 +03:00
Egor 330d670f3f Merge pull request #2621 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.15.1
2026-02-17 07:55:52 +03:00
github-actions[bot] 41cb122a2f chore(main): release 3.15.1 2026-02-17 04:54:40 +00:00
Egor 1b3e6f2f11 Merge pull request #2620 from BEDOLAGA-DEV/dev
fix: add naive datetime guards to fromisoformat() in Redis cache readers
2026-02-17 07:54:20 +03:00
Fringg 6fa49485d9 fix: add naive datetime guards to fromisoformat() in Redis cache readers
Old Redis entries saved before utcnow→now(UTC) migration lack timezone
info, causing TypeError on subtraction with aware datetimes.
2026-02-17 07:52:26 +03:00
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
371 changed files with 29179 additions and 18401 deletions
+4
View File
@@ -16,6 +16,10 @@ __pycache__/
.pytest_cache/
.coverage
htmlcov/
.venv/
tests/
.mypy_cache/
.ruff_cache/
# Environment files
.env
+14 -6
View File
@@ -116,10 +116,8 @@ BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновле
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
# Обязательная подписка на канал
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
# Channel subscription settings (channels are managed via admin panel)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
@@ -371,7 +369,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
@@ -384,6 +383,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 = основной чат)
@@ -629,6 +630,13 @@ FREEKASSA_WEBHOOK_PORT=8088
FREEKASSA_PAYMENT_SYSTEM_ID=
# Использовать API для создания заказов (обязательно для NSPK СБП)
FREEKASSA_USE_API=false
# Раздельные методы оплаты (отображаются как отдельные кнопки)
# СБП (QR код) — i=44
FREEKASSA_SBP_ENABLED=false
FREEKASSA_SBP_DISPLAY_NAME=СБП (QR код)
# Карты РФ — i=36
FREEKASSA_CARD_ENABLED=false
FREEKASSA_CARD_DISPLAY_NAME=Карта РФ
# ===== KASSA AI (api.fk.life) =====
# Отдельная платёжная система, работает параллельно с Freekassa
@@ -802,8 +810,6 @@ PRICE_ROUNDING_ENABLED=true
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
APP_CONFIG_PATH=app-config.json
ENABLE_DEEP_LINKS=true
APP_CONFIG_CACHE_TTL=3600
@@ -845,6 +851,8 @@ VERSION_CHECK_INTERVAL_HOURS=1
# ===== ЛОГИРОВАНИЕ =====
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
# ANSI-цвета в консоли (true — цветной вывод с Rich, false — plain-text)
LOG_COLORS=true
# === Ротация логов ===
# Включить новую систему ротации (по умолчанию старое поведение)
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 0
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 0
+3 -3
View File
@@ -10,11 +10,11 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v7
- uses: astral-sh/setup-uv@v5
- uses: actions/setup-python@v6
- uses: actions/setup-python@v5
with:
python-version: '3.13'
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
-1
View File
@@ -16,7 +16,6 @@
!uv.lock
!requirements.txt
!alembic.ini
!app-config.json
!release-please-config.json
!.release-please-manifest.json
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.12.1"
".": "3.24.0"
}
+817
View File
@@ -1,5 +1,822 @@
# Changelog
## [3.24.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.23.2...v3.24.0) (2026-03-07)
### New Features
* account linking and merge system for cabinet ([dc7b8dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc7b8dc72a3a398d6270a0a2b8ce9e2b54cb9af7))
* account merge system — atomic user merge with full FK coverage ([2664b49](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2664b4956d8436a2720d7cd5992b8cdbb72cdbd9))
* add 'default' (no color) option for button styles ([10538e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10538e735149bf3f3f2029ff44b94d11d48c478e))
* add admin campaign chart data endpoint with deposits/spending split ([fa7de58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa7de589c1bd0ae37ebaaa07bae0ed3d68e01720))
* 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 admin sales statistics API with 6 analytics endpoints ([58faf9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58faf9eaeca63c458093d2a5e74a860f57712ab0))
* add admin topic notifications for landing page purchases ([dbb9757](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dbb9757a3c7938ab7505358942f675b82401245a))
* add all remaining RemnaWave webhook events (node, service, crm, device) ([1e37fd9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e37fd9dd271814e644af591343cada6ab12d612))
* 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 cabinet admin API for pinned messages management ([1a476c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a476c49c19d1ec2ab2cda1c2ffb5fd242288bb6))
* add campaign_id to ReferralEarning for campaign attribution ([0c07812](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c07812ecc9502f54a7745a77b086fc52bdc0e34))
* add ChatTypeFilterMiddleware to ignore group/forum messages ([25f014f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25f014fd8988b5513fba8fec4483981384687e96))
* add close button to all webhook notifications ([d9de15a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9de15a5a06aec3901415bdfd25b55d2ca01d28c))
* add daily deposits by payment method breakdown ([d33c5d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d33c5d6c07ce4a9efaf3c5aceb448e968e1b8ed7))
* add daily device purchases chart to addons stats ([2449a5c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2449a5cbbe5179a762197414a5752896383a6ee4))
* add dedicated sales_stats RBAC permission section ([8f29e2e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f29e2eee2e0c78f7f7e87a322eaf4bd4221069c))
* add desired commission percent to partner application ([7ea8fbd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ea8fbd584aff2127595001094ef69acb52f847f))
* add discount system for landing pages ([aa7d986](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa7d98630dd9be2cfb81dac3ef2c1c6730487e61))
* add external squad support for tariffs ([c10d678](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c10d6780ba89ac641769dcb0c4ab2d89f124f0b7))
* add GET /admin/rbac/users endpoint for listing all RBAC users ([8b77cda](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b77cdae2ccc489bfead89523f31cd15bfdc675b))
* add granular user permissions (balance, subscription, promo_group, referral, send_offer) ([60c4fe2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/60c4fe2e239d8fef7726cac769711c8fcce789eb))
* add landings to permission registry ([c93dbec](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c93dbec7a0e24a6cc41449ed3c6e5fb669b127a9))
* add lite mode functionality with endpoints for retrieval and update ([7b0403a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b0403a307702c24efefc5c14af8cb2fb7525671))
* add LOG_COLORS env setting to toggle console ANSI colors ([27309f5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27309f53d9fa0ba9a2ca07a65feed96bf38f470c))
* add MULENPAY_WEBSITE_URL setting for post-payment redirect ([fe5f5de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe5f5ded965e36300e1c73f25f16de22f84651ad))
* add multi-channel mandatory subscription system ([8375d7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8375d7ecc5e54ea935a00175dd26f667eab95346))
* add partner system and withdrawal management to cabinet ([58bfaea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58bfaeaddbcbb98cb67dbd507847a0e5c8d07809))
* 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-channel disable settings and fix CHANNEL_REQUIRED_FOR_ALL bug ([3642462](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3642462670c876052aa668c1515af8c04234cb34))
* add per-section button style and emoji customization via admin API ([a968791](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9687912dfe756e7d772d96cc253f78f2e97185c))
* add Persian (fa) locale with complete translations ([29a3b39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29a3b395b6e67e4ce2437b75120b78c76b69ff4f))
* add POST /auth/telegram/oidc endpoint for OIDC popup flow ([3a400d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a400d9f8b3b4dd2c0bb12fc68f1af6e7c880761))
* add quick purchase email templates to admin panel ([6970340](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6970340e62c67a41f3219759fb0a752617690ea0))
* add RBAC + ABAC permission system for admin cabinet ([3fee54f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3fee54f657dc6e0db1ec36697850ada2235e6968))
* add referral code tracking to all cabinet auth methods + email_templates migration ([18c2477](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18c24771737994f3ae1f832435ed2247ca625aab))
* add RemnaWave incoming webhooks for real-time subscription events ([6d67cad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d67cad3e7aa07b8490d88b73c38c4aca6b9e315))
* add required channels button to admin settings submenu in bot ([3af07ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3af07ff627fc354da4f8c41b0bd0575dddd9afa5))
* add RESET_TRAFFIC_ON_TARIFF_SWITCH admin setting ([4eaedd3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4eaedd33bf697469fe9ed6a1bfe8b59ca43b46fb))
* add resource_type and request body to audit log entries ([388fc7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/388fc7ee67f5fc0edf6b7b64b977e12a2d8f0566))
* add separate Freekassa SBP and card payment methods ([0da0c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0da0c5547d0648a70f848fe77c13d583f4868a52))
* add server-complete OAuth linking endpoint for Mini App flow ([f867989](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f867989557d20378cfe815c9c88e1a842c4f6654))
* 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))
* add sub_options support for landing page payment methods ([220196f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/220196fb7abc88b60a37c1fb60786dd3a6ada3ad))
* add Telegram account linking endpoint with security hardening ([da40d56](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da40d5662d6d064090769823d616d6f9748ab5b9))
* add Telegram OIDC id_token validation and code exchange ([2f0a9dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f0a9dc4f3489f7d4311101191129ee95d7edbcc))
* add TELEGRAM_OIDC_* settings for new Telegram Login ([833df51](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/833df518d010d1bfd773eb0c85aaa7e653c7e153))
* add validation to animation config API ([a15403b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a15403b8b6e1ec1bb5c37fdde646e7790373e860))
* add web admin button for admins in cabinet mode ([9ac6da4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ac6da490dffa03ce823009c6b4e5014b7d2bdfb))
* add web campaign links with bonus processing in auth flow ([d955279](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9552799c17a76e2cc2118699528c5b591bd97fb))
* allow editing system roles ([f6b6e22](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f6b6e22a9528dc05b7fbfa80b63051a75c8e73cd))
* allow tariff deletion with active subscriptions ([ebd6bee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebd6bee05ed7d9187de9394c64dfd745bb06b65a))
* 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))
* capture query params in audit log details for all requests ([bea9da9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bea9da96d44965fcee5e2eba448960443152d4ea))
* colored channel subscription buttons via Bot API 9.4 style ([0b3b2e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b3b2e5dc54d8b6b3ede883d5c0f5b91791b7b9b))
* colored console logs via structlog + rich + FORCE_COLOR ([bf64611](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf646112df02aa7aa7918d0513cb6968ceb7f378))
* configurable Telegram Login Widget with admin settings ([084a3cd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/084a3cd16f8825c389514813ba679748ba235d0a))
* enforce 1-to-1 partner-campaign binding with partner info in campaigns ([366df18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/366df18c547047a7c69192c768970ebc6ee426fc))
* enhance sales stats with device purchases, per-tariff daily breakdown, and registration tracking ([31c7e2e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31c7e2e9c14cb88762a62a72e4f65051e0c6c1fd))
* expose oidc_enabled and oidc_client_id in telegram-widget config ([000b0c0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/000b0c0592773d0a5f6f572fd8a721ce0f474b2c))
* expose payment sub-options with labels in public landing API ([c53e9af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c53e9af744114e5d6fe014b09b4fac8da1e59c6e))
* 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))
* guest purchase → cabinet account integration ([f8edfd7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f8edfd77463aad64d9e616569467b4883be4dccf))
* guest purchase delivery & activation system ([776fc3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/776fc3aadc14e1cc415286cf008fa4eb85f21164))
* 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))
* 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))
* **localization:** add Persian (fa) locale support and wire it across app flows ([cc54a7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc54a7ad2fb98fe6e662e1923027f4989ae72868))
* notify users on partner/withdrawal approve/reject ([327d4f4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/327d4f4d1559e37dc591adbfd0c839d986d1068d))
* register TELEGRAM_OIDC category, hints in admin settings ([3a36162](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a361628aa543cb629d6967d84d7f474b89c3841))
* 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))
* replace pip with uv in Dockerfile ([e23d69f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e23d69fcec7ab65a14b054fd46f6ecf87ae6fd13))
* rework guide mode with Remnawave API integration ([5a269b2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a269b249e8e6cad266822095676937481613f5f))
* show all active webhook endpoints in startup log ([9d71005](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d710050ad40ba76a14aa6ace8e8a47f25cdde94))
* 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))
* мультиязычные лендинги + гостевые платежи для всех провайдеров ([6deab7d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6deab7dd8c5c5df812bd69608369258a10a67ca4))
* публичные лендинг-страницы для быстрой покупки VPN-подписок ([5e404cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5e404cc082859d875f988911fcc4eedaa35b886b))
### Bug Fixes
* 3 user deletion bugs — type cast, inner savepoint, lazy load ([af31c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af31c551d2f23ef01425bdb2db8f255dbc3047e2))
* abs() for transaction amounts in admin notifications and subscription events ([fd139b2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fd139b28a2c45cc3fbd2e01707fb83fbabf57c71))
* add /start burst rate-limit to prevent spam abuse ([61a9722](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61a97220d30031816ab23e33a46717e4895c0758))
* add abs() to expenses query, display flip, contest stats, and recent payments ([de6f806](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de6f80694ba8aa240764e2769ec04c16fe7f3672))
* add action buttons to webhook notifications and fix empty device names ([7091eb9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7091eb9c148aaf913c4699fc86fef5b548002668))
* add activate hint to gift pending activation email link ([fa21549](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa21549cac9098f49e2e32868acce461acd1b40d))
* add blocked_count column migration to universal_migration.py ([b4b10c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4b10c998cadbb879540e56dbd0e362b5497ee57))
* add diagnostic logging for device_limit sync to RemnaWave ([97b3f89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97b3f899d12c4bf32b6229a3b595f1b9ad611096))
* add exc_info traceback to sync user error log ([efdf2a3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efdf2a3189a2f790e570f9a6e19d91469be4ea4f))
* add int32 overflow guards and strengthen auth validation ([50a931e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/50a931ec363d1842126b90098f93c6cae47a9fac))
* add IntegrityError handling on link commit and format fixes ([0c1dc58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c1dc580c67254d11ffb096c22d8c8d78ac18e2b))
* add local traffic_used_gb reset in all tariff switch handlers ([2cdbbc0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cdbbc09ba9a19dcb720049ffde08ba780ac5751))
* add Message-ID and Date headers to outgoing emails ([de541ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de541ea1c3fa20c606c0ea1b69a0223569afb9e2))
* add Message-ID and Date headers to outgoing emails ([e9b4d8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e9b4d8e444be9ab666caf642c849dcf63b1884ab))
* 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))
* add min_length to state field, use exc_info for referral warning ([062c486](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/062c4865db194f9d2242772044402fa2711a69bd))
* add missing broadcast_history columns and harden subscription logic ([d4c4a8a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d4c4a8a211eaf836024f8d9dcb725f25f514f05e))
* add missing CHANNEL_CHECK_NOT_SUBSCRIBED localization key ([a47ef67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a47ef67090c4e48f466286f7c676eeee0c61a4fb))
* add missing mark_as_paid_subscription, fix operation order, remove dead code ([5f2d855](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f2d855702dea838b38887a5f44b9ad759acd5cf))
* 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 missing placeholders to Arabic SUBSCRIPTION_INFO template ([fe54640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe546408857128649930de9473c7cde1f7cc450a))
* add missing subscription columns migration ([b96e819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b96e819da4cc37710e9fc17467045b33bcffac4d))
* 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))
* add naive datetime guards to parsers and fix test datetime literals ([0946090](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/094609005af7358bf5d34d252fc66685bd25751c))
* 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 pending_activation to purchase stats and show total count ([8510597](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8510597ddb501c479d5b70118a94944556ab984f))
* add promo code anti-abuse protections ([97ec39a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97ec39aa803f0e3f03fdcd482df0cbcb86fd1efd))
* add referral_code pattern validation, email login rate limiting, and Retry-After headers ([5499ad6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5499ad62dc98346bef9cb83bf6d8bca319291371))
* add selectinload for campaign registrations in list query ([4d74afd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4d74afd7118524623371f904a93ae1fcbba8d64e))
* add selectinload for subscription in campaign user list ([eb9dba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb9dba3f4728b478f2206ff992700a9677f879c7))
* add startup warning for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE in guide mode ([1d43ae5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d43ae5e25ffcf0e4fe6fec13319d393717e1e50))
* add X-CSRF-Token and X-Telegram-Init-Data to CORS allow_headers ([77456ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/77456efb7504e12c9b9879a352118ce1687132b1))
* address code review findings for Telegram OIDC ([da1cc4f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da1cc4fe5ab6436210185a12dc2a82cb153fc24a))
* address code review issues in guide mode rework ([fae6f71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fae6f71def421e319733e4edcf1ca80a2831b2ec))
* address RBAC review findings (CRITICAL + HIGH) ([1646f04](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1646f04bde47a08f3fd782b7831d40760bd1ba60))
* address remaining abs() issues from review ([ff21b27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff21b27b98bb5a7517e06057eb319c9f3ebb74c7))
* address review findings for guest purchase admin notifications ([770f19e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/770f19e84688e55ed44f7c9de26b0e9ae9636c4b))
* address review findings from agent verification ([cc5be70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc5be7059fdf4cefb01e97196c825b217f8b54b3))
* address review issues in backup, updates, and webhook handlers ([2094886](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20948869902dc570681b05709ac8d51996330a6e))
* address security review findings ([6feec1e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6feec1eaa847644ba3402763a2ffefd8f770cc01))
* align RBAC route prefixes with frontend API paths ([5a7dd3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a7dd3f16408f3497a9765e79a540ccdabc50e69))
* allow email change for unverified emails ([93bb8e0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93bb8e0eb492ca59e29da86594e84e9c486fea65))
* allow non-HTTP deep links in crypto link webhook updates ([f779225](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f77922522a85b3017be44b5fc71da9c95ec16379))
* allow purchase when recalculated price is lower than cached ([19dabf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/19dabf38512ae0c2121108d0b92fc8f384292484))
* allow tariff switch when less than 1 day remains ([67f3547](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67f3547ae2f40153229d71c1abe7e1213466e5c3))
* always include details in successful audit log entries ([3dc0b93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3dc0b93bdfc85fb97f371dc34e024272766afc65))
* AttributeError in withdrawal admin notification (send_to_admins → send_admin_notification) ([c75ec0b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c75ec0b22a3f674d3e1a24b9d546eca1998701b3))
* auth middleware catches all commit errors, not just connection errors ([6409b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6409b0c023cd7957c43d5c1c3d83e671ccaf959c))
* auto-convert naive datetimes to UTC-aware on model load ([f7d33a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7d33a7d2b31145a839ee54676816aa657ac90da))
* auto-update permissions for system roles on bootstrap ([eff74be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eff74bed5bcc47a6cfa05c20cad14a40c1572d1f))
* backup restore fails on FK constraints and transaction poisoning ([ff1c872](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff1c8722c9188fdbaf765d6b7e9192686df64850))
* build composite device name from platform + hwid short suffix ([17ce640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17ce64037f198837c8f2aa7bf863871f60bdf547))
* callback routing safety and cache invalidation order ([6a50013](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a50013c21de199df0ba0dab3600b693548b6c1e))
* campaign web link uses ?campaign= param, not ?start= ([28f524b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28f524b7622ed975d2fece66edc94d9713354738))
* cap expected_monthly_referrals to prevent int32 overflow ([2ef6185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2ef618571570edb6011a365af8aa9cd7e3348c2e))
* centralize balance deduction and fix unchecked return values ([0466528](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0466528925a24087b8522a10cbb11c947c2b7d91))
* centralize has_had_paid_subscription into subtract_user_balance ([e4a6aad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4a6aad621be7ef4e7aedb21373927ede0c8d0a5))
* change CryptoBot URL priority to bot_invoice_url for Telegram opening ([3193ffb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3193ffbd1bee07cb79824d87cb0f77b473b22989))
* classic mode prices overridden by active tariff prices ([628a99e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/628a99e7aa0812842dabc430857190c0cd5c2680))
* clean email verification and password fields from secondary user during merge ([7b4e948](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b4e9488f6fbd1271f063579e48ca9a3c96cb645))
* clean stale squad UUIDs from tariffs during server sync ([fcaa9df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcaa9dfb27350ceda3765c6980ad67f671477caf))
* clear subscription data when user deleted from Remnawave panel ([b0fd38d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b0fd38d60c22247a0086c570665b92c73a060f2f))
* close remaining daily subscription expire paths ([618c936](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/618c936ac9ce4904cd784bf2278d3da188895f2d))
* code style and formatting from review ([a539d69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a539d698546a60aa0a06759f91c77476380a20b1))
* complete datetime.utcnow() → datetime.now(UTC) migration ([eb18994](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18994b7d34d777ca39d3278d509e41359e2a85))
* complete FK migration — add 27 missing constraints, fix broadcast_history nullable ([fe393d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe393d2ca6ce302d8213cc751842ea92ef277e76))
* comprehensive security and quality fixes from 7-agent review ([5c55662](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5c55662e2c7068456aeee435b543a851225ff39e))
* comprehensive security hardening from 7-agent review ([e96fe1e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e96fe1ecd8d90878a3fbad9ed76c1a2e7f3a1415))
* connected_squads stores UUIDs, not int IDs — use get_server_ids_by_uuids ([d7039d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7039d75a47fbf67436a9d39f2cd9f65f2646544))
* consume promo offer in miniapp tariff-mode renewal path ([b8857e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b8857e789ef60cf0c8766abbeadd094f62070a61))
* consume promo offer in tariff_purchase.py, fix negative transaction amount ([c8ef808](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c8ef80853915af3e3eb254edd07d8d78b66a9282))
* correct broadcast button deep-links for cabinet mode ([e5fa45f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e5fa45f74f969b84f9f1388f8d4888d22c46d7e8))
* correct cart notification after balance top-up ([2fab50c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fab50c340c885fc92a4bf797a4b03da6e44af31))
* correct referral withdrawal balance formula and commission transaction type ([83c6db4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83c6db48349440447305604e944fa440bdceb3fb))
* correct subscription_service import in broadcast cleanup ([6c4e035](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c4e035146934dffb576477cc75f7365b2f27b99))
* count sales from completed payment transactions instead of subscription created_at ([06c3996](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06c3996da4fa14eafb294651158068c7cda51e52))
* critical OIDC fixes from 7-agent review ([b78c01c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b78c01cae9746275057aaf61c0876ccfd72e1f62))
* critical security and data integrity fixes for partner system ([8899749](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88997492c3534ea2f6e194c0382c77302557c2f3))
* cross-validate Telegram identity on every authenticated request ([973b3d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/973b3d3d3ff80376c0fd19c531d7aac3ae751df8))
* CryptoBot guest payment — remove is_paid [@property](https://github.com/property) write, use correct status ([6f871ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f871edc9d01ca20d1b194a157d3d6ae46512d05))
* daily tariff subscriptions stuck in expired/disabled with no resume path ([80914c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/80914c1af739aa0ee1ea75b0e5871bf391b9020d))
* deadlock on user deletion + robust migration 0002 ([b7b83ab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7b83abb723913b3167e7462ff592a374c3f421b))
* delete cross-referral earnings before bulk reassignment, clear secondary.referred_by_id ([f204b67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f204b678803297ce60faad628d16f46344b11ed0))
* delete subscription_servers before subscription to prevent FK violation ([7d9ced8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d9ced8f4f71b43ed4ac798e6ff904a086e1ac4a))
* device_limit fallback 1→0 для корректного отображения безлимита ([3e26832](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3e26832e745368a0dab2617e4e8ae2c410c6bca2))
* don't delete Heleket invoice message on status check ([9943253](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/994325360ca7665800177bfad8f831154f4d733f))
* downgrade Telegram timeout errors to warning in monitoring service ([e43a8d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e43a8d6ce4c40a7212bf90644f82da109717bdcb))
* downgrade transient API errors (502/503/504) to warning level ([ec8eaf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ec8eaf52bfdc2bde612e4fc0324575ba7dc6b2e1))
* eliminate deadlock by matching lock order with webhook ([d651a6c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d651a6c02f501b7a0ded570f2db6addcc16173a9))
* eliminate double panel API call on tariff change, harden cart notification ([b2cf4aa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2cf4aaa91f3fb63dca7e70645cadb75aa158cfe))
* eliminate referral system inconsistencies ([60c97f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/60c97f778bc4cc18aaf4d8a31826bc831c3b3f8f))
* email verification bypass, ban-notifications size limit, referral balance API ([256cbfc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/256cbfcadfd2fc88d8de69557c78618639af157d))
* empty JSONB values exported as None in backup ([57aaca8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57aaca82f5bf9d7bdd9d4b924aa3412d85eccbb5))
* enforce user restrictions in cabinet API and fix poll history crash ([faba3a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/faba3a8ed6d428305f9ca7d7fd9bdcc1fd72ba52))
* expand backup coverage to all 68 models and harden restore ([02e40bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e40bd6f7ef8e653cae53ccd127f2f79009e0d4))
* extend naive datetime guard to all model properties ([bd11801](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd11801467e917d76005d1a782c71f5ae4ffee6e))
* extract device name from nested hwidUserDevice object ([79793c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79793c47bbbdae8b0f285448d5f70e90c9d4f4b0))
* extract real client IP from X-Forwarded-For/X-Real-IP headers ([af6686c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af6686ccfae12876e867cdabe729d0c893bd85a1))
* filter out traffic packages with zero price from purchase options ([64a684c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64a684cd2ff51e663a1f70e61c07ca6b4f6bfc91))
* flood control handling in pinned messages and XSS hardening in HTML sanitizer ([454b831](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454b83138e4db8dc4f07171ee6fe262d2cd6d311))
* force basicConfig to replace pre-existing handlers ([7eb8d4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7eb8d4e153bab640a5829f75bfa6f70df5763284))
* freekassa OP-SP-7 error and missing telegram notification ([200f91e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/200f91ef1748bb6213d1ef3a8e83ae976290a8a7))
* from redis.exceptions import NoScriptError ([667291a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/667291a2dcaeae21e27eeb6376085e69caa4e45a))
* generate missing crypto link on the fly and skip unresolved templates ([4c72058](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c72058d4ad8b0594991b17323928d9004803bfa))
* grant legacy config-based admins full RBAC access ([8893fc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8893fc128e3d8927054f1df1647e896e780c69e7))
* handle duplicate remnawave_uuid on email sync ([eaeee7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaeee7a765c03ff33e2928cdb41be91948eca95c))
* handle expired callback queries and harden middleware error handling ([f52e6ae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f52e6aedac3de1c9bb2ad1a5a16b06d38b79ab63))
* handle expired ORM attributes in sync UUID mutation ([9ae5d7b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ae5d7bb60c57e2c29d6f3c5098c23450d5feb61))
* handle naive datetime in raw SQL row comparison (payment/common) ([38f3a9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38f3a9a16a24e85adf473f2150aad31574a87060))
* handle naive datetimes in Subscription properties ([e512e5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e512e5fe6e9009992b5bc8b9be7f53e0612f234a))
* handle NULL used_promocodes for migrated users ([cdcabee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cdcabee80d1d7f0b367a97cdec20bb49e8592115))
* handle nullable traffic_limit_gb and end_date in subscription model ([e94b93d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e94b93d0c10b4e61d7750ca47e1b2f888f5873ed))
* handle photo message in ticket creation flow ([e182280](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1822800aba3ea5eee721846b1e0d8df0a9398d1))
* handle RemnaWave API errors in traffic aggregation ([ed4624c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed4624c6649bdbc04bc850ef63e5c86e26a37ce4))
* handle StaleDataError in webhook user.deleted server counter decrement ([c30c2fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30c2feee1db03f0a359b291117da88002dd0fe0))
* handle StaleDataError in webhook when user already deleted ([d58a80f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d58a80f3eaa64a6fc899e10b3b14584fb7fc18a9))
* handle tariff_extend callback without period (back button crash) ([ba0a5e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba0a5e9abd9bd582968d69a5c6e57f336094c782))
* handle TelegramBadRequest in ticket edit_message_text calls ([8e61fe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e61fe47746da2ac09c3ea8c4dbfc6be198e49e3))
* handle time/date types in backup JSON serialization ([27365b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27365b3c7518c09229afcd928f505d0f3f66213f))
* handle unique constraint conflicts during backup restore without clear_existing ([5893874](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/589387477624691e0026086800428e7e52e06128))
* handle YooKassa NotFoundError gracefully in get_payment_info ([df5b1a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5b1a072d99ff8aee0c94304b2a0214f0fcffe7))
* harden account merge security and correctness ([d855e9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d855e9e47fab1a038e581437a9921bdfeb11e927))
* harden backup create/restore against serialization and constraint errors ([fc42916](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fc42916b10bb698895eb75c0e2568747647555d3))
* hide traffic topup button when tariff doesn't support it ([399ca86](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/399ca86561f4271e9c542bac87c0dd2931a223e0))
* HTML parse fallback, email change race condition, username length limit ([d05ff67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d05ff678abfacaa7e55ad3e55f226d706d32a7b7))
* HTML-escape all externally-sourced text in guide messages ([711ec34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/711ec344c646844401f355695a7e8c0d4fb401ee))
* ignore 'message is not modified' on privacy policy decline ([be1da97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be1da976e14a35e6cca01a7fca7529c55c1a208b))
* improve campaign notifications and ticket media in admin topics ([a594a0f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a594a0f79f48227f75d6102b4586179102c4d344))
* improve campaign routes, schemas, and add database indexes ([ded5c89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ded5c899f7425707b17fef4d0d5ceafac777ef08))
* improve deduplication log message wording in monitoring service ([2aead9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2aead9a68b6bf274c8d1497c85f2ed4d4fc9c70b))
* include desired_commission_percent in admin notification ([dc3d22f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc3d22f52db40150d595bccf524d38790e5725d9))
* initialize logger in bot_configuration.py ([988d0e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/988d0e5c2f27538135d757187a0b6770f078b1d9))
* invalidate app config cache on local file saves ([978726a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/978726a7856cf56257c49491afe569fa8c395eac))
* limit Rich traceback output to prevent console flood ([11ef714](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11ef714e0dde25a08711c0daeee943b6e71e20b7))
* make migration 0002 robust with table existence checks ([f076269](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f076269c323726c683a38db092d907591a26e647))
* make migrations 0010/0011 idempotent, escape HTML in crash notification ([a696896](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a696896d2c4a3d0d6026398fcdc76ded9575375d))
* make users.promo_group_id nullable — sync DB with model ([e0f2243](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0f2243f49ca8cc741a5c07b63ef3eb2abdef52c))
* medium-priority fixes for partner system ([7c20fde](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c20fde4e887749d72280a8804467645e5bab416))
* **merge:** validate before consuming token, add flush, defensive balance ([bc1e6fb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc1e6fb22c6e23c7a34364796f51a55c60224aff))
* migrate all remaining naive timestamp columns to timestamptz ([708bb9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/708bb9eec7ea4360b26709fb2a3f82dd139ed600))
* migrate VK OAuth to VK ID OAuth 2.1 with PKCE ([1dfa780](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1dfa78013c4fb926a2b32bf4d63baa28215e7340))
* MissingGreenlet on campaign registrations access ([018f18f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/018f18fa0c9bba1a1dbca8b2398b9611d0c94c36))
* move PartnerStatus enum before User class to fix NameError ([acc1323](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acc1323a542b8e92433cabf1334d2d98bfa21e21))
* NameError in set_user_devices_button — undefined action_text ([1b8ef69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b8ef69a1bbb7d8d86827cf7aaa4f05cbf480d75))
* negative balance transfer, linking state validation, referrer migration ([531d5cf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/531d5cff3019e72dde6ee64977cb801e8f8c8d0b))
* normalize transaction amount signs across all aggregations ([4247981](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4247981c98111af388c98628c1e61f0517c57417))
* nullify payment FK references before deleting transactions in user restoration ([0b86f37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b86f379b4e55e499ca3d189137e2aed865774b5))
* partner system — CRUD nullable fields, per-campaign stats, atomic unassign, diagnostic logging ([ed3ae14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed3ae14d0c378fa0dc2d442c3aa5a70172f3132c))
* pass return_url to all payment providers for guest purchases ([b85646a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b85646af85c4b2036f1c07c89e3e282f74d43c1e))
* payment race conditions, balance atomicity, renewal rollback safety ([c5124b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c5124b97b63eda59b52d2cbf9e2dcdaa6141ed6e))
* photo handling in QR messages ([1afcd84](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1afcd84e0ed2c39abd674170b8b17e6c7ee8754d))
* pre-existing bugs found during review ([1bb939f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bb939f63a360a687fafba26bc363024df0f6be0))
* 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))
* preserve connected_squads during subscription replacement cleanup ([d86c29a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d86c29a5d384db1d11ef3666153fa288d0c822d8))
* preserve payment initiation time in transaction created_at ([90d9df8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90d9df8f0e949913f09c4ebed8fe5280453ab3ab))
* preserve purchased traffic when extending same tariff ([b167ed3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b167ed3dd1c6e6239db2bdbb8424bcb1fb7715d9))
* prevent 'caption is too long' error in logo mode ([6e28a1a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6e28a1a22b02055b357051dfecbee7fefbebc774))
* prevent cascading greenlet errors after sync rollback ([a1ffd5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1ffd5bda6b63145104ce750835d8e6492d781dc))
* prevent concurrent device purchases exceeding max device limit ([1cfede2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cfede28b7570bcaf77cb53d6b2a9f3b0e4e9408))
* prevent daily subscriptions from being expired by middleware/CRUD/webhook ([0ed6397](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ed6397fa9e5810fcffc9152ab2241fcf37cf85a))
* prevent fileConfig from destroying structlog handlers ([e78b104](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e78b1040a50ac14759bceab396d0c3e34dd79cdd))
* prevent infinite reuse of first_purchase_only promo code discounts ([2cec8dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cec8dc4a487017f4b1c5ca80710f2d70045b825))
* prevent negative amounts in spent display and balance history ([c30972f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30972f6a7911a89a6c3f2080019ff465d11b597))
* prevent partner self-referral via own campaign link ([115c0c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/115c0c84c0698591da75d7d3b8fbd8e0fc8541ea))
* prevent race condition expiring active daily subscriptions ([bfef7cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfef7cc6296e296f17068e519469c3deaddc1b3b))
* prevent self-referral loops, invalidate all sessions on merge ([db61365](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db61365e11ccec4dd45671b33da00f4b05484589))
* prevent squad drop on admin subscription type change, require subscription for wheel spins ([59f0e42](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59f0e42be7e3c679d15cf2fc6820ab7097cd2201))
* prevent sync from overwriting end_date for non-ACTIVE panel users ([49871f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49871f82f37d84979ea9ec91055e3f046d5854be))
* prevent sync from overwriting subscription URLs with empty strings ([9c00479](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9c004791f28fbcf314b93c1b2a38593069605239))
* promo code max_uses=0 conversion and trial UX after promo activation ([1cae713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cae7130bc87493ab8c7691b3c22ead8189dab55))
* protect active paid subscriptions from being disabled in RemnaWave ([1b6bbc7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b6bbc7131341b4afd739e4195f02aa956ead616))
* protect server counter callers and fix tariff change detection ([bee4aa4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bee4aa42842b8b6611c7c268bcfced408a227bc0))
* RBAC API response format fixes and audit log user info ([4598c27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4598c2785a42773ee8be04ada1c00d14824e07e0))
* RBAC audit log action filter and legacy admin level ([c1da8a4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c1da8a4dba5d0c993d3e15b2866bdcfa09de1752))
* read discount overrides from landing model instead of response DTO ([6d65e15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d65e152669a7e92f93f592993c1d5507b890046))
* read OIDC enabled setting from DB in auth endpoint ([2405dc5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2405dc5c1b6d6266da373e0e4dac6444b0e70a03))
* reassign orphaned records on merge, eliminate TOCTOU race ([d7a9d2b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7a9d2bfba5b796882d3e04be6038b766cd0a4c8))
* redis cache uses sync client due to import shadowing ([667291a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/667291a2dcaeae21e27eeb6376085e69caa4e45a))
* reject promo codes for days when user has no subscription or trial ([e32e2f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e32e2f779d014d587b58d63b513fd913ae1b7a41))
* remove [@username](https://github.com/username) channel ID input, auto-prefix -100 for bare digits ([a7db469](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7db469fd7603e7d8dac3076f5d633da654a3a57))
* remove decorative cloudpayments sub-options ([694aecc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/694aeccc3121116bf193b5766572de7472eb4016))
* remove DisplayNameRestrictionMiddleware ([640da34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/640da3473662cfdcceaa4346729467600ac3b14f))
* remove executable bit from email_service.py ([372d628](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/372d628908294d905c37828219cac6aef7941151))
* remove gemini-effect and noise from allowed background types ([731eb24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/731eb2436428d0e12f1e5ccdebc72cd74fd7c65e))
* remove local UTC re-imports shadowing module-level import in purchase.py ([e68760c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e68760cc668016209f4f19a2e08af8680343d6ed))
* remove premature tariff_id assignment in _apply_extension_updates ([b47678c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b47678cfb0ba5897b37dfe1f94e3d1336af5698e))
* remove redundant trial inactivity monitoring checks ([d712ab8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d712ab830166cab61ce38dd32498a8a9e3e602b0))
* remove subscription connection links from guest purchase emails ([9217352](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9217352685189118620f1246bc7d7a4459883ed6))
* remove unused PaymentService from MonitoringService init ([491a7e1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/491a7e1c425a355e55b3020e2bcc7b96047bdf5e))
* renewals stats empty on all-time filter ([e25fcfc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e25fcfc6ef941465b83f368f152304ea5a6747d9))
* reorder button_click_logs migration to nullify before ALTER TYPE ([df5415f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5415f30b2aae4412ff5fbd3cac8076128b818c))
* repair missing DB columns and make backup resilient to schema mismatches ([c20355b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c20355b06df13328f85cc5a6045b3e490419a30a))
* replace deprecated Query(regex=) with pattern= ([871ceb8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/871ceb866ccf1f3a770c7ef33406e1a43d0a7ff7))
* reset QR photo when returning to referral ([3ee108f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3ee108fce85962dde5bc6c80b3464278369da9f5))
* reset traffic purchases on expired subscription renewal + pricing fixes ([dce9eaa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dce9eaa5971cb1dc0945747e02397a250e8e411b))
* resolve deadlock on server_squads counter updates and add webhook notification toggles ([57dc1ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57dc1ff47f2f6183351db7594544a07ca6f27250))
* resolve exc_info for admin notifications, clean log formatting ([11f8af0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11f8af003fc60384abafa2b670b89d6ad3ac57a4))
* resolve GROUP BY mismatch for daily_by_tariff query ([e5f29eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e5f29eb041e88bc6315f0b4da3b78898d9dd7fff))
* resolve HIGH-priority performance and security issues in partner system ([fcf3a2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcf3a2c8062752b2b1dc06b5993ac2d8ae80ee85))
* resolve MissingGreenlet error when accessing subscription.tariff ([a93a32f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a93a32f3a7d1b259a2e24954ae5d2b7c966c5639))
* resolve ruff lint errors (import sorting, unused variable) ([b2d7abf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2d7abf5bd10a98fd7ad1da50b5072afc65a5b48))
* resolve sync 404 errors, user deletion FK constraint, and device limit not sent to RemnaWave ([1ce9174](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ce91749aa12ffcefcf66bea714cea218739f3fe))
* restore merge token on DB failure, fix partner_status priority ([9582758](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9582758d1c85735c8ead8cbfeb56bbdae45288af))
* restore panel user discovery on admin tariff change, localize cart reminder ([1256ddc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1256ddcd1a772f90e7bdf9437043a47ea9d84d53))
* restore RemnaWave config management endpoints ([6f473de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f473defef32a6d81cee55ef2cd397d536a784a7))
* restore subscription_url and crypto_link after panel sync ([26efb15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26efb157e476a18b036d09167628a295d7e4c10b))
* return zeroed stats dict when withdrawal is disabled ([7883efc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7883efc3d6e6d8bedf8e4b7d72634cbab6e2f3d7))
* review findings — exception chaining, redundant unquote, validator tightening ([467dea1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/467dea1315fbaf8d09ccbba292cd0bcc60d9f3ab))
* safe HTML preview truncation and lazy-load subscription fallback ([40d8a6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/40d8a6dc8baf3f0f7c30b0883898b4655a907eb5))
* second round review fixes for account merge ([64ee045](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64ee0459e4e3d3fe87ad65387fcbcb147147ac1b))
* security and architecture fixes for webhook handlers ([dc1e96b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc1e96bbe9b4496e91e9dea591c7fc0ef4cc245b))
* separate base and purchased traffic in renewal pricing ([739ba29](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/739ba2986f41b04058eb14e8b87b0699fe96f922))
* show negative amounts for withdrawals in admin transaction list ([5ee45f9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ee45f97d179ce2d32b3f19eeb6fd01989a30ca7))
* skip blocked users in trial notifications and broadcasts without DB status change ([493f315](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/493f315a65610826a04e04c3d2065e0b395426ed))
* skip users with active subscriptions in admin inactive cleanup ([e79f598](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e79f598d17ffa76372e6f88d2a498accf8175c76))
* specify foreign_keys on User.admin_roles_rel to resolve ambiguous join ([bc7d061](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc7d0612f1476f2fdb498cd76a9374b41fd9440a))
* stack promo group + promo offer discounts in bot (matching cabinet) ([628997f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/628997fb48413cc4fae9ac491d1c7f6185877200))
* stop CryptoBot webhook retry loop and save cabinet payments to DB ([2cb6d73](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cb6d731e96cbfc305b098d8424b84bfd6826fb4))
* suppress 'message is not modified' error in updates panel ([3a680b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a680b41b0124848572809d187cab720e1db8506))
* suppress bot-blocked-by-user error in AuthMiddleware ([fda9f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fda9f3beecbfcca4d7abc16cf661d5ad5e3b5141))
* suppress expired callback query error in AuthMiddleware ([2de4384](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2de438426a647e2bcae9b4d99eef4093ff8b5429))
* suppress startup log noise (~350 lines → ~30) ([8a6650e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a6650e57cd8ea396d9b057a7753469947f38d29))
* suppress web page preview when logo mode is disabled ([1f4430f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f4430f3af8f3efcc58ef7b562904adcb1640a44))
* sync subscription status from panel in user.modified webhook ([5156d63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5156d635f0b5bc0493e8f18ce9710cca6ff4ffc8))
* 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))
* sync traffic reset across all tariff switch code paths ([d708365](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d708365aca9dfd5c3afda1a1de4303e0bd1d263e))
* sync uv.lock version with pyproject.toml 3.23.1 ([8eb6a8c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8eb6a8c4606a0ea48e383c031ad83219fc8e062b))
* sync uv.lock version with pyproject.toml 3.23.1 ([bc52fd2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc52fd27113f95a4154b1990142d46ae606fd2e0))
* ticket creation crash and webhook PendingRollbackError ([760c833](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/760c833b7402541d3c7cf2ed7fc0418119e75042))
* traceback in Telegram notifications + reduce log padding ([909a403](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/909a4039c43b910761bd05c36e79c8e6773199db))
* transaction boundary and CORS in webapi ([6495384](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6495384bcfd76c377971438f6c132f1404ea1f7d))
* translate required channels handler to Russian, add localization keys ([1bc9074](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bc9074c1bcdaba7215065c77aac9dd51db4d7c8))
* treat empty icon_url as None in payment method validation ([ab981dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ab981dce0d84bba3df5fc4366e39ba3ed0adeccd))
* unassign all campaigns when revoking partner status ([d39063b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d39063b22ffb6442e275db39704361cdb9251793))
* UnboundLocalError for get_logo_media in required_sub_channel_check ([d3c14ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c14ac30363839d1340129f279a7a7b4b021ed1))
* UniqueViolation при мерже аккаунтов с общим OAuth/telegram/email ID ([1c89bd8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1c89bd8b2acfe49de2c97dd75446a037a54fded7))
* uploaded backup restore button not triggering handler ([ebe5083](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebe508302b906f8b56cb230b934fb8566990c684))
* use .is_(True) and add or 0 guards per code review ([69b5ca0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69b5ca06701e7381c39448e2bf6b927f0558058c))
* use actual DB columns for subscription fallback query ([f0e7f8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f0e7f8e3bec27d97a3f22445948b8dde37a92438))
* use aiogram 3.x bot.download() instead of document.download() ([205c8d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/205c8d987d93151a17aa0793cb51bd99917aea97))
* use AwareDateTime TypeDecorator for all datetime columns ([a7f3d65](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7f3d652c51ecd653900a530b7d38feaf603ecf1))
* use callback fallback when MINIAPP_CUSTOM_URL is not set ([eaf3a07](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaf3a07579729031030308d77f61a5227b796c02))
* use direct is_trial access, add missing error codes to promo APIs ([69a9899](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69a9899d40dda83e83cbdba1aa43d9d1f756704b))
* use event field directly as event_name (already includes scope prefix) ([9aa22af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9aa22af3390a249d1b500d75a7d7189daaed265e))
* use float instead of int | float (PYI041) ([310edae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/310edae013973d8533051088f3720cc5da3651b5))
* use flush instead of commit in server counter functions ([6cec024](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6cec024e46ef9177cb59aa81590953c9a75d81bb))
* use get_rendered_override for proper variable substitution in guest email overrides ([c165cca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c165cca3239c9a1249aae9e5e712f7e34fb01107))
* use SAVEPOINT instead of full rollback in sync user creation ([2a90f87](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2a90f871b97b2b7ee8289e62294c65f8becb2539))
* use selection.period.days instead of selection.period_days ([4541016](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/45410168afe683675003a1c41c17074a54ce04f1))
* use short TTL fallback in restore_merge_token on parse error ([0e8c61a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0e8c61a7762ae796284144056c0cbdbcb53b6c7c))
* use sync context manager for structlog bound_contextvars ([25e8c9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25e8c9f8fc4d2c66d5a1407d3de5c7402dc596da))
* use traffic topup config and add WATA 429 retry ([b5998ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5998ea9d22644ed2914b0e829b3a76a32a69ddf))
* validate payment sub-option suffix and harden payment method handling ([5f01783](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f01783dcb63f2f8bc20fef935d74d7588273aea))
* webhook notification 'My Subscription' button uses unregistered callback_data ([1e2a7e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e2a7e3096af11540184d60885b8c08d73506c4a))
* webhook:close button not working due to channel check timeout ([019fbc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/019fbc12b6cf61d374bbed4bce3823afc60445c9))
* wrap user deletion steps in savepoints to prevent transaction cascade abort ([a38dfcb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a38dfcb75a47a185d979a8202f637d8b79812e67))
* безопасность и качество кода лендингов — 16 исправлений ([ef45095](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ef450955e6b37d437dabac55da037f53ca1f75dc))
* гарантировать положительный доход от подписок и исправить общий доход ([93a55df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93a55df4c0ac099946d440ec79fefb24327ab0e1))
* дедупликация promocode_uses при мерже аккаунтов ([00a7db2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/00a7db26905d53a9a978aaf6b97800ca3042b957))
* добавить create_transaction для 6 потоков оплаты с баланса ([374907b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/374907b6078c483531061465983e23f281e841a2))
* добавить create_transaction и admin-уведомления для автопродлений ([9f35088](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9f35088788c971cb757936dba7214abe54477af0))
* добавить ON DELETE CASCADE/SET NULL на все FK к users.id ([34c82c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/34c82c348829cf528154bd1e2f5d77006d7ed5da))
* добавить пробелы в формат тарифов (1000 ГБ / 2 📱) ([900be65](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/900be65617dd5bbc6ffdcc82bb5504e1a93ead95))
* дубликаты системных ролей при переименовании и сброс permissions ([7a7fb71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7a7fb71bf535e2a501f0677747ba63ca0b27ede5))
* изолировать stored_amount от downstream consumers в create_transaction ([b87535a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b87535ad4842cbf1f99f6fc1e28b5932fa5e3baa))
* исправления системы реферальных конкурсов ([6713b34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6713b3497854e73dddc212280d7bf12db818f38a))
* кнопка «Назад» в тарифах ведёт в админ панель, а не в настройки ([04562fd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04562fd7e74de26776517549730819389b24a0d0))
* миграция 0016 падает если FK constraint отсутствует в БД ([15fe45d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15fe45d11341001714599f8db963d182dc371aa3))
* миграция 0021 — drop server_default перед сменой типа на JSON ([3d3bb3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3d3bb3badb55511960ed9b2a29ea67e0f0c3f26c))
* передать явный диапазон дат для all_time_stats в дашборде ([968d147](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/968d14704610eed528bca28cbf295c1ba1644a5a))
* показывать кнопку покупки тарифа вместо ошибки для триальных подписок ([acfa4b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acfa4b3c2ea96e74d93470085265df76ec50e1e6))
* показывать только активные провайдеры на странице /profile/accounts ([9d7a557](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d7a557ef0e294ce9920e9953bb1358656ff9b81))
* промокоды — конвертация триалов, race condition, savepoints ([7fb839a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7fb839aef6234294b95064f9575c19d5a0c3f892))
* реактивация DISABLED подписок при покупке трафика для LIMITED пользователей ([7d28f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d28f5516a52606280219cbea846fba431da80d2))
* реактивация DISABLED подписок при покупке устройств и в REST API ([b9e17be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b9e17be8554a65eaf765a0b5b36fee062205c66f))
* синхронизация версии pyproject.toml с main и обновление uv в Dockerfile ([b31a893](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b31a893b13b2db911e51298ceb0107419f9a4cb3))
* убрать WITHDRAWAL из автонегации, добавить abs() в агрегации, исправить all_time_stats ([6da61d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6da61d79510f7e05310f3cc020515b4dd0b3eb34))
* убрать избыточный минус в amount_kopeks для create_transaction ([849b3a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/849b3a7034f2291db40e049c12e1b7c71b58bab1))
* устранение race condition при покупке устройств через re-lock после коммита ([a7a18dd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7a18dd0d1d59c64f7e4dd3ddc1b8cec47198077))
* устранение race conditions и атомарность платёжной системы ([4984f20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4984f20e8fb030ee338723d797d51aee21f67ca8))
* устранение каскадного PendingRollbackError при восстановлении бэкапа ([8259278](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/82592784d0da8b8718f3b3aa34076af59ad2a878))
### 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
* complete structlog migration with contextvars, kwargs, and logging hardening ([1f0fef1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f0fef114bd979b2b0d2bd38dde6ce05e7bba07b))
* extract shared OAuth linking logic, add Literal types for providers ([f7caf0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7caf0de709ca6a46283f0b1928e34f8908f2c93))
* improve log formatting — logger name prefix and table alignment ([f637204](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f63720467a935bdaaa58bb34d588d65e46698f26))
* 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 legacy app-config.json system ([295d2e8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/295d2e877e43f48e9319ba0b01be959904637000))
* remove modem functionality from classic subscriptions ([ee2e79d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ee2e79db3114fe7a9852d2cd33c4b4fbbde311ea))
* remove smart auto-activation & activation prompt, fix production bugs ([a3903a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a3903a252efdd0db4b42ca3fd6771f1627050a7f))
* 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.23.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.23.1...v3.23.2) (2026-03-06)
### Bug Fixes
* device_limit fallback 1→0 для корректного отображения безлимита ([3e26832](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3e26832e745368a0dab2617e4e8ae2c410c6bca2))
* sync uv.lock version with pyproject.toml 3.23.1 ([8eb6a8c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8eb6a8c4606a0ea48e383c031ad83219fc8e062b))
* sync uv.lock version with pyproject.toml 3.23.1 ([bc52fd2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc52fd27113f95a4154b1990142d46ae606fd2e0))
* миграция 0016 падает если FK constraint отсутствует в БД ([15fe45d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15fe45d11341001714599f8db963d182dc371aa3))
## [3.23.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.23.0...v3.23.1) (2026-03-06)
### Bug Fixes
* complete FK migration — add 27 missing constraints, fix broadcast_history nullable ([fe393d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe393d2ca6ce302d8213cc751842ea92ef277e76))
* UniqueViolation при мерже аккаунтов с общим OAuth/telegram/email ID ([1c89bd8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1c89bd8b2acfe49de2c97dd75446a037a54fded7))
* дедупликация promocode_uses при мерже аккаунтов ([00a7db2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/00a7db26905d53a9a978aaf6b97800ca3042b957))
* добавить ON DELETE CASCADE/SET NULL на все FK к users.id ([34c82c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/34c82c348829cf528154bd1e2f5d77006d7ed5da))
* дубликаты системных ролей при переименовании и сброс permissions ([7a7fb71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7a7fb71bf535e2a501f0677747ba63ca0b27ede5))
* исправления системы реферальных конкурсов ([6713b34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6713b3497854e73dddc212280d7bf12db818f38a))
* кнопка «Назад» в тарифах ведёт в админ панель, а не в настройки ([04562fd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04562fd7e74de26776517549730819389b24a0d0))
* промокоды — конвертация триалов, race condition, savepoints ([7fb839a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7fb839aef6234294b95064f9575c19d5a0c3f892))
* устранение race conditions и атомарность платёжной системы ([4984f20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4984f20e8fb030ee338723d797d51aee21f67ca8))
## [3.23.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.22.0...v3.23.0) (2026-03-05)
### New Features
* account linking and merge system for cabinet ([dc7b8dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc7b8dc72a3a398d6270a0a2b8ce9e2b54cb9af7))
* account merge system — atomic user merge with full FK coverage ([2664b49](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2664b4956d8436a2720d7cd5992b8cdbb72cdbd9))
* add dedicated sales_stats RBAC permission section ([8f29e2e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f29e2eee2e0c78f7f7e87a322eaf4bd4221069c))
* add server-complete OAuth linking endpoint for Mini App flow ([f867989](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f867989557d20378cfe815c9c88e1a842c4f6654))
* add Telegram account linking endpoint with security hardening ([da40d56](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da40d5662d6d064090769823d616d6f9748ab5b9))
### Bug Fixes
* abs() for transaction amounts in admin notifications and subscription events ([fd139b2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fd139b28a2c45cc3fbd2e01707fb83fbabf57c71))
* add abs() to expenses query, display flip, contest stats, and recent payments ([de6f806](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de6f80694ba8aa240764e2769ec04c16fe7f3672))
* add IntegrityError handling on link commit and format fixes ([0c1dc58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c1dc580c67254d11ffb096c22d8c8d78ac18e2b))
* add missing mark_as_paid_subscription, fix operation order, remove dead code ([5f2d855](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f2d855702dea838b38887a5f44b9ad759acd5cf))
* auto-update permissions for system roles on bootstrap ([eff74be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eff74bed5bcc47a6cfa05c20cad14a40c1572d1f))
* centralize balance deduction and fix unchecked return values ([0466528](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0466528925a24087b8522a10cbb11c947c2b7d91))
* centralize has_had_paid_subscription into subtract_user_balance ([e4a6aad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4a6aad621be7ef4e7aedb21373927ede0c8d0a5))
* clean email verification and password fields from secondary user during merge ([7b4e948](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b4e9488f6fbd1271f063579e48ca9a3c96cb645))
* consume promo offer in miniapp tariff-mode renewal path ([b8857e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b8857e789ef60cf0c8766abbeadd094f62070a61))
* consume promo offer in tariff_purchase.py, fix negative transaction amount ([c8ef808](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c8ef80853915af3e3eb254edd07d8d78b66a9282))
* delete cross-referral earnings before bulk reassignment, clear secondary.referred_by_id ([f204b67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f204b678803297ce60faad628d16f46344b11ed0))
* from redis.exceptions import NoScriptError ([667291a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/667291a2dcaeae21e27eeb6376085e69caa4e45a))
* harden account merge security and correctness ([d855e9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d855e9e47fab1a038e581437a9921bdfeb11e927))
* **merge:** validate before consuming token, add flush, defensive balance ([bc1e6fb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc1e6fb22c6e23c7a34364796f51a55c60224aff))
* negative balance transfer, linking state validation, referrer migration ([531d5cf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/531d5cff3019e72dde6ee64977cb801e8f8c8d0b))
* prevent concurrent device purchases exceeding max device limit ([1cfede2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cfede28b7570bcaf77cb53d6b2a9f3b0e4e9408))
* prevent infinite reuse of first_purchase_only promo code discounts ([2cec8dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cec8dc4a487017f4b1c5ca80710f2d70045b825))
* prevent self-referral loops, invalidate all sessions on merge ([db61365](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db61365e11ccec4dd45671b33da00f4b05484589))
* reassign orphaned records on merge, eliminate TOCTOU race ([d7a9d2b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7a9d2bfba5b796882d3e04be6038b766cd0a4c8))
* redis cache uses sync client due to import shadowing ([667291a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/667291a2dcaeae21e27eeb6376085e69caa4e45a))
* restore merge token on DB failure, fix partner_status priority ([9582758](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9582758d1c85735c8ead8cbfeb56bbdae45288af))
* review findings — exception chaining, redundant unquote, validator tightening ([467dea1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/467dea1315fbaf8d09ccbba292cd0bcc60d9f3ab))
* second round review fixes for account merge ([64ee045](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64ee0459e4e3d3fe87ad65387fcbcb147147ac1b))
* use short TTL fallback in restore_merge_token on parse error ([0e8c61a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0e8c61a7762ae796284144056c0cbdbcb53b6c7c))
* гарантировать положительный доход от подписок и исправить общий доход ([93a55df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93a55df4c0ac099946d440ec79fefb24327ab0e1))
* добавить create_transaction для 6 потоков оплаты с баланса ([374907b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/374907b6078c483531061465983e23f281e841a2))
* добавить create_transaction и admin-уведомления для автопродлений ([9f35088](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9f35088788c971cb757936dba7214abe54477af0))
* добавить пробелы в формат тарифов (1000 ГБ / 2 📱) ([900be65](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/900be65617dd5bbc6ffdcc82bb5504e1a93ead95))
* изолировать stored_amount от downstream consumers в create_transaction ([b87535a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b87535ad4842cbf1f99f6fc1e28b5932fa5e3baa))
* передать явный диапазон дат для all_time_stats в дашборде ([968d147](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/968d14704610eed528bca28cbf295c1ba1644a5a))
* показывать кнопку покупки тарифа вместо ошибки для триальных подписок ([acfa4b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acfa4b3c2ea96e74d93470085265df76ec50e1e6))
* показывать только активные провайдеры на странице /profile/accounts ([9d7a557](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d7a557ef0e294ce9920e9953bb1358656ff9b81))
* реактивация DISABLED подписок при покупке трафика для LIMITED пользователей ([7d28f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d28f5516a52606280219cbea846fba431da80d2))
* реактивация DISABLED подписок при покупке устройств и в REST API ([b9e17be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b9e17be8554a65eaf765a0b5b36fee062205c66f))
* синхронизация версии pyproject.toml с main и обновление uv в Dockerfile ([b31a893](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b31a893b13b2db911e51298ceb0107419f9a4cb3))
* убрать WITHDRAWAL из автонегации, добавить abs() в агрегации, исправить all_time_stats ([6da61d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6da61d79510f7e05310f3cc020515b4dd0b3eb34))
* убрать избыточный минус в amount_kopeks для create_transaction ([849b3a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/849b3a7034f2291db40e049c12e1b7c71b58bab1))
* устранение race condition при покупке устройств через re-lock после коммита ([a7a18dd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7a18dd0d1d59c64f7e4dd3ddc1b8cec47198077))
* устранение каскадного PendingRollbackError при восстановлении бэкапа ([8259278](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/82592784d0da8b8718f3b3aa34076af59ad2a878))
### Refactoring
* extract shared OAuth linking logic, add Literal types for providers ([f7caf0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7caf0de709ca6a46283f0b1928e34f8908f2c93))
## [3.22.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.21.0...v3.22.0) (2026-03-04)
### New Features
* replace pip with uv in Dockerfile ([e23d69f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e23d69fcec7ab65a14b054fd46f6ecf87ae6fd13))
### Bug Fixes
* add selectinload for campaign registrations in list query ([4d74afd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4d74afd7118524623371f904a93ae1fcbba8d64e))
* backup restore fails on FK constraints and transaction poisoning ([ff1c872](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff1c8722c9188fdbaf765d6b7e9192686df64850))
* classic mode prices overridden by active tariff prices ([628a99e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/628a99e7aa0812842dabc430857190c0cd5c2680))
* close remaining daily subscription expire paths ([618c936](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/618c936ac9ce4904cd784bf2278d3da188895f2d))
* empty JSONB values exported as None in backup ([57aaca8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57aaca82f5bf9d7bdd9d4b924aa3412d85eccbb5))
* handle duplicate remnawave_uuid on email sync ([eaeee7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaeee7a765c03ff33e2928cdb41be91948eca95c))
* MissingGreenlet on campaign registrations access ([018f18f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/018f18fa0c9bba1a1dbca8b2398b9611d0c94c36))
* prevent daily subscriptions from being expired by middleware/CRUD/webhook ([0ed6397](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ed6397fa9e5810fcffc9152ab2241fcf37cf85a))
* reset traffic purchases on expired subscription renewal + pricing fixes ([dce9eaa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dce9eaa5971cb1dc0945747e02397a250e8e411b))
## [3.21.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.20.1...v3.21.0) (2026-03-02)
### New Features
* add admin campaign chart data endpoint with deposits/spending split ([fa7de58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa7de589c1bd0ae37ebaaa07bae0ed3d68e01720))
* add admin sales statistics API with 6 analytics endpoints ([58faf9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58faf9eaeca63c458093d2a5e74a860f57712ab0))
* add daily deposits by payment method breakdown ([d33c5d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d33c5d6c07ce4a9efaf3c5aceb448e968e1b8ed7))
* add daily device purchases chart to addons stats ([2449a5c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2449a5cbbe5179a762197414a5752896383a6ee4))
* add desired commission percent to partner application ([7ea8fbd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ea8fbd584aff2127595001094ef69acb52f847f))
* add RESET_TRAFFIC_ON_TARIFF_SWITCH admin setting ([4eaedd3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4eaedd33bf697469fe9ed6a1bfe8b59ca43b46fb))
* enhance sales stats with device purchases, per-tariff daily breakdown, and registration tracking ([31c7e2e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31c7e2e9c14cb88762a62a72e4f65051e0c6c1fd))
### Bug Fixes
* add exc_info traceback to sync user error log ([efdf2a3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efdf2a3189a2f790e570f9a6e19d91469be4ea4f))
* add local traffic_used_gb reset in all tariff switch handlers ([2cdbbc0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cdbbc09ba9a19dcb720049ffde08ba780ac5751))
* add min_length to state field, use exc_info for referral warning ([062c486](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/062c4865db194f9d2242772044402fa2711a69bd))
* add missing subscription columns migration ([b96e819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b96e819da4cc37710e9fc17467045b33bcffac4d))
* address review findings from agent verification ([cc5be70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc5be7059fdf4cefb01e97196c825b217f8b54b3))
* correct cart notification after balance top-up ([2fab50c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fab50c340c885fc92a4bf797a4b03da6e44af31))
* correct referral withdrawal balance formula and commission transaction type ([83c6db4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83c6db48349440447305604e944fa440bdceb3fb))
* count sales from completed payment transactions instead of subscription created_at ([06c3996](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06c3996da4fa14eafb294651158068c7cda51e52))
* eliminate double panel API call on tariff change, harden cart notification ([b2cf4aa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2cf4aaa91f3fb63dca7e70645cadb75aa158cfe))
* eliminate referral system inconsistencies ([60c97f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/60c97f778bc4cc18aaf4d8a31826bc831c3b3f8f))
* email verification bypass, ban-notifications size limit, referral balance API ([256cbfc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/256cbfcadfd2fc88d8de69557c78618639af157d))
* enforce user restrictions in cabinet API and fix poll history crash ([faba3a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/faba3a8ed6d428305f9ca7d7fd9bdcc1fd72ba52))
* freekassa OP-SP-7 error and missing telegram notification ([200f91e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/200f91ef1748bb6213d1ef3a8e83ae976290a8a7))
* generate missing crypto link on the fly and skip unresolved templates ([4c72058](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c72058d4ad8b0594991b17323928d9004803bfa))
* handle expired callback queries and harden middleware error handling ([f52e6ae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f52e6aedac3de1c9bb2ad1a5a16b06d38b79ab63))
* handle expired ORM attributes in sync UUID mutation ([9ae5d7b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ae5d7bb60c57e2c29d6f3c5098c23450d5feb61))
* handle NULL used_promocodes for migrated users ([cdcabee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cdcabee80d1d7f0b367a97cdec20bb49e8592115))
* hide traffic topup button when tariff doesn't support it ([399ca86](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/399ca86561f4271e9c542bac87c0dd2931a223e0))
* improve campaign routes, schemas, and add database indexes ([ded5c89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ded5c899f7425707b17fef4d0d5ceafac777ef08))
* include desired_commission_percent in admin notification ([dc3d22f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc3d22f52db40150d595bccf524d38790e5725d9))
* migrate VK OAuth to VK ID OAuth 2.1 with PKCE ([1dfa780](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1dfa78013c4fb926a2b32bf4d63baa28215e7340))
* partner system — CRUD nullable fields, per-campaign stats, atomic unassign, diagnostic logging ([ed3ae14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed3ae14d0c378fa0dc2d442c3aa5a70172f3132c))
* prevent squad drop on admin subscription type change, require subscription for wheel spins ([59f0e42](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59f0e42be7e3c679d15cf2fc6820ab7097cd2201))
* prevent sync from overwriting subscription URLs with empty strings ([9c00479](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9c004791f28fbcf314b93c1b2a38593069605239))
* reject promo codes for days when user has no subscription or trial ([e32e2f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e32e2f779d014d587b58d63b513fd913ae1b7a41))
* remove premature tariff_id assignment in _apply_extension_updates ([b47678c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b47678cfb0ba5897b37dfe1f94e3d1336af5698e))
* renewals stats empty on all-time filter ([e25fcfc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e25fcfc6ef941465b83f368f152304ea5a6747d9))
* resolve GROUP BY mismatch for daily_by_tariff query ([e5f29eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e5f29eb041e88bc6315f0b4da3b78898d9dd7fff))
* restore panel user discovery on admin tariff change, localize cart reminder ([1256ddc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1256ddcd1a772f90e7bdf9437043a47ea9d84d53))
* separate base and purchased traffic in renewal pricing ([739ba29](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/739ba2986f41b04058eb14e8b87b0699fe96f922))
* sync traffic reset across all tariff switch code paths ([d708365](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d708365aca9dfd5c3afda1a1de4303e0bd1d263e))
* use .is_(True) and add or 0 guards per code review ([69b5ca0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69b5ca06701e7381c39448e2bf6b927f0558058c))
* use direct is_trial access, add missing error codes to promo APIs ([69a9899](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69a9899d40dda83e83cbdba1aa43d9d1f756704b))
* use float instead of int | float (PYI041) ([310edae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/310edae013973d8533051088f3720cc5da3651b5))
* use SAVEPOINT instead of full rollback in sync user creation ([2a90f87](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2a90f871b97b2b7ee8289e62294c65f8becb2539))
## [3.20.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.20.0...v3.20.1) (2026-02-25)
### Bug Fixes
* make migrations 0010/0011 idempotent, escape HTML in crash notification ([a696896](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a696896d2c4a3d0d6026398fcdc76ded9575375d))
* prevent race condition expiring active daily subscriptions ([bfef7cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfef7cc6296e296f17068e519469c3deaddc1b3b))
## [3.20.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.19.0...v3.20.0) (2026-02-25)
### New Features
* add separate Freekassa SBP and card payment methods ([0da0c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0da0c5547d0648a70f848fe77c13d583f4868a52))
* add validation to animation config API ([a15403b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a15403b8b6e1ec1bb5c37fdde646e7790373e860))
### Bug Fixes
* initialize logger in bot_configuration.py ([988d0e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/988d0e5c2f27538135d757187a0b6770f078b1d9))
* remove gemini-effect and noise from allowed background types ([731eb24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/731eb2436428d0e12f1e5ccdebc72cd74fd7c65e))
* resolve ruff lint errors (import sorting, unused variable) ([b2d7abf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2d7abf5bd10a98fd7ad1da50b5072afc65a5b48))
* resolve sync 404 errors, user deletion FK constraint, and device limit not sent to RemnaWave ([1ce9174](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ce91749aa12ffcefcf66bea714cea218739f3fe))
## [3.19.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.18.0...v3.19.0) (2026-02-25)
### New Features
* add granular user permissions (balance, subscription, promo_group, referral, send_offer) ([60c4fe2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/60c4fe2e239d8fef7726cac769711c8fcce789eb))
* add per-channel disable settings and fix CHANNEL_REQUIRED_FOR_ALL bug ([3642462](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3642462670c876052aa668c1515af8c04234cb34))
* add RBAC + ABAC permission system for admin cabinet ([3fee54f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3fee54f657dc6e0db1ec36697850ada2235e6968))
* add resource_type and request body to audit log entries ([388fc7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/388fc7ee67f5fc0edf6b7b64b977e12a2d8f0566))
* allow editing system roles ([f6b6e22](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f6b6e22a9528dc05b7fbfa80b63051a75c8e73cd))
* capture query params in audit log details for all requests ([bea9da9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bea9da96d44965fcee5e2eba448960443152d4ea))
### Bug Fixes
* address RBAC review findings (CRITICAL + HIGH) ([1646f04](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1646f04bde47a08f3fd782b7831d40760bd1ba60))
* align RBAC route prefixes with frontend API paths ([5a7dd3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a7dd3f16408f3497a9765e79a540ccdabc50e69))
* always include details in successful audit log entries ([3dc0b93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3dc0b93bdfc85fb97f371dc34e024272766afc65))
* extract real client IP from X-Forwarded-For/X-Real-IP headers ([af6686c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af6686ccfae12876e867cdabe729d0c893bd85a1))
* grant legacy config-based admins full RBAC access ([8893fc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8893fc128e3d8927054f1df1647e896e780c69e7))
* improve campaign notifications and ticket media in admin topics ([a594a0f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a594a0f79f48227f75d6102b4586179102c4d344))
* RBAC API response format fixes and audit log user info ([4598c27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4598c2785a42773ee8be04ada1c00d14824e07e0))
* RBAC audit log action filter and legacy admin level ([c1da8a4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c1da8a4dba5d0c993d3e15b2866bdcfa09de1752))
* restore subscription_url and crypto_link after panel sync ([26efb15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26efb157e476a18b036d09167628a295d7e4c10b))
* specify foreign_keys on User.admin_roles_rel to resolve ambiguous join ([bc7d061](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc7d0612f1476f2fdb498cd76a9374b41fd9440a))
* stack promo group + promo offer discounts in bot (matching cabinet) ([628997f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/628997fb48413cc4fae9ac491d1c7f6185877200))
## [3.18.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.17.1...v3.18.0) (2026-02-24)
### New Features
* add ChatTypeFilterMiddleware to ignore group/forum messages ([25f014f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25f014fd8988b5513fba8fec4483981384687e96))
* add multi-channel mandatory subscription system ([8375d7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8375d7ecc5e54ea935a00175dd26f667eab95346))
* add required channels button to admin settings submenu in bot ([3af07ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3af07ff627fc354da4f8c41b0bd0575dddd9afa5))
* colored channel subscription buttons via Bot API 9.4 style ([0b3b2e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b3b2e5dc54d8b6b3ede883d5c0f5b91791b7b9b))
* rework guide mode with Remnawave API integration ([5a269b2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a269b249e8e6cad266822095676937481613f5f))
### Bug Fixes
* add missing CHANNEL_CHECK_NOT_SUBSCRIBED localization key ([a47ef67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a47ef67090c4e48f466286f7c676eeee0c61a4fb))
* address code review issues in guide mode rework ([fae6f71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fae6f71def421e319733e4edcf1ca80a2831b2ec))
* address security review findings ([6feec1e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6feec1eaa847644ba3402763a2ffefd8f770cc01))
* callback routing safety and cache invalidation order ([6a50013](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a50013c21de199df0ba0dab3600b693548b6c1e))
* correct broadcast button deep-links for cabinet mode ([e5fa45f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e5fa45f74f969b84f9f1388f8d4888d22c46d7e8))
* HTML-escape all externally-sourced text in guide messages ([711ec34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/711ec344c646844401f355695a7e8c0d4fb401ee))
* improve deduplication log message wording in monitoring service ([2aead9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2aead9a68b6bf274c8d1497c85f2ed4d4fc9c70b))
* invalidate app config cache on local file saves ([978726a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/978726a7856cf56257c49491afe569fa8c395eac))
* pre-existing bugs found during review ([1bb939f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bb939f63a360a687fafba26bc363024df0f6be0))
* remove [@username](https://github.com/username) channel ID input, auto-prefix -100 for bare digits ([a7db469](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7db469fd7603e7d8dac3076f5d633da654a3a57))
* restore RemnaWave config management endpoints ([6f473de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f473defef32a6d81cee55ef2cd397d536a784a7))
* translate required channels handler to Russian, add localization keys ([1bc9074](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bc9074c1bcdaba7215065c77aac9dd51db4d7c8))
### Refactoring
* remove legacy app-config.json system ([295d2e8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/295d2e877e43f48e9319ba0b01be959904637000))
## [3.17.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.17.0...v3.17.1) (2026-02-23)
### Bug Fixes
* add diagnostic logging for device_limit sync to RemnaWave ([97b3f89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97b3f899d12c4bf32b6229a3b595f1b9ad611096))
* add int32 overflow guards and strengthen auth validation ([50a931e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/50a931ec363d1842126b90098f93c6cae47a9fac))
* add missing broadcast_history columns and harden subscription logic ([d4c4a8a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d4c4a8a211eaf836024f8d9dcb725f25f514f05e))
* allow tariff switch when less than 1 day remains ([67f3547](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67f3547ae2f40153229d71c1abe7e1213466e5c3))
* cap expected_monthly_referrals to prevent int32 overflow ([2ef6185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2ef618571570edb6011a365af8aa9cd7e3348c2e))
* cross-validate Telegram identity on every authenticated request ([973b3d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/973b3d3d3ff80376c0fd19c531d7aac3ae751df8))
* handle RemnaWave API errors in traffic aggregation ([ed4624c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed4624c6649bdbc04bc850ef63e5c86e26a37ce4))
* migrate all remaining naive timestamp columns to timestamptz ([708bb9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/708bb9eec7ea4360b26709fb2a3f82dd139ed600))
* prevent partner self-referral via own campaign link ([115c0c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/115c0c84c0698591da75d7d3b8fbd8e0fc8541ea))
* protect active paid subscriptions from being disabled in RemnaWave ([1b6bbc7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b6bbc7131341b4afd739e4195f02aa956ead616))
* repair missing DB columns and make backup resilient to schema mismatches ([c20355b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c20355b06df13328f85cc5a6045b3e490419a30a))
* show negative amounts for withdrawals in admin transaction list ([5ee45f9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ee45f97d179ce2d32b3f19eeb6fd01989a30ca7))
* suppress web page preview when logo mode is disabled ([1f4430f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f4430f3af8f3efcc58ef7b562904adcb1640a44))
* uploaded backup restore button not triggering handler ([ebe5083](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebe508302b906f8b56cb230b934fb8566990c684))
* use aiogram 3.x bot.download() instead of document.download() ([205c8d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/205c8d987d93151a17aa0793cb51bd99917aea97))
## [3.17.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.3...v3.17.0) (2026-02-18)
### New Features
* add referral code tracking to all cabinet auth methods + email_templates migration ([18c2477](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18c24771737994f3ae1f832435ed2247ca625aab))
### Bug Fixes
* prevent 'caption is too long' error in logo mode ([6e28a1a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6e28a1a22b02055b357051dfecbee7fefbebc774))
* skip blocked users in trial notifications and broadcasts without DB status change ([493f315](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/493f315a65610826a04e04c3d2065e0b395426ed))
## [3.16.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.2...v3.16.3) (2026-02-18)
### Bug Fixes
* 3 user deletion bugs — type cast, inner savepoint, lazy load ([af31c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af31c551d2f23ef01425bdb2db8f255dbc3047e2))
* auth middleware catches all commit errors, not just connection errors ([6409b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6409b0c023cd7957c43d5c1c3d83e671ccaf959c))
* connected_squads stores UUIDs, not int IDs — use get_server_ids_by_uuids ([d7039d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7039d75a47fbf67436a9d39f2cd9f65f2646544))
* deadlock on user deletion + robust migration 0002 ([b7b83ab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7b83abb723913b3167e7462ff592a374c3f421b))
* eliminate deadlock by matching lock order with webhook ([d651a6c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d651a6c02f501b7a0ded570f2db6addcc16173a9))
* make migration 0002 robust with table existence checks ([f076269](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f076269c323726c683a38db092d907591a26e647))
* wrap user deletion steps in savepoints to prevent transaction cascade abort ([a38dfcb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a38dfcb75a47a185d979a8202f637d8b79812e67))
## [3.16.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.1...v3.16.2) (2026-02-18)
### Bug Fixes
* auto-convert naive datetimes to UTC-aware on model load ([f7d33a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7d33a7d2b31145a839ee54676816aa657ac90da))
* extend naive datetime guard to all model properties ([bd11801](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd11801467e917d76005d1a782c71f5ae4ffee6e))
* handle naive datetime in raw SQL row comparison (payment/common) ([38f3a9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38f3a9a16a24e85adf473f2150aad31574a87060))
* handle naive datetimes in Subscription properties ([e512e5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e512e5fe6e9009992b5bc8b9be7f53e0612f234a))
* use AwareDateTime TypeDecorator for all datetime columns ([a7f3d65](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7f3d652c51ecd653900a530b7d38feaf603ecf1))
## [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)
+16 -17
View File
@@ -4,27 +4,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY --from=ghcr.io/astral-sh/uv:0.10.8 /uv /uvx /bin/
COPY requirements.txt .
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=uv.lock,target=uv.lock \
uv sync --locked --no-dev
FROM python:3.13-slim
ARG VERSION="v3.12.1" # x-release-please-version
ARG VERSION="v3.24.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
RUN groupadd -g 1000 app && \
useradd -u 1000 -g 1000 -m -s /bin/bash app
@@ -33,8 +33,7 @@ WORKDIR /app
COPY --chown=app:app . .
RUN mkdir -p logs data && \
chown -R app:app /app logs data
RUN mkdir -p logs data && chown app:app logs data
USER app
@@ -56,7 +55,7 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.url="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
org.opencontainers.image.vendor="fr1ngg"
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" || exit 1
CMD ["python", "main.py"]
+16
View File
@@ -40,6 +40,22 @@ fix: ## Исправить код (ruff check --fix + format)
uv run ruff check . --fix
uv run ruff format .
.PHONY: migrate
migrate: ## Применить миграции (alembic upgrade head)
uv run alembic upgrade head
.PHONY: migration
migration: ## Создать миграцию (usage: make migration m="description")
uv run alembic revision --autogenerate -m "$(m)"
.PHONY: migrate-stamp
migrate-stamp: ## Пометить БД как актуальную (для существующих БД)
uv run alembic stamp head
.PHONY: migrate-history
migrate-history: ## Показать историю миграций
uv run alembic history --verbose
.PHONY: help
help: ## Показать список доступных команд
@echo ""
+1 -1
View File
@@ -2,7 +2,7 @@
script_location = migrations/alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
-658
View File
@@ -1,658 +0,0 @@
{
"config": {
"additionalLocales": [
"ru",
"zh",
"fa"
],
"branding": {
"name": "Subscription",
"logoUrl": "https://raw.githubusercontent.com/Fr1ngg/remnawave-bedolaga-telegram-bot/bf0c1ce711a26fa2f24559e7e4443820e68d758b/assets/bedolaga_app3.svg",
"supportUrl": "https://t.me"
}
},
"platforms": {
"ios": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "Open in App Store [EU]",
"fa": "باز کردن در App Store [EU]",
"ru": "Открыть в App Store [EU]",
"zh": "在 App Store 中打开 [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "Open in App Store [RU]",
"fa": "باز کردن در App Store [RU]",
"ru": "Открыть в App Store [RU]",
"zh": "在 App Store 中打开 [RU]"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "streisand",
"name": "Streisand",
"isFeatured": false,
"urlScheme": "streisand://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/streisand/id6450534064",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "shadowrocket",
"name": "Shadowrocket",
"isFeatured": false,
"urlScheme": "sub://",
"isNeedBase64Encoding": true,
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/shadowrocket/id932747118",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"android": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
"buttonText": {
"en": "Open in Google Play",
"fa": "باز کردن در Google Play",
"ru": "Открыть в Google Play",
"zh": "在 Google Play 中打开"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"fa": "برنامه را باز کنید و به سرور متصل شوید",
"ru": "Откройте приложение и подключитесь к серверу",
"zh": "打开应用并连接到服务器"
}
}
},
{
"id": "clash-meta",
"name": "Clash Meta",
"isFeatured": false,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/MetaCubeX/ClashMetaForAndroid/releases/download/v2.11.7/cmfa-2.11.7-meta-universal-release.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
},
{
"buttonLink": "https://f-droid.org/packages/com.github.metacubex.clash.meta/",
"buttonText": {
"en": "Open in F-Droid",
"fa": "در F-Droid باز کنید",
"ru": "Открыть в F-Droid",
"zh": "在 F-Droid 中打开"
}
}
],
"description": {
"en": "Download and install Clash Meta APK",
"fa": "دانلود و نصب Clash Meta APK",
"ru": "Скачайте и установите Clash Meta APK",
"zh": "下载并安装 Clash Meta APK"
}
},
"addSubscriptionStep": {
"description": {
"en": "Tap the button to import configuration",
"fa": "برای وارد کردن پیکربندی روی دکمه ضربه بزنید",
"ru": "Нажмите кнопку, чтобы импортировать конфигурацию",
"zh": "点击按钮导入配置"
}
},
"connectAndUseStep": {
"description": {
"en": "Open Clash Meta and tap on Connect",
"fa": "Clash Meta را باز کنید و روی اتصال ضربه بزنید",
"ru": "Откройте Clash Meta и нажмите Подключиться",
"zh": "打开 Clash Meta 并点击连接"
}
}
}
],
"macos": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выбрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
}
},
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"windows": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выبрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
}
},
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"linux": [],
"androidTV": [
{
"id": "new-app-androidtv-1760203310792",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Button TextGoogle Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
],
"appleTV": [
{
"id": "new-app-appletv-1760203488851",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
]
}
}
+15 -10
View File
@@ -45,6 +45,7 @@ from app.handlers.admin import (
referrals as admin_referrals,
remnawave as admin_remnawave,
reports as admin_reports,
required_channels as admin_required_channels,
rules as admin_rules,
servers as admin_servers,
statistics as admin_statistics,
@@ -58,10 +59,12 @@ from app.handlers.admin import (
users as admin_users,
welcome_text as admin_welcome_text,
)
from app.handlers.channel_member import register_handlers as register_channel_member_handlers
from app.handlers.stars_payments import register_stars_handlers
from app.middlewares.auth import AuthMiddleware
from app.middlewares.blacklist import BlacklistMiddleware
from app.middlewares.button_stats import ButtonStatsMiddleware
from app.middlewares.chat_type_filter import ChatTypeFilterMiddleware
from app.middlewares.context_binding import ContextVarsMiddleware
from app.middlewares.global_error import GlobalErrorMiddleware
from app.middlewares.logging import LoggingMiddleware
@@ -115,6 +118,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.message.middleware(ContextVarsMiddleware())
dp.callback_query.middleware(ContextVarsMiddleware())
dp.pre_checkout_query.middleware(ContextVarsMiddleware())
chat_type_filter = ChatTypeFilterMiddleware()
dp.message.middleware(chat_type_filter)
dp.callback_query.middleware(chat_type_filter)
dp.message.middleware(GlobalErrorMiddleware())
dp.callback_query.middleware(GlobalErrorMiddleware())
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
@@ -126,8 +132,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.message.middleware(blacklist_middleware)
dp.callback_query.middleware(blacklist_middleware)
dp.pre_checkout_query.middleware(blacklist_middleware)
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
throttling_middleware = ThrottlingMiddleware()
dp.message.middleware(throttling_middleware)
dp.callback_query.middleware(throttling_middleware)
# Middleware для автоматического логирования кликов по кнопкам
if settings.MENU_LAYOUT_ENABLED:
@@ -135,15 +142,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(button_stats_middleware)
logger.info('📊 ButtonStatsMiddleware активирован')
if settings.CHANNEL_IS_REQUIRED_SUB:
from app.middlewares.channel_checker import ChannelCheckerMiddleware
from app.middlewares.channel_checker import ChannelCheckerMiddleware
channel_checker_middleware = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker_middleware)
dp.callback_query.middleware(channel_checker_middleware)
logger.info('🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован')
else:
logger.info('🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован')
channel_checker = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker)
dp.callback_query.middleware(channel_checker)
dp.message.middleware(AuthMiddleware())
dp.callback_query.middleware(AuthMiddleware())
dp.pre_checkout_query.middleware(AuthMiddleware())
@@ -194,6 +197,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_bulk_ban.register_bulk_ban_handlers(dp)
admin_blacklist.register_blacklist_handlers(dp)
admin_blocked_users.register_handlers(dp)
admin_required_channels.register_handlers(dp)
register_channel_member_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_contests.register_handlers(dp)
+4 -1
View File
@@ -2,21 +2,24 @@
from .jwt_handler import (
create_access_token,
create_auto_login_token,
create_refresh_token,
decode_token,
get_token_payload,
)
from .password_utils import hash_password, verify_password
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget, validate_telegram_oidc_token
__all__ = [
'create_access_token',
'create_auto_login_token',
'create_refresh_token',
'decode_token',
'get_token_payload',
'hash_password',
'validate_telegram_init_data',
'validate_telegram_login_widget',
'validate_telegram_oidc_token',
'verify_password',
]
+5 -5
View File
@@ -1,7 +1,7 @@
"""Email verification token generation and validation."""
import secrets
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from app.config import settings
@@ -24,7 +24,7 @@ def get_email_change_expires_at() -> datetime:
Datetime when the email change code expires
"""
minutes = settings.get_cabinet_email_change_code_expire_minutes()
return datetime.utcnow() + timedelta(minutes=minutes)
return datetime.now(UTC) + timedelta(minutes=minutes)
def generate_verification_token() -> str:
@@ -55,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:
@@ -66,7 +66,7 @@ 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: datetime | None) -> bool:
@@ -81,4 +81,4 @@ def is_token_expired(expires_at: datetime | None) -> bool:
"""
if expires_at is None:
return True
return datetime.utcnow() > expires_at
return datetime.now(UTC) > expires_at
+37 -7
View File
@@ -1,6 +1,6 @@
"""JWT token handling for cabinet authentication."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
import jwt
@@ -11,31 +11,49 @@ from app.config import settings
JWT_ALGORITHM = 'HS256'
def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
def create_access_token(
user_id: int,
telegram_id: int | None = None,
*,
permissions: list[str] | None = None,
roles: list[str] | None = None,
role_level: int = 0,
) -> str:
"""
Create a short-lived access token.
Args:
user_id: Database user ID
telegram_id: Telegram user ID (optional for email-only users)
permissions: RBAC permission strings to embed in token
roles: Role names to embed in token
role_level: Maximum role level (0 = no special level)
Returns:
Encoded JWT access token
"""
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),
'type': 'access',
'exp': expires,
'iat': datetime.utcnow(),
'iat': datetime.now(UTC),
}
# Добавляем telegram_id только если он есть
if telegram_id is not None:
payload['telegram_id'] = telegram_id
# RBAC data — only include when provided to keep token compact
if permissions is not None:
payload['permissions'] = permissions
if roles is not None:
payload['roles'] = roles
if role_level > 0:
payload['role_level'] = role_level
secret = settings.get_cabinet_jwt_secret()
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
@@ -51,13 +69,13 @@ 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(),
'iat': datetime.now(UTC),
}
secret = settings.get_cabinet_jwt_secret()
@@ -105,7 +123,19 @@ def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, An
return payload
def create_auto_login_token(user_id: int, ttl_hours: int = 72) -> str:
"""Short-lived JWT for auto-login from guest purchase success page."""
expires = datetime.now(UTC) + timedelta(hours=ttl_hours)
payload = {
'sub': str(user_id),
'type': 'auto_login',
'exp': expires,
'iat': datetime.now(UTC),
}
return jwt.encode(payload, settings.get_cabinet_jwt_secret(), algorithm=JWT_ALGORITHM)
def get_refresh_token_expires_at() -> datetime:
"""Get the expiration datetime for a new refresh token."""
expire_days = settings.get_cabinet_refresh_token_expire_days()
return datetime.utcnow() + timedelta(days=expire_days)
return datetime.now(UTC) + timedelta(days=expire_days)
+154
View File
@@ -0,0 +1,154 @@
"""Temporary merge token management for account linking.
Stores short-lived tokens in Redis so the user can confirm merging
two cabinet accounts (primary absorbs secondary) via a separate
confirmation endpoint.
"""
import secrets
from datetime import UTC, datetime
from typing import Any
import structlog
from app.utils.cache import cache, cache_key
logger = structlog.get_logger(__name__)
MERGE_TOKEN_TTL_SECONDS = 1800 # 30 minutes
MERGE_TOKEN_PREFIX = 'account_merge'
async def create_merge_token(
primary_user_id: int,
secondary_user_id: int,
provider: str,
provider_id: str,
) -> str:
"""Generate a merge token and store its payload in Redis.
The token is a one-time confirmation handle: whoever presents it
within ``MERGE_TOKEN_TTL_SECONDS`` can execute the account merge.
Returns the raw token string (URL-safe base64, 32 bytes of entropy).
Raises ``RuntimeError`` if Redis write fails.
"""
token = secrets.token_urlsafe(32)
value: dict[str, Any] = {
'primary_user_id': primary_user_id,
'secondary_user_id': secondary_user_id,
'provider': provider,
'provider_id': provider_id,
'created_at': datetime.now(UTC).isoformat(),
}
key = cache_key(MERGE_TOKEN_PREFIX, token)
stored = await cache.set(key, value, expire=MERGE_TOKEN_TTL_SECONDS)
if not stored:
logger.error(
'Failed to store merge token in Redis',
primary_user_id=primary_user_id,
secondary_user_id=secondary_user_id,
provider=provider,
)
raise RuntimeError('Failed to store merge token')
logger.info(
'Merge token created',
primary_user_id=primary_user_id,
secondary_user_id=secondary_user_id,
provider=provider,
provider_id=provider_id,
)
return token
async def get_merge_token_data(token: str) -> dict[str, Any] | None:
"""Read merge token payload *without* consuming it.
Intended for preview / confirmation screens where the user sees
what will happen before they press "Confirm".
Returns ``None`` when the token is expired, missing, or malformed.
"""
key = cache_key(MERGE_TOKEN_PREFIX, token)
data: Any = await cache.get(key)
if data is None or not isinstance(data, dict):
return None
return data
async def consume_merge_token(token: str) -> dict[str, Any] | None:
"""Atomically read and delete a merge token (GETDEL).
This prevents double-merge race conditions: only the first caller
that reaches Redis will get the payload; every subsequent attempt
receives ``None``.
Returns the stored dict or ``None`` if already consumed / expired.
"""
key = cache_key(MERGE_TOKEN_PREFIX, token)
data: Any = await cache.getdel(key)
if data is None or not isinstance(data, dict):
return None
logger.info(
'Merge token consumed',
primary_user_id=data.get('primary_user_id'),
secondary_user_id=data.get('secondary_user_id'),
provider=data.get('provider'),
)
return data
_MAX_MERGE_RESTORE_ATTEMPTS = 3
async def restore_merge_token(token: str, data: dict[str, Any]) -> bool:
"""Re-store a consumed merge token so the user can retry after a DB failure.
Uses the remaining TTL based on the original ``created_at``.
Uses SETNX to avoid overwriting a fresh token.
Caps restore attempts to prevent infinite retry cycles.
Returns ``True`` if restored, ``False`` if exhausted or Redis write failed.
"""
restore_count = data.get('_restore_count', 0) + 1
if restore_count > _MAX_MERGE_RESTORE_ATTEMPTS:
logger.warning(
'Merge token exhausted restore attempts',
primary_user_id=data.get('primary_user_id'),
secondary_user_id=data.get('secondary_user_id'),
restore_count=restore_count,
)
return False
# Shallow copy to avoid mutating the caller's dict
data = {**data, '_restore_count': restore_count}
created_at_str: str = data.get('created_at', '')
try:
created_at = datetime.fromisoformat(created_at_str)
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=UTC)
elapsed = (datetime.now(UTC) - created_at).total_seconds()
remaining_ttl = max(1, min(int(MERGE_TOKEN_TTL_SECONDS - elapsed), MERGE_TOKEN_TTL_SECONDS))
except (ValueError, TypeError):
remaining_ttl = 60 # brief retry window — fail closed
key = cache_key(MERGE_TOKEN_PREFIX, token)
stored = await cache.setnx(key, data, expire=remaining_ttl)
if stored:
logger.info(
'Merge token restored after failed merge',
primary_user_id=data.get('primary_user_id'),
secondary_user_id=data.get('secondary_user_id'),
remaining_ttl=remaining_ttl,
restore_count=restore_count,
)
else:
logger.error(
'Failed to restore merge token to Redis (key may already exist)',
primary_user_id=data.get('primary_user_id'),
secondary_user_id=data.get('secondary_user_id'),
)
return bool(stored)
+137 -53
View File
@@ -1,5 +1,7 @@
"""OAuth 2.0 provider implementations for cabinet authentication."""
import base64
import hashlib
import secrets
from abc import ABC, abstractmethod
from typing import Any, TypedDict
@@ -33,7 +35,7 @@ class OAuthTokenResponse(TypedDict, total=False):
expires_in: int
refresh_token: str
scope: str
# VK-specific: email and user_id come in token response
# Provider-specific extra fields (optional)
email: str
user_id: int
@@ -67,15 +69,19 @@ class DiscordUserInfoResponse(TypedDict, total=False):
avatar: str
class VKUserInfoItem(TypedDict, total=False):
id: int
class VKIDUserData(TypedDict, total=False):
"""VK ID /oauth2/user_info response user object."""
user_id: str
first_name: str
last_name: str
photo_200: str
phone: str
avatar: str
email: str
class VKUserInfoResponse(TypedDict, total=False):
response: list[VKUserInfoItem]
class VKIDUserInfoResponse(TypedDict, total=False):
user: VKIDUserData
# --- Models ---
@@ -97,23 +103,45 @@ class OAuthUserInfo(BaseModel):
# --- CSRF state management (Redis) ---
async def generate_oauth_state(provider: str) -> str:
"""Generate a CSRF state token for OAuth flow. Stored in Redis with TTL."""
async def generate_oauth_state(provider: str, extra_data: dict[str, str] | None = None) -> str:
"""Generate a CSRF state token for OAuth flow.
Stores provider name and optional extra data (e.g., PKCE code_verifier) in Redis with TTL.
Keys prefixed with '_' are ephemeral and NOT stored in Redis (e.g., _code_challenge).
CacheService handles JSON serialization internally.
"""
state = secrets.token_urlsafe(32)
await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS)
value: dict[str, Any] = {'provider': provider}
if extra_data:
# Filter out ephemeral keys (prefixed with '_') — they're only needed for the URL
value.update({k: v for k, v in extra_data.items() if not k.startswith('_')})
stored = await cache.set(cache_key('oauth_state', state), value, expire=STATE_TTL_SECONDS)
if not stored:
logger.error('Failed to store OAuth state in Redis')
raise RuntimeError('Failed to store OAuth state')
return state
async def validate_oauth_state(state: str, provider: str) -> bool:
"""Validate and consume a CSRF state token from Redis."""
async def validate_oauth_state(state: str, provider: str | None = None) -> dict[str, Any] | None:
"""Validate and consume a CSRF state token from Redis.
Uses atomic GETDEL to prevent TOCTOU race conditions.
Returns the stored data dict (with 'provider' key + any extra data) or None if invalid.
Args:
state: The state token to validate.
provider: If provided, verifies it matches the stored provider.
If None, skips provider check (used for server-complete flow).
"""
key = cache_key('oauth_state', state)
stored_provider: str | None = await cache.get(key)
if stored_provider is None:
return False
await cache.delete(key)
if stored_provider != provider:
return False
return True
data: Any = await cache.getdel(key)
if data is None:
return None
if not isinstance(data, dict):
return None
if provider is not None and data.get('provider') != provider:
return None
return data
# --- Provider implementations ---
@@ -130,13 +158,28 @@ class OAuthProvider(ABC):
self.client_secret = client_secret
self.redirect_uri = redirect_uri
@abstractmethod
def get_authorization_url(self, state: str) -> str:
"""Build the authorization URL for the provider."""
def prepare_auth_state(self) -> dict[str, str]:
"""Return extra data to store with OAuth state (e.g., PKCE code_verifier).
Override in providers that need PKCE or other state-stored data.
The returned dict is stored in Redis alongside the state token
and passed back via validate_oauth_state().
"""
return {}
@abstractmethod
async def exchange_code(self, code: str) -> OAuthTokenResponse:
"""Exchange authorization code for tokens."""
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
"""Build the authorization URL for the provider.
kwargs may contain extra data from prepare_auth_state() (e.g., code_challenge).
"""
@abstractmethod
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
"""Exchange authorization code for tokens.
kwargs may contain provider-specific params (e.g., device_id, code_verifier for VK).
"""
@abstractmethod
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
@@ -151,7 +194,7 @@ class GoogleProvider(OAuthProvider):
TOKEN_URL = 'https://oauth2.googleapis.com/token'
USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
def get_authorization_url(self, state: str) -> str:
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
@@ -164,7 +207,7 @@ class GoogleProvider(OAuthProvider):
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
@@ -209,7 +252,7 @@ class YandexProvider(OAuthProvider):
TOKEN_URL = 'https://oauth.yandex.com/token'
USERINFO_URL = 'https://login.yandex.ru/info'
def get_authorization_url(self, state: str) -> str:
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
@@ -221,7 +264,7 @@ class YandexProvider(OAuthProvider):
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
@@ -275,7 +318,7 @@ class DiscordProvider(OAuthProvider):
TOKEN_URL = 'https://discord.com/api/oauth2/token'
USERINFO_URL = 'https://discord.com/api/v10/users/@me'
def get_authorization_url(self, state: str) -> str:
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
@@ -287,7 +330,7 @@ class DiscordProvider(OAuthProvider):
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
@@ -329,35 +372,72 @@ class DiscordProvider(OAuthProvider):
class VKProvider(OAuthProvider):
"""VK ID OAuth 2.1 provider (id.vk.ru).
Uses OAuth 2.1 with mandatory PKCE (S256).
Old oauth.vk.com endpoints deprecated since September 30, 2025.
"""
name = 'vk'
display_name = 'VK'
AUTHORIZE_URL = 'https://oauth.vk.com/authorize'
TOKEN_URL = 'https://oauth.vk.com/access_token'
USERINFO_URL = 'https://api.vk.com/method/users.get'
API_VERSION = '5.131'
AUTHORIZE_URL = 'https://id.vk.ru/authorize'
TOKEN_URL = 'https://id.vk.ru/oauth2/auth'
USERINFO_URL = 'https://id.vk.ru/oauth2/user_info'
def get_authorization_url(self, state: str) -> str:
@staticmethod
def _generate_pkce() -> tuple[str, str]:
"""Generate PKCE code_verifier and code_challenge (S256)."""
code_verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(code_verifier.encode('ascii')).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
return code_verifier, code_challenge
def prepare_auth_state(self) -> dict[str, str]:
"""Generate PKCE pair. code_verifier stored in Redis, code_challenge only goes to URL."""
code_verifier, code_challenge = self._generate_pkce()
# code_challenge is ephemeral — only needed for the authorization URL,
# not stored in Redis (code_verifier is the secret used during token exchange)
return {
'code_verifier': code_verifier,
'_code_challenge': code_challenge,
}
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
code_challenge: str = kwargs.get('_code_challenge', '')
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'email',
'scope': 'vkid.personal_info email',
'state': state,
'v': self.API_VERSION,
'code_challenge': code_challenge,
'code_challenge_method': 'S256',
}
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
device_id: str = kwargs.get('device_id', '')
code_verifier: str = kwargs.get('code_verifier', '')
state: str = kwargs.get('state', '')
if not device_id:
raise ValueError('device_id is required for VK ID token exchange')
if not code_verifier:
raise ValueError('code_verifier is required for VK ID token exchange')
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
response = await client.post(
self.TOKEN_URL,
params={
'client_id': self.client_id,
'client_secret': self.client_secret,
data={
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': self.redirect_uri,
'client_id': self.client_id,
'device_id': device_id,
'code_verifier': code_verifier,
'state': state,
},
)
response.raise_for_status()
@@ -366,33 +446,37 @@ class VKProvider(OAuthProvider):
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
access_token = token_data['access_token']
user_id: int | None = token_data.get('user_id')
# VK returns email in token response, not in userinfo
email: str | None = token_data.get('email')
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
response = await client.post(
self.USERINFO_URL,
params={
data={
'access_token': access_token,
'fields': 'photo_200',
'v': self.API_VERSION,
'client_id': self.client_id,
},
)
response.raise_for_status()
data: VKUserInfoResponse = response.json()
data: VKIDUserInfoResponse = response.json()
users: list[Any] = data.get('response', [])
user_data: VKUserInfoItem = users[0] if users else {} # type: ignore[assignment]
user_data = data.get('user')
if not user_data:
raise ValueError('VK ID response missing user data')
user_id = user_data.get('user_id')
if not user_id:
raise ValueError('VK ID response missing user_id')
# VK ID returns email only if 'email' scope was granted and user has a verified email
email: str | None = user_data.get('email') or None
return OAuthUserInfo(
provider='vk',
provider_id=str(user_id or user_data.get('id', '')),
provider_id=str(user_id),
email=email,
email_verified=bool(email),
first_name=user_data.get('first_name'),
last_name=user_data.get('last_name'),
avatar_url=user_data.get('photo_200'),
avatar_url=user_data.get('avatar'),
)
+147 -21
View File
@@ -1,15 +1,27 @@
"""Telegram authentication validation for cabinet."""
import asyncio
import hashlib
import hmac
import json
from datetime import datetime
from datetime import UTC, datetime, timedelta
from typing import Any
from urllib.parse import parse_qsl, unquote
from urllib.parse import parse_qsl
import httpx
import jwt as pyjwt
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
# Maximum allowed clock skew (seconds) for auth_date — tolerates minor drift between Telegram servers and ours.
_MAX_CLOCK_SKEW_SECONDS = 300
def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int = 86400) -> bool:
"""
Validate Telegram Login Widget data.
@@ -29,17 +41,17 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
if not check_hash:
return False
# Check auth_date is not too old
# Check auth_date is present and within valid range
auth_date = auth_data.get('auth_date')
if auth_date:
try:
# Use UTC timestamp to avoid timezone issues
auth_time = datetime.utcfromtimestamp(int(auth_date))
age = (datetime.utcnow() - auth_time).total_seconds()
if age > max_age_seconds:
return False
except (ValueError, TypeError, OSError):
if not auth_date:
return False
try:
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
age = (datetime.now(UTC) - auth_time).total_seconds()
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
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]
@@ -76,17 +88,17 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
if not received_hash:
return None
# Check auth_date is not too old
# Check auth_date is present and within valid range
auth_date = parsed.get('auth_date')
if auth_date:
try:
# Use UTC timestamp to avoid timezone issues
auth_time = datetime.utcfromtimestamp(int(auth_date))
age = (datetime.utcnow() - auth_time).total_seconds()
if age > max_age_seconds:
return None
except (ValueError, TypeError, OSError):
if not auth_date:
return None
try:
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
age = (datetime.now(UTC) - auth_time).total_seconds()
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
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())]
@@ -105,7 +117,7 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
# Parse user data from the validated data
user_data_str = parsed.get('user')
if user_data_str:
user_data = json.loads(unquote(user_data_str))
user_data = json.loads(user_data_str)
return user_data
return parsed
@@ -125,3 +137,117 @@ def extract_telegram_user_from_init_data(init_data: str) -> dict[str, Any] | Non
User data dict with id, first_name, last_name, username, etc. or None if invalid
"""
return validate_telegram_init_data(init_data)
# JWKS cache (module-level, refreshed periodically)
_jwks_cache: dict[str, Any] = {}
_jwks_cache_expiry: datetime | None = None
_JWKS_CACHE_TTL_SECONDS = 3600 # 1 hour
_JWKS_URL = 'https://oauth.telegram.org/.well-known/jwks.json'
_OIDC_ISSUER = 'https://oauth.telegram.org'
_jwks_lock = asyncio.Lock()
_jwks_last_force_refresh: datetime | None = None
_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS = 30
def _build_public_keys(jwks_data: dict[str, Any]) -> dict[str, Any]:
"""Build public key mapping from JWKS data."""
public_keys: dict[str, Any] = {}
for key_data in jwks_data.get('keys', []):
kid = key_data.get('kid')
if kid:
public_keys[kid] = pyjwt.algorithms.RSAAlgorithm.from_jwk(key_data)
return public_keys
async def _get_jwks(force: bool = False) -> dict[str, Any]:
"""Fetch and cache Telegram OIDC JWKS keys."""
global _jwks_cache, _jwks_cache_expiry
now = datetime.now(UTC)
if not force and _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
return _jwks_cache
async with _jwks_lock:
# Double-check after acquiring lock
now = datetime.now(UTC)
if not force and _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
return _jwks_cache
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(_JWKS_URL)
response.raise_for_status()
_jwks_cache = response.json()
_jwks_cache_expiry = now + timedelta(seconds=_JWKS_CACHE_TTL_SECONDS)
return _jwks_cache
async def _force_refresh_jwks(kid: str) -> dict[str, Any] | None:
"""Force JWKS refresh with cooldown protection. Returns refreshed JWKS or None if on cooldown."""
global _jwks_cache_expiry, _jwks_last_force_refresh
async with _jwks_lock:
now = datetime.now(UTC)
if (
_jwks_last_force_refresh
and (now - _jwks_last_force_refresh).total_seconds() < _JWKS_FORCE_REFRESH_COOLDOWN_SECONDS
):
logger.warning('Telegram OIDC: JWKS force refresh on cooldown', kid=kid)
return None
_jwks_last_force_refresh = now
_jwks_cache_expiry = None
return await _get_jwks(force=True)
async def validate_telegram_oidc_token(id_token: str, client_id: str) -> dict[str, Any] | None:
"""
Validate a Telegram OIDC id_token using JWKS.
Args:
id_token: JWT id_token from Telegram OIDC flow
client_id: Expected audience (bot's numeric ID as string)
Returns:
Decoded claims dict if valid, None otherwise.
Claims include: sub, id, name, preferred_username, picture, iss, aud, exp, iat
"""
try:
# Build public keys from JWKS
jwks_data = await _get_jwks()
public_keys = _build_public_keys(jwks_data)
# Decode header to get kid
unverified_header = pyjwt.get_unverified_header(id_token)
kid = unverified_header.get('kid')
# If kid not found, force JWKS refresh (key rotation) with cooldown
if kid and kid not in public_keys:
refreshed = await _force_refresh_jwks(kid)
if refreshed:
public_keys = _build_public_keys(refreshed)
if not kid or kid not in public_keys:
logger.warning('Telegram OIDC: unknown kid in id_token', kid=kid)
return None
claims = pyjwt.decode(
id_token,
key=public_keys[kid],
algorithms=['RS256'],
audience=client_id,
issuer=_OIDC_ISSUER,
options={'require': ['exp', 'iat', 'iss', 'aud', 'sub']},
)
return claims
except pyjwt.ExpiredSignatureError:
logger.warning('Telegram OIDC: id_token expired')
return None
except pyjwt.InvalidTokenError as e:
logger.warning('Telegram OIDC: invalid id_token', error=str(e))
return None
except httpx.HTTPError as e:
logger.error('Telegram OIDC: failed to fetch JWKS', error=str(e))
return None
+190 -53
View File
@@ -1,10 +1,7 @@
"""FastAPI dependencies for cabinet module."""
import asyncio
import structlog
from aiogram import Bot
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,23 +13,14 @@ from app.services.blacklist_service import blacklist_service
from app.services.maintenance_service import maintenance_service
from .auth.jwt_handler import get_token_payload
from .auth.telegram_auth import validate_telegram_init_data
from .ip_utils import get_client_ip
logger = structlog.get_logger(__name__)
security = HTTPBearer(auto_error=False)
# Кешированный Bot для проверки подписки на канал
_channel_check_bot: Bot | None = None
def _get_channel_check_bot() -> Bot:
"""Получить или создать Bot для проверки подписки на канал."""
global _channel_check_bot
if _channel_check_bot is None:
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
return _channel_check_bot
async def get_cabinet_db() -> AsyncSession:
"""Get database session for cabinet operations."""
@@ -44,6 +32,7 @@ async def get_cabinet_db() -> AsyncSession:
async def get_current_cabinet_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
@@ -51,6 +40,7 @@ async def get_current_cabinet_user(
Get current authenticated cabinet user from JWT token.
Args:
request: FastAPI request object (for reading X-Telegram-Init-Data header)
credentials: HTTP Bearer credentials
db: Database session
@@ -105,6 +95,34 @@ async def get_current_cabinet_user(
detail='User account is not active',
)
# Defense in depth: cross-validate Telegram identity.
# The frontend sends X-Telegram-Init-Data on every request.
# If the header is present and cryptographically valid, verify that
# the Telegram user ID matches the JWT user's telegram_id.
# This prevents cross-account token reuse when Telegram WebView
# shares localStorage across accounts on the same device.
init_data_raw = request.headers.get('X-Telegram-Init-Data')
if init_data_raw and user.telegram_id is not None:
# Use generous max_age: Telegram Desktop caches initData
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
if tg_user is None:
logger.warning(
'Telegram initData validation failed but header was present',
jwt_user_id=user.id,
)
elif tg_user.get('id') != user.telegram_id:
logger.warning(
'Telegram identity mismatch: JWT belongs to different user than current Telegram account',
jwt_user_id=user.id,
jwt_telegram_id=user.telegram_id,
init_data_telegram_id=tg_user.get('id'),
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Session belongs to a different Telegram account. Please restart the app.',
headers={'WWW-Authenticate': 'Bearer'},
)
# Check blacklist
if user.telegram_id is not None:
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
@@ -132,47 +150,37 @@ async def get_current_cabinet_user(
},
)
# Check required channel subscription - ТОЛЬКО для Telegram юзеров
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
# Пропускаем проверку для email-only юзеров (нет telegram_id)
# Check required channel subscription - Telegram users only
if settings.CHANNEL_IS_REQUIRED_SUB:
# Skip for email-only users (no telegram_id)
if user.telegram_id is not None:
# Проверяем админа по telegram_id ИЛИ email
# Skip admin check
is_admin = settings.is_admin(
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
)
if not is_admin:
try:
bot = _get_channel_check_bot()
chat_member = await asyncio.wait_for(
bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=user.telegram_id),
timeout=10.0,
)
# Не закрываем сессию - бот переиспользуется
from app.services.channel_subscription_service import channel_subscription_service
if chat_member.status not in ['member', 'administrator', 'creator']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to our channel to continue',
'channel_link': settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except TimeoutError:
logger.warning('Timeout checking channel subscription for user', telegram_id=user.telegram_id)
# Don't block user if check times out
except Exception as e:
logger.warning(
'Failed to check channel subscription for user', telegram_id=user.telegram_id, error=e
channels_with_status = await channel_subscription_service.get_channels_with_status(user.telegram_id)
is_subscribed = (
all(ch['is_subscribed'] for ch in channels_with_status) if channels_with_status else True
)
if not is_subscribed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to the required channels to continue',
'channels': channels_with_status,
},
)
# Don't block user if check fails
return user
async def get_optional_cabinet_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User | None:
@@ -200,31 +208,160 @@ async def get_optional_cabinet_user(
if not user or user.status != 'active':
return None
# Cross-validate Telegram identity (same as get_current_cabinet_user)
init_data_raw = request.headers.get('X-Telegram-Init-Data')
if init_data_raw and user.telegram_id is not None:
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
if tg_user and tg_user.get('id') != user.telegram_id:
logger.warning(
'Telegram identity mismatch in optional auth',
jwt_user_id=user.id,
jwt_telegram_id=user.telegram_id,
init_data_telegram_id=tg_user.get('id'),
)
return None
return user
async def get_current_admin_user(
request: Request,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
"""
Get current authenticated admin user.
Checks if the user is admin by telegram_id or email.
Checks if the user is admin by legacy config (ADMIN_IDS / ADMIN_EMAILS)
**or** by RBAC role assignment (any role with level > 0).
Args:
request: FastAPI request object
user: Authenticated User object
db: Database session
Returns:
Authenticated admin User object
Raises:
HTTPException: If user is not an admin
HTTPException: If user is not an admin by either mechanism
"""
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Admin access required',
)
# Legacy check: config-based admin list
is_legacy_admin = settings.is_admin(
telegram_id=user.telegram_id,
email=user.email if user.email_verified else None,
)
if is_legacy_admin:
return user
return user
# RBAC check: user has any active role with level > 0
from app.database.crud.rbac import UserRoleCRUD
_permissions, _role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
if max_level > 0:
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Admin access required',
)
def require_permission(*permissions: str):
"""
FastAPI dependency factory for RBAC permission checks.
Usage::
@router.get("/users", dependencies=[Depends(require_permission("users:read"))])
async def list_users(...): ...
# Or inject the user:
@router.get("/users")
async def list_users(user: User = Depends(require_permission("users:read"))): ...
"""
if not permissions:
raise ValueError('require_permission() requires at least one permission argument')
async def dependency(
request: Request,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
from app.services.permission_service import PermissionService
try:
client_ip = get_client_ip(request)
except HTTPException:
logger.warning('Unable to determine client IP in require_permission')
client_ip = 'unknown'
user_agent = request.headers.get('user-agent', '')
# Extract resource_type from the first permission (section before ':')
resource_type = None
if permissions:
first_perm = permissions[0]
if ':' in first_perm:
resource_type = first_perm.split(':', maxsplit=1)[0]
for perm in permissions:
allowed, reason = await PermissionService.check_permission(
db,
user,
perm,
ip_address=client_ip,
)
if not allowed:
await PermissionService.log_action(
db,
user_id=user.id,
action=perm,
resource_type=resource_type,
status='denied',
ip_address=client_ip,
user_agent=user_agent,
request_method=request.method,
request_path=str(request.url.path),
details={'reason': reason},
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Permission denied: {reason}',
)
# Capture request details
details: dict = {
'method': request.method,
'path': str(request.url.path),
}
query_params = dict(request.query_params)
if query_params:
details['query_params'] = query_params
if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'):
try:
body = await request.body()
if body:
import json
details['request_body'] = json.loads(body)
except Exception:
pass
# Log successful access with all requested permissions
await PermissionService.log_action(
db,
user_id=user.id,
action=','.join(permissions),
resource_type=resource_type,
status='success',
ip_address=client_ip,
user_agent=user_agent,
request_method=request.method,
request_path=str(request.url.path),
details=details,
)
await db.commit()
return user
return dependency
+60
View File
@@ -0,0 +1,60 @@
"""Shared IP extraction utilities for cabinet module."""
from ipaddress import ip_address, ip_network
from fastapi import HTTPException, Request, status
from app.config import settings
def _is_trusted_proxy(peer_ip: str, trusted: set[str]) -> bool:
"""Check if peer IP matches any trusted proxy entry (IP or CIDR)."""
if not trusted:
return False
try:
addr = ip_address(peer_ip)
except ValueError:
return False
for entry in trusted:
try:
if '/' in entry:
if addr in ip_network(entry, strict=False):
return True
elif addr == ip_address(entry):
return True
except ValueError:
continue
return False
def get_client_ip(request: Request) -> str:
"""Extract real client IP, trusting proxy headers only from known proxies.
Raises HTTPException 400 if the peer IP cannot be determined
(request.client is None — e.g., test harness or broken transport).
"""
if not request.client:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Unable to determine client IP',
)
peer_ip = request.client.host
trusted_proxies = settings.get_cabinet_trusted_proxies()
if trusted_proxies and _is_trusted_proxy(peer_ip, trusted_proxies):
forwarded = request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
if forwarded:
try:
ip_address(forwarded)
return forwarded
except ValueError:
pass # invalid IP in header — fall through to peer_ip
real_ip = request.headers.get('X-Real-IP', '').strip()
if real_ip:
try:
ip_address(real_ip)
return real_ip
except ValueError:
pass
return peer_ip
+26 -1
View File
@@ -2,18 +2,26 @@
from fastapi import APIRouter
from .account_linking import merge_router as merge_router, router as account_linking_router
from .admin_apps import router as admin_apps_router
from .admin_audit_log import router as admin_audit_log_router
from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router
from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_channels import router as admin_channels_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_landings import router as admin_landings_router
from .admin_partners import router as admin_partners_router
from .admin_payment_methods import router as admin_payment_methods_router
from .admin_payments import router as admin_payments_router
from .admin_pinned_messages import router as admin_pinned_messages_router
from .admin_policies import router as admin_policies_router
from .admin_promo_offers import router as admin_promo_offers_router
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
from .admin_remnawave import router as admin_remnawave_router
from .admin_roles import router as admin_roles_router
from .admin_sales_stats import router as admin_sales_stats_router
from .admin_servers import router as admin_servers_router
from .admin_settings import router as admin_settings_router
from .admin_stats import router as admin_stats_router
@@ -23,14 +31,17 @@ 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 .landing import router as landing_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
@@ -43,6 +54,7 @@ from .ticket_notifications import (
from .tickets import router as tickets_router
from .websocket import router as websocket_router
from .wheel import router as wheel_router
from .withdrawal import router as withdrawal_router
# Main cabinet router
@@ -51,9 +63,13 @@ router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
# Include all sub-routers
router.include_router(auth_router)
router.include_router(oauth_router)
router.include_router(account_linking_router)
router.include_router(merge_router)
router.include_router(subscription_router)
router.include_router(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)
@@ -64,6 +80,7 @@ router.include_router(promo_router)
router.include_router(notifications_router)
router.include_router(info_router)
router.include_router(branding_router)
router.include_router(landing_router)
router.include_router(media_router)
# Wheel routes
@@ -73,18 +90,21 @@ router.include_router(wheel_router)
router.include_router(admin_ticket_notifications_router)
router.include_router(admin_tickets_router)
router.include_router(admin_settings_router)
router.include_router(admin_apps_router)
router.include_router(admin_wheel_router)
router.include_router(admin_tariffs_router)
router.include_router(admin_servers_router)
router.include_router(admin_stats_router)
router.include_router(admin_sales_stats_router)
router.include_router(admin_ban_system_router)
router.include_router(admin_broadcasts_router)
router.include_router(admin_promocodes_router)
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_landings_router)
router.include_router(admin_payments_router)
router.include_router(admin_promo_offers_router)
router.include_router(admin_remnawave_router)
@@ -93,6 +113,11 @@ router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
router.include_router(admin_pinned_messages_router)
router.include_router(admin_button_styles_router)
router.include_router(admin_channels_router)
router.include_router(admin_apps_router)
router.include_router(admin_roles_router)
router.include_router(admin_policies_router)
router.include_router(admin_audit_log_router)
# WebSocket route
router.include_router(websocket_router)
+825
View File
@@ -0,0 +1,825 @@
"""Account linking and merge routes for cabinet.
Router 1 (`router`): JWT-protected endpoints for linking/unlinking OAuth providers.
Exception: `link/server-complete` uses state-token auth instead of JWT (for Mini App external browser flow).
Router 2 (`merge_router`): Public endpoints for merge preview and execution.
"""
from datetime import UTC, datetime
from typing import Literal, NotRequired, TypedDict
import structlog
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.user import (
OAUTH_PROVIDER_COLUMNS,
clear_user_oauth_provider_id,
get_user_by_id,
get_user_by_oauth_provider,
get_user_by_telegram_id,
set_user_oauth_provider_id,
)
from app.database.models import User
from app.services.account_merge_service import compute_auth_methods, execute_merge, get_merge_preview
from app.utils.cache import RateLimitCache
from ..auth.merge_service import (
MERGE_TOKEN_TTL_SECONDS,
consume_merge_token,
create_merge_token,
get_merge_token_data,
restore_merge_token,
)
from ..auth.oauth_providers import (
generate_oauth_state,
get_provider,
validate_oauth_state,
)
from ..auth.telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..ip_utils import get_client_ip
from ..schemas.auth import UserResponse
from .auth import _create_auth_response, _store_refresh_token, _user_to_response
logger = structlog.get_logger(__name__)
OAuthProviderName = Literal['google', 'yandex', 'discord', 'vk']
# Ensure OAuthProviderName Literal stays in sync with OAUTH_PROVIDER_COLUMNS
_EXPECTED_PROVIDERS = {'google', 'yandex', 'discord', 'vk'}
if set(OAUTH_PROVIDER_COLUMNS.keys()) != _EXPECTED_PROVIDERS:
raise RuntimeError(
f'OAuthProviderName Literal is out of sync with OAUTH_PROVIDER_COLUMNS: '
f'{set(OAUTH_PROVIDER_COLUMNS.keys())} != {_EXPECTED_PROVIDERS}'
)
class OAuthStateData(TypedDict):
"""Typed dict for Redis-stored OAuth state data."""
provider: str # Always present
linking: NotRequired[str] # 'true' if account linking flow
user_id: NotRequired[str] # ID of user who initiated linking
code_verifier: NotRequired[str] # PKCE code verifier (VK)
def _get_active_providers() -> list[str]:
"""Вернуть список активных провайдеров аутентификации (только включённые)."""
from app.config import settings
providers: list[str] = ['telegram']
if settings.is_cabinet_email_auth_enabled():
providers.append('email')
providers.extend(settings.get_enabled_oauth_provider_names())
return providers
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class LinkedProvider(BaseModel):
provider: str
linked: bool
identifier: str | None = None
class LinkedProvidersResponse(BaseModel):
providers: list[LinkedProvider]
class LinkInitResponse(BaseModel):
authorize_url: str
state: str
class LinkCallbackRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=2048, description='Authorization code from provider')
state: str = Field(..., min_length=1, max_length=128, description='CSRF state token')
device_id: str | None = Field(None, max_length=256, description='Device ID from VK ID callback')
class LinkCallbackResponse(BaseModel):
success: bool
message: str | None = None
merge_required: bool = False
merge_token: str | None = None
class UnlinkResponse(BaseModel):
success: bool
class LinkTelegramRequest(BaseModel):
"""Request for linking Telegram account. Supply EITHER init_data OR widget fields."""
# Mini App: Telegram WebApp initData
init_data: str | None = Field(None, max_length=4096, description='Telegram WebApp initData string')
# Login Widget fields
id: int | None = Field(None, description='Telegram user ID from Login Widget')
first_name: str | None = Field(None, max_length=256, description="User's first name")
last_name: str | None = Field(None, max_length=256, description="User's last name")
username: str | None = Field(None, max_length=256, description="User's username")
photo_url: str | None = Field(None, max_length=2048, description="User's photo URL")
auth_date: int | None = Field(None, description='Unix timestamp of authentication')
hash: str | None = Field(None, min_length=64, max_length=64, description='Authentication hash (SHA-256 hex)')
@model_validator(mode='after')
def check_exclusive(self) -> 'LinkTelegramRequest':
has_init = self.init_data is not None
has_widget = self.id is not None or self.hash is not None or self.auth_date is not None
if has_init and has_widget:
raise ValueError('Provide either init_data or Login Widget fields, not both')
if not has_init and not has_widget:
raise ValueError('Provide either init_data or Login Widget fields (id, auth_date, hash)')
if has_widget and not (self.id is not None and self.auth_date is not None and self.hash is not None):
raise ValueError('Login Widget mode requires id, auth_date, and hash fields')
return self
class MergePreviewSubscription(BaseModel):
status: str
is_trial: bool
end_date: datetime | None = None
traffic_limit_gb: float
traffic_used_gb: float
device_limit: int
tariff_name: str | None = None
autopay_enabled: bool
class MergePreviewUser(BaseModel):
id: int
username: str | None = None
first_name: str | None = None
email: str | None = None
auth_methods: list[str]
balance_kopeks: int = 0
subscription: MergePreviewSubscription | None = None
created_at: datetime | None = None
class MergePreviewResponse(BaseModel):
primary: MergePreviewUser
secondary: MergePreviewUser
expires_in_seconds: int
class MergeRequest(BaseModel):
keep_subscription_from: int = Field(..., description='User ID whose subscription to keep')
class MergeResponse(BaseModel):
success: bool
access_token: str | None = None
refresh_token: str | None = None
user: UserResponse | None = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_provider_identifier(user: User, provider: str) -> str | None:
"""Return the identifier (provider_id or email) for a given provider, or None."""
match provider:
case 'telegram':
return str(user.telegram_id) if user.telegram_id else None
case 'email':
return user.email if user.email and user.password_hash else None
case _:
column = OAUTH_PROVIDER_COLUMNS.get(provider)
if not column:
return None
value = getattr(user, column, None)
return str(value) if value else None
def _count_auth_methods(user: User) -> int:
"""Count how many auth methods the user has linked."""
return len(compute_auth_methods(user))
async def _exchange_and_link_oauth(
*,
db: AsyncSession,
user: User,
provider: str,
code: str,
state: str,
state_data: OAuthStateData,
device_id: str | None,
log_context: str,
) -> LinkCallbackResponse:
"""Shared OAuth linking logic: exchange code, fetch user info, link or merge.
Used by both link_provider_callback (JWT-authed) and link_server_complete (state-authed).
"""
oauth_provider = get_provider(provider)
if not oauth_provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Requested OAuth provider is not available',
)
# Exchange code for tokens
exchange_kwargs: dict[str, str] = {'state': state}
code_verifier = state_data.get('code_verifier')
if code_verifier:
exchange_kwargs['code_verifier'] = code_verifier
if device_id:
exchange_kwargs['device_id'] = device_id
try:
token_data = await oauth_provider.exchange_code(code, **exchange_kwargs)
except Exception as exc:
logger.error('OAuth code exchange failed', context=log_context, provider=provider, exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to exchange authorization code',
) from exc
# Fetch user info from provider
try:
user_info = await oauth_provider.get_user_info(token_data)
except Exception as exc:
logger.error('OAuth user info fetch failed', context=log_context, provider=provider, exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to fetch user information from provider',
) from exc
# Check if provider_id is already linked to THIS user
column = OAUTH_PROVIDER_COLUMNS[provider]
current_value = getattr(user, column, None)
if current_value and str(current_value) == user_info.provider_id:
return LinkCallbackResponse(success=True, message='already_linked')
# Check if provider_id is linked to ANOTHER user
existing_user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
if existing_user and existing_user.id != user.id:
logger.info(
'Account linking conflict: provider already linked to another user',
context=log_context,
provider=provider,
provider_id=user_info.provider_id,
current_user_id=user.id,
existing_user_id=existing_user.id,
)
merge_token = await create_merge_token(
primary_user_id=user.id,
secondary_user_id=existing_user.id,
provider=provider,
provider_id=user_info.provider_id,
)
return LinkCallbackResponse(
success=False,
merge_required=True,
merge_token=merge_token,
)
# Link the provider to current user
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='This provider account was just linked to another user',
) from exc
logger.info(
'OAuth provider linked to account',
context=log_context,
provider=provider,
provider_id=user_info.provider_id,
user_id=user.id,
)
return LinkCallbackResponse(success=True, message='linked')
# ---------------------------------------------------------------------------
# Router 1: Account linking (JWT required)
# ---------------------------------------------------------------------------
router = APIRouter(prefix='/auth/account', tags=['Cabinet Account Linking'])
@router.get('/linked-providers', response_model=LinkedProvidersResponse)
async def get_linked_providers(
user: User = Depends(get_current_cabinet_user),
) -> LinkedProvidersResponse:
"""Return all auth methods with their link status for the current user."""
providers: list[LinkedProvider] = []
for provider in _get_active_providers():
identifier = _get_provider_identifier(user, provider)
providers.append(
LinkedProvider(
provider=provider,
linked=identifier is not None,
identifier=identifier,
)
)
return LinkedProvidersResponse(providers=providers)
@router.get('/link/{provider}/init', response_model=LinkInitResponse)
async def link_provider_init(
provider: OAuthProviderName,
user: User = Depends(get_current_cabinet_user),
) -> LinkInitResponse:
"""Start OAuth flow for linking a new provider to the current account."""
# Check if already linked
column = OAUTH_PROVIDER_COLUMNS[provider]
if getattr(user, column, None):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Provider is already linked to your account',
)
oauth_provider = get_provider(provider)
if not oauth_provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Requested OAuth provider is not available',
)
# Generate PKCE data for VK (and potentially future providers)
auth_extra = oauth_provider.prepare_auth_state()
extra_data: dict[str, str] = {
'linking': 'true',
'user_id': str(user.id),
}
if auth_extra:
extra_data.update(auth_extra)
state = await generate_oauth_state(provider, extra_data=extra_data)
# Only pass URL-safe params (prefixed with _) to authorize URL; exclude secrets like code_verifier
url_params = {k: v for k, v in auth_extra.items() if k.startswith('_')} if auth_extra else {}
authorize_url = oauth_provider.get_authorization_url(state, **url_params)
return LinkInitResponse(authorize_url=authorize_url, state=state)
@router.post('/link/{provider}/callback', response_model=LinkCallbackResponse)
async def link_provider_callback(
provider: OAuthProviderName,
request: LinkCallbackRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> LinkCallbackResponse:
"""Handle OAuth callback for linking a provider to the current account."""
# 1. Validate CSRF state
state_data = await validate_oauth_state(request.state, provider)
if not state_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid or expired OAuth state',
)
# 1b. Validate that this state was created for account linking (not login)
if state_data.get('linking') != 'true' or not state_data.get('user_id'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='OAuth state was not initiated for account linking',
)
# 1c. Validate that the user who initiated the link flow is the same user completing it
state_user_id = state_data['user_id']
if str(user.id) != state_user_id:
logger.warning(
'OAuth state user_id mismatch in link callback',
state_user_id=state_user_id,
current_user_id=user.id,
provider=provider,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='OAuth state was initiated by a different user',
)
# 2-7. Exchange code, fetch user info, link or merge
return await _exchange_and_link_oauth(
db=db,
user=user,
provider=provider,
code=request.code,
state=request.state,
state_data=state_data,
device_id=request.device_id,
log_context='link-callback',
)
@router.post('/unlink/{provider}', response_model=UnlinkResponse)
async def unlink_provider(
provider: OAuthProviderName,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> UnlinkResponse:
"""Unlink an OAuth provider from the current account."""
column = OAUTH_PROVIDER_COLUMNS[provider]
if not getattr(user, column, None):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Provider is not linked to your account',
)
# Ensure at least one auth method remains
if _count_auth_methods(user) <= 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot unlink last authentication method',
)
await clear_user_oauth_provider_id(db, user, provider)
await db.commit()
return UnlinkResponse(success=True)
@router.post('/link/telegram', response_model=LinkCallbackResponse)
async def link_telegram(
request: LinkTelegramRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> LinkCallbackResponse:
"""Link Telegram account via WebApp initData or Login Widget."""
# 1. Already has Telegram linked?
if user.telegram_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Telegram is already linked to your account',
)
# 2. Validate and extract telegram_id
telegram_id: int | None = None
telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
if request.init_data:
# Mini App flow: validate initData
user_data = validate_telegram_init_data(request.init_data)
if not user_data or not user_data.get('id'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid or expired Telegram initData',
)
telegram_id = int(user_data['id'])
telegram_username = user_data.get('username')
telegram_first_name = user_data.get('first_name')
telegram_last_name = user_data.get('last_name')
elif request.id is not None and request.hash is not None and request.auth_date is not None:
# Login Widget flow: validate widget hash
widget_data = {
'id': request.id,
'auth_date': request.auth_date,
'hash': request.hash,
}
if request.first_name is not None:
widget_data['first_name'] = request.first_name
if request.last_name is not None:
widget_data['last_name'] = request.last_name
if request.username is not None:
widget_data['username'] = request.username
if request.photo_url is not None:
widget_data['photo_url'] = request.photo_url
if not validate_telegram_login_widget(widget_data):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid or expired Telegram Login Widget data',
)
telegram_id = request.id
telegram_username = request.username
telegram_first_name = request.first_name
telegram_last_name = request.last_name
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Provide either init_data (Mini App) or Login Widget fields (id, auth_date, hash)',
)
# 3. Check if telegram_id is linked to ANOTHER user
existing_user = await get_user_by_telegram_id(db, telegram_id)
if existing_user and existing_user.id != user.id:
logger.info(
'Telegram linking conflict: telegram_id already linked to another user',
telegram_id=telegram_id,
current_user_id=user.id,
existing_user_id=existing_user.id,
)
merge_token = await create_merge_token(
primary_user_id=user.id,
secondary_user_id=existing_user.id,
provider='telegram',
provider_id=str(telegram_id),
)
return LinkCallbackResponse(
success=False,
merge_required=True,
merge_token=merge_token,
)
# 4. Link Telegram to current user
user.telegram_id = telegram_id
if telegram_username and not user.username:
user.username = telegram_username
if telegram_first_name and not user.first_name:
user.first_name = telegram_first_name
if telegram_last_name and not user.last_name:
user.last_name = telegram_last_name
user.updated_at = datetime.now(UTC)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='This Telegram account was just linked to another user',
) from exc
logger.info(
'Telegram linked to account',
telegram_id=telegram_id,
user_id=user.id,
)
return LinkCallbackResponse(success=True, message='linked')
# ---------------------------------------------------------------------------
# Server-side OAuth linking callback (NO JWT required — auth via state token)
# Used by Telegram Mini App where OAuth must open in external browser.
# ---------------------------------------------------------------------------
class ServerCompleteRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=2048, description='Authorization code from provider')
state: str = Field(..., min_length=1, max_length=128, description='CSRF state token')
provider: OAuthProviderName | None = Field(None, description='OAuth provider name (resolved from state if omitted)')
device_id: str | None = Field(None, max_length=256, description='Device ID from VK ID callback')
class ServerCompleteResponse(LinkCallbackResponse):
provider: str
@router.post('/link/server-complete', response_model=ServerCompleteResponse)
async def link_server_complete(
request: ServerCompleteRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
) -> ServerCompleteResponse:
"""Complete OAuth account linking without JWT.
Authenticates via the one-time state token stored in Redis during link_provider_init.
Used when OAuth opens in an external browser (e.g., from Telegram Mini App).
Provider is resolved from the state token if not explicitly provided.
"""
# Rate limit by IP (unauthenticated endpoint)
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'server_complete', limit=10, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
# 1. Validate and consume state from Redis (one-time use).
# Provider may be None — validate_oauth_state will skip provider check,
# and we'll resolve it from state_data['provider'].
state_data = await validate_oauth_state(request.state, request.provider)
if not state_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid or expired OAuth state',
)
# Resolve provider from state data (canonical source)
state_provider: str = state_data.get('provider', '')
if not state_provider or state_provider not in OAUTH_PROVIDER_COLUMNS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Could not determine OAuth provider',
)
# If request explicitly provides a provider, ensure it matches the state
if request.provider and request.provider != state_provider:
logger.warning(
'Provider mismatch in server-complete',
request_provider=request.provider,
state_provider=state_provider,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Provider does not match OAuth state',
)
provider_name: str = state_provider
# 2. Must be a linking state (not login)
if state_data.get('linking') != 'true' or not state_data.get('user_id'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='OAuth state was not initiated for account linking',
)
# 3. Parse and validate user_id from state
try:
user_id = int(state_data['user_id'])
except (ValueError, TypeError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid user_id in OAuth state',
) from exc
# 4. Load user from DB
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='User not found',
)
# 5-9. Exchange code, fetch user info, link or merge
result = await _exchange_and_link_oauth(
db=db,
user=user,
provider=provider_name,
code=request.code,
state=request.state,
state_data=state_data,
device_id=request.device_id,
log_context='server-complete',
)
return ServerCompleteResponse(
success=result.success,
message=result.message,
merge_required=result.merge_required,
merge_token=result.merge_token,
provider=provider_name,
)
# ---------------------------------------------------------------------------
# Router 2: Merge (NO JWT required)
# ---------------------------------------------------------------------------
merge_router = APIRouter(prefix='/auth/merge', tags=['Cabinet Account Merge'])
@merge_router.get('/{merge_token}', response_model=MergePreviewResponse)
async def get_merge_preview_endpoint(
raw_request: Request,
merge_token: str = Path(..., min_length=32, max_length=64),
db: AsyncSession = Depends(get_cabinet_db),
) -> MergePreviewResponse:
"""Preview the result of merging two accounts before confirming."""
# Rate limit by IP (unauthenticated endpoint)
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'merge_preview', limit=15, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
token_data = await get_merge_token_data(merge_token)
if not token_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Merge token is invalid or expired',
)
primary_user_id: int = token_data['primary_user_id']
secondary_user_id: int = token_data['secondary_user_id']
try:
preview = await get_merge_preview(db, primary_user_id, secondary_user_id)
except ValueError as exc:
logger.error('Merge preview failed', error=str(exc))
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='One or both users not found',
) from exc
# Calculate remaining TTL
created_at_str: str = token_data.get('created_at', '')
try:
created_at = datetime.fromisoformat(created_at_str)
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=UTC)
elapsed = (datetime.now(UTC) - created_at).total_seconds()
expires_in_seconds = max(0, int(MERGE_TOKEN_TTL_SECONDS - elapsed))
except (ValueError, TypeError):
expires_in_seconds = 0
return MergePreviewResponse(
primary=MergePreviewUser(**preview['primary']),
secondary=MergePreviewUser(**preview['secondary']),
expires_in_seconds=expires_in_seconds,
)
@merge_router.post('/{merge_token}', response_model=MergeResponse)
async def execute_merge_endpoint(
request: MergeRequest,
raw_request: Request,
merge_token: str = Path(..., min_length=32, max_length=64),
db: AsyncSession = Depends(get_cabinet_db),
) -> MergeResponse:
"""Execute account merge. Consumes the merge token (one-time use)."""
# Rate limit by IP (unauthenticated endpoint)
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'merge_execute', limit=5, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
# 1. Consume token atomically first (GETDEL — one-time use, no TOCTOU)
consumed = await consume_merge_token(merge_token)
if not consumed:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Merge token is invalid, expired, or already consumed',
)
primary_user_id: int = consumed['primary_user_id']
secondary_user_id: int = consumed['secondary_user_id']
provider: str = consumed.get('provider', '')
provider_id: str = consumed.get('provider_id', '')
# 2. Validate keep_subscription_from — restore token if invalid
if request.keep_subscription_from not in (primary_user_id, secondary_user_id):
await restore_merge_token(merge_token, consumed)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='keep_subscription_from must be one of the two user IDs being merged',
)
# Convert user_id to 'primary'/'secondary' string for execute_merge()
keep_from: Literal['primary', 'secondary'] = (
'primary' if request.keep_subscription_from == primary_user_id else 'secondary'
)
# 3. Execute merge
try:
merged_user = await execute_merge(
db=db,
primary_user_id=primary_user_id,
secondary_user_id=secondary_user_id,
keep_subscription_from=keep_from,
provider=provider,
provider_id=provider_id,
)
await db.commit()
except ValueError as exc:
await db.rollback()
await restore_merge_token(merge_token, consumed)
logger.error('Merge execution failed (ValueError)', error=str(exc))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Account merge cannot be completed. The accounts may have already been merged or deleted.',
) from exc
except Exception as exc:
await db.rollback()
await restore_merge_token(merge_token, consumed)
logger.exception('Merge execution failed')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Account merge failed due to an internal error',
) from exc
# 4. Re-fetch merged user with full relationships for auth response
merged_user = await get_user_by_id(db, primary_user_id)
if not merged_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load merged user',
)
# 5. Create auth tokens for the merged user
try:
auth_response = await _create_auth_response(merged_user, db)
await _store_refresh_token(db, merged_user.id, auth_response.refresh_token, device_info='merge')
except Exception as exc:
logger.exception('Failed to create auth tokens after merge')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Merge succeeded but failed to create new session',
) from exc
logger.info(
'Account merge completed successfully',
primary_user_id=primary_user_id,
secondary_user_id=secondary_user_id,
provider=provider,
)
return MergeResponse(
success=True,
access_token=auth_response.access_token,
refresh_token=auth_response.refresh_token,
user=_user_to_response(merged_user),
)
+30 -453
View File
@@ -1,7 +1,6 @@
"""Admin routes for managing VPN applications in app-config.json."""
"""Admin routes for managing RemnaWave app configuration."""
import json
from pathlib import Path
import re
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
@@ -13,7 +12,7 @@ from app.database.models import User
from app.services.remnawave_service import RemnaWaveService
from app.services.system_settings_service import bot_configuration_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -24,431 +23,6 @@ router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
# ============ Schemas ============
class LocalizedText(BaseModel):
"""Localized text for multiple languages."""
en: str = ''
ru: str = ''
zh: str | None = ''
fa: str | None = ''
class AppButton(BaseModel):
"""Button with link and localized text."""
buttonLink: str
buttonText: LocalizedText
class AppStep(BaseModel):
"""Step with description and optional buttons/title."""
description: LocalizedText
buttons: list[AppButton] | None = None
title: LocalizedText | None = None
class AppDefinition(BaseModel):
"""VPN application definition."""
id: str
name: str
isFeatured: bool = False
urlScheme: str
isNeedBase64Encoding: bool | None = None
installationStep: AppStep
addSubscriptionStep: AppStep
connectAndUseStep: AppStep
additionalBeforeAddSubscriptionStep: AppStep | None = None
additionalAfterAddSubscriptionStep: AppStep | None = None
class PlatformApps(BaseModel):
"""Apps for a specific platform."""
platform: str
apps: list[AppDefinition]
class AppConfigBranding(BaseModel):
"""Branding configuration."""
name: str
logoUrl: str
supportUrl: str
class AppConfigConfig(BaseModel):
"""Top-level config section."""
additionalLocales: list[str]
branding: AppConfigBranding
class AppConfigResponse(BaseModel):
"""Full app config response."""
config: AppConfigConfig
platforms: dict[str, list[AppDefinition]]
class CreateAppRequest(BaseModel):
"""Request to create a new app."""
platform: str
app: AppDefinition
class UpdateAppRequest(BaseModel):
"""Request to update an app."""
app: AppDefinition
class ReorderAppsRequest(BaseModel):
"""Request to reorder apps in a platform."""
app_ids: list[str]
class UpdateBrandingRequest(BaseModel):
"""Request to update branding."""
branding: AppConfigBranding
# ============ Helpers ============
def _get_config_path() -> Path:
"""Get path to app-config.json."""
return Path(settings.get_app_config_path())
def _load_config() -> dict:
"""Load app config from file."""
config_path = _get_config_path()
if not config_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'App config file not found: {config_path}',
)
try:
with open(config_path, encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to parse app config: {e}',
)
def _save_config(config: dict) -> None:
"""Save app config to file."""
config_path = _get_config_path()
try:
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2, ensure_ascii=False)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to save app config: {e}',
)
VALID_PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']
# ============ Routes ============
@router.get('', response_model=AppConfigResponse)
async def get_app_config(
admin: User = Depends(get_current_admin_user),
):
"""Get full app configuration."""
config = _load_config()
return config
@router.get('/platforms', response_model=list[str])
async def get_platforms(
admin: User = Depends(get_current_admin_user),
):
"""Get list of available platforms."""
return VALID_PLATFORMS
@router.get('/platforms/{platform}', response_model=list[AppDefinition])
async def get_platform_apps(
platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Get apps for a specific platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}',
)
config = _load_config()
platforms = config.get('platforms', {})
return platforms.get(platform, [])
@router.post('/platforms/{platform}', response_model=AppDefinition)
async def create_app(
platform: str,
request: CreateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Create a new app for a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
if platform not in platforms:
platforms[platform] = []
# Check if app with same ID already exists
existing_ids = [app.get('id') for app in platforms[platform]]
if request.app.id in existing_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App with ID '{request.app.id}' already exists in {platform}",
)
# Add new app
app_dict = request.app.model_dump(exclude_none=True)
platforms[platform].append(app_dict)
config['platforms'] = platforms
_save_config(config)
logger.info('Admin created app for platform', admin_id=admin.id, app_id=request.app.id, platform=platform)
return request.app
@router.put('/platforms/{platform}/{app_id}', response_model=AppDefinition)
async def update_app(
platform: str,
app_id: str,
request: UpdateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update an existing app."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Find and update app
app_index = None
for i, app in enumerate(apps):
if app.get('id') == app_id:
app_index = i
break
if app_index is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Update app
app_dict = request.app.model_dump(exclude_none=True)
apps[app_index] = app_dict
platforms[platform] = apps
config['platforms'] = platforms
_save_config(config)
logger.info('Admin updated app in platform', admin_id=admin.id, app_id=app_id, platform=platform)
return request.app
@router.delete('/platforms/{platform}/{app_id}')
async def delete_app(
platform: str,
app_id: str,
admin: User = Depends(get_current_admin_user),
):
"""Delete an app from a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Find and remove app
original_length = len(apps)
apps = [app for app in apps if app.get('id') != app_id]
if len(apps) == original_length:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
platforms[platform] = apps
config['platforms'] = platforms
_save_config(config)
logger.info('Admin deleted app from platform', admin_id=admin.id, app_id=app_id, platform=platform)
return {'status': 'deleted', 'app_id': app_id}
@router.post('/platforms/{platform}/reorder')
async def reorder_apps(
platform: str,
request: ReorderAppsRequest,
admin: User = Depends(get_current_admin_user),
):
"""Reorder apps in a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Create a map of apps by ID
apps_map = {app.get('id'): app for app in apps}
# Verify all IDs exist
for app_id in request.app_ids:
if app_id not in apps_map:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Reorder apps
reordered_apps = [apps_map[app_id] for app_id in request.app_ids]
# Add any apps that weren't in the reorder list (shouldn't happen but just in case)
for app in apps:
if app.get('id') not in request.app_ids:
reordered_apps.append(app)
platforms[platform] = reordered_apps
config['platforms'] = platforms
_save_config(config)
logger.info('Admin reordered apps in platform', admin_id=admin.id, platform=platform)
return {'status': 'reordered', 'order': request.app_ids}
@router.put('/branding', response_model=AppConfigBranding)
async def update_branding(
request: UpdateBrandingRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update branding configuration."""
config = _load_config()
if 'config' not in config:
config['config'] = {}
config['config']['branding'] = request.branding.model_dump()
_save_config(config)
logger.info('Admin updated branding', admin_id=admin.id)
return request.branding
@router.get('/branding', response_model=AppConfigBranding)
async def get_branding(
admin: User = Depends(get_current_admin_user),
):
"""Get branding configuration."""
config = _load_config()
branding = config.get('config', {}).get('branding', {})
return branding
@router.post('/platforms/{platform}/copy/{app_id}')
async def copy_app_to_platform(
platform: str,
app_id: str,
target_platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Copy an app from one platform to another."""
if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid platform(s)',
)
config = _load_config()
platforms = config.get('platforms', {})
source_apps = platforms.get(platform, [])
# Find source app
source_app = None
for app in source_apps:
if app.get('id') == app_id:
source_app = app.copy()
break
if not source_app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Generate new ID for copied app
import time
new_id = f'{app_id}-copy-{int(time.time())}'
source_app['id'] = new_id
# Add to target platform
if target_platform not in platforms:
platforms[target_platform] = []
platforms[target_platform].append(source_app)
config['platforms'] = platforms
_save_config(config)
logger.info(
'Admin copied app from to as',
admin_id=admin.id,
app_id=app_id,
platform=platform,
target_platform=target_platform,
new_id=new_id,
)
return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform}
# ============ RemnaWave Config Routes ============
class RemnaWaveConfigStatus(BaseModel):
"""Status of RemnaWave config integration."""
@@ -462,6 +36,11 @@ class UpdateRemnaWaveUuidRequest(BaseModel):
uuid: str | None = None
# ============ Helpers ============
_UUID_PATTERN = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
def _get_remnawave_config_uuid() -> str | None:
"""Get RemnaWave config UUID from system settings or env."""
try:
@@ -470,9 +49,12 @@ def _get_remnawave_config_uuid() -> str | None:
return settings.CABINET_REMNA_SUB_CONFIG
# ============ Routes ============
@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus)
async def get_remnawave_config_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('apps:read')),
):
"""Get RemnaWave config integration status."""
config_uuid = _get_remnawave_config_uuid()
@@ -485,27 +67,26 @@ async def get_remnawave_config_status(
@router.put('/remnawave/uuid', response_model=RemnaWaveConfigStatus)
async def set_remnawave_config_uuid(
request: UpdateRemnaWaveUuidRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('apps:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Set RemnaWave subscription config UUID."""
uuid_value = request.uuid.strip() if request.uuid else None
# Validate UUID format if provided
if uuid_value:
import re
uuid_pattern = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
if not uuid_pattern.match(uuid_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid UUID format',
)
if uuid_value and not _UUID_PATTERN.match(uuid_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid UUID format',
)
try:
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value)
await db.commit()
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG to', admin_id=admin.id, uuid_value=uuid_value)
from app.handlers.subscription.common import invalidate_app_config_cache
invalidate_app_config_cache()
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG', admin_id=admin.id, uuid_value=uuid_value)
except Exception as e:
logger.error('Error saving RemnaWave config UUID', error=e)
raise HTTPException(
@@ -521,17 +102,14 @@ async def set_remnawave_config_uuid(
@router.get('/remnawave/config')
async def get_remnawave_subscription_config(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('apps:read')),
):
"""
Fetch subscription page config from RemnaWave panel.
Uses CABINET_REMNA_SUB_CONFIG setting for the config UUID.
"""
"""Fetch subscription page config from RemnaWave panel."""
config_uuid = _get_remnawave_config_uuid()
if not config_uuid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='CABINET_REMNA_SUB_CONFIG is not configured',
detail='RemnaWave subscription config is not configured',
)
try:
@@ -541,10 +119,9 @@ async def get_remnawave_subscription_config(
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Subscription config '{config_uuid}' not found in RemnaWave",
detail='Subscription config not found',
)
# Return the raw config data from RemnaWave
return {
'uuid': config.uuid,
'name': config.name,
@@ -557,13 +134,13 @@ async def get_remnawave_subscription_config(
logger.error('Error fetching RemnaWave config', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch config from RemnaWave: {e!s}',
detail='Failed to fetch config from RemnaWave',
)
@router.get('/remnawave/configs')
async def list_remnawave_subscription_configs(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('apps:read')),
):
"""List available subscription page configs from RemnaWave panel."""
try:
@@ -582,5 +159,5 @@ async def list_remnawave_subscription_configs(
logger.error('Error listing RemnaWave configs', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch configs from RemnaWave: {e!s}',
detail='Failed to fetch configs from RemnaWave',
)
+208
View File
@@ -0,0 +1,208 @@
"""Admin audit log routes — view and export admin action history."""
from __future__ import annotations
import csv
import io
from datetime import UTC, datetime
from typing import Any
import structlog
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.rbac import AuditLogCRUD
from app.database.models import User
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/rbac/audit-log', tags=['Admin RBAC Audit Log'])
# ============ Schemas ============
class AuditLogEntry(BaseModel):
"""Single audit log entry."""
id: int
user_id: int
action: str
resource_type: str | None = None
resource_id: str | None = None
details: dict[str, Any] | None = None
ip_address: str | None = None
user_agent: str | None = None
status: str
request_method: str | None = None
request_path: str | None = None
created_at: datetime | None = None
user_first_name: str | None = None
user_email: str | None = None
class AuditLogListResponse(BaseModel):
"""Paginated audit log list."""
items: list[AuditLogEntry]
total: int
limit: int
offset: int
# ============ CSV Export ============
_CSV_COLUMNS = [
'id',
'user_id',
'action',
'resource_type',
'resource_id',
'status',
'ip_address',
'request_method',
'request_path',
'created_at',
'user_agent',
'details',
]
def _sanitize_csv_cell(value: str) -> str:
"""Prevent CSV formula injection by prefixing dangerous leading characters."""
if value and value[0] in ('=', '+', '-', '@', '\t', '\r'):
return f"'{value}"
return value
def _logs_to_csv(logs) -> str:
"""Serialize audit log entries to CSV string."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(_CSV_COLUMNS)
for log in logs:
writer.writerow(
[
log.id,
log.user_id,
log.action,
log.resource_type or '',
log.resource_id or '',
log.status,
log.ip_address or '',
log.request_method or '',
_sanitize_csv_cell(log.request_path or ''),
log.created_at.isoformat() if log.created_at else '',
_sanitize_csv_cell((log.user_agent or '')[:200]),
_sanitize_csv_cell(str(log.details) if log.details else ''),
]
)
return output.getvalue()
# ============ Routes ============
@router.get('', response_model=AuditLogListResponse)
async def list_audit_logs(
admin: User = Depends(require_permission('audit_log:read')),
db: AsyncSession = Depends(get_cabinet_db),
user_id: int | None = Query(default=None),
action: str | None = Query(default=None),
resource_type: str | None = Query(default=None),
status: str | None = Query(default=None),
date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0),
):
"""List audit log entries with optional filters and pagination."""
logs, total = await AuditLogCRUD.get_logs(
db,
user_id=user_id,
action=action,
resource_type=resource_type,
status=status,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=offset,
load_user=True,
)
items = [
AuditLogEntry(
id=log.id,
user_id=log.user_id,
action=log.action,
resource_type=log.resource_type,
resource_id=log.resource_id,
details=log.details,
ip_address=log.ip_address,
user_agent=log.user_agent,
status=log.status,
request_method=log.request_method,
request_path=log.request_path,
created_at=log.created_at,
user_first_name=log.user.first_name if log.user else None,
user_email=log.user.email if log.user else None,
)
for log in logs
]
return AuditLogListResponse(
items=items,
total=total,
limit=limit,
offset=offset,
)
@router.get('/export')
async def export_audit_logs(
admin: User = Depends(require_permission('audit_log:export')),
db: AsyncSession = Depends(get_cabinet_db),
user_id: int | None = Query(default=None),
action: str | None = Query(default=None),
resource_type: str | None = Query(default=None),
status: str | None = Query(default=None),
date_from: datetime | None = Query(default=None),
date_to: datetime | None = Query(default=None),
limit: int = Query(default=10000, ge=1, le=50000),
):
"""Export audit logs as CSV file."""
logs, _total = await AuditLogCRUD.get_logs(
db,
user_id=user_id,
action=action,
resource_type=resource_type,
status=status,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=0,
)
csv_content = _logs_to_csv(logs)
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
filename = f'audit_log_{timestamp}.csv'
logger.info(
'Admin exported audit logs',
admin_id=admin.id,
rows=len(logs),
filename=filename,
)
return StreamingResponse(
iter([csv_content]),
media_type='text/csv',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
+29 -29
View File
@@ -9,7 +9,7 @@ from app.config import settings
from app.database.models import User
from app.external.ban_system_api import BanSystemAPI, BanSystemAPIError
from ..dependencies import get_current_admin_user
from ..dependencies import require_permission
from ..schemas.ban_system import (
BanAgentHistoryItem,
BanAgentHistoryResponse,
@@ -103,7 +103,7 @@ async def _api_request(api: BanSystemAPI, method: str, *args, **kwargs) -> Any:
@router.get('/status', response_model=BanSystemStatusResponse)
async def get_ban_system_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanSystemStatusResponse:
"""Get Ban System integration status."""
return BanSystemStatusResponse(
@@ -117,7 +117,7 @@ async def get_ban_system_status(
@router.get('/stats/raw')
async def get_stats_raw(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> dict:
"""Get raw stats from Ban System API for debugging."""
api = _get_ban_api()
@@ -127,7 +127,7 @@ async def get_stats_raw(
@router.get('/stats', response_model=BanSystemStatsResponse)
async def get_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanSystemStatsResponse:
"""Get overall Ban System statistics."""
from datetime import datetime
@@ -181,7 +181,7 @@ async def get_users(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
status: str | None = Query(None, description='Filter: over_limit, with_limit, unlimited'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanUsersListResponse:
"""Get list of users from Ban System."""
api = _get_ban_api()
@@ -211,7 +211,7 @@ async def get_users(
@router.get('/users/over-limit', response_model=BanUsersListResponse)
async def get_users_over_limit(
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanUsersListResponse:
"""Get users who exceeded their device limit."""
api = _get_ban_api()
@@ -241,7 +241,7 @@ async def get_users_over_limit(
@router.get('/users/search/{query}')
async def search_users(
query: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanUsersListResponse:
"""Search for users."""
api = _get_ban_api()
@@ -272,7 +272,7 @@ async def search_users(
@router.get('/users/{email}', response_model=BanUserDetailResponse)
async def get_user_detail(
email: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanUserDetailResponse:
"""Get detailed user information."""
api = _get_ban_api()
@@ -325,7 +325,7 @@ async def get_user_detail(
@router.get('/punishments', response_model=BanPunishmentsListResponse)
async def get_punishments(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanPunishmentsListResponse:
"""Get list of active punishments (bans)."""
api = _get_ban_api()
@@ -360,7 +360,7 @@ async def get_punishments(
@router.post('/punishments/{user_id}/unban', response_model=UnbanResponse)
async def unban_user(
user_id: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:unban')),
) -> UnbanResponse:
"""Unban (enable) a user."""
api = _get_ban_api()
@@ -377,7 +377,7 @@ async def unban_user(
@router.post('/ban', response_model=UnbanResponse)
async def ban_user(
request: BanUserRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:ban')),
) -> UnbanResponse:
"""Manually ban a user."""
api = _get_ban_api()
@@ -401,7 +401,7 @@ async def ban_user(
async def get_punishment_history(
query: str,
limit: int = Query(20, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanHistoryResponse:
"""Get punishment history for a user."""
api = _get_ban_api()
@@ -438,7 +438,7 @@ async def get_punishment_history(
@router.get('/nodes', response_model=BanNodesListResponse)
async def get_nodes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanNodesListResponse:
"""Get list of connected nodes."""
api = _get_ban_api()
@@ -480,7 +480,7 @@ async def get_agents(
search: str | None = Query(None),
health: str | None = Query(None, description='healthy, warning, critical'),
agent_status: str | None = Query(None, alias='status', description='online, offline'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanAgentsListResponse:
"""Get list of monitoring agents."""
api = _get_ban_api()
@@ -579,7 +579,7 @@ async def get_agents(
@router.get('/agents/summary', response_model=BanAgentsSummary)
async def get_agents_summary(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanAgentsSummary:
"""Get agents summary statistics."""
api = _get_ban_api()
@@ -603,7 +603,7 @@ async def get_agents_summary(
@router.get('/traffic/violations', response_model=BanTrafficViolationsResponse)
async def get_traffic_violations(
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanTrafficViolationsResponse:
"""Get list of traffic limit violations."""
api = _get_ban_api()
@@ -637,7 +637,7 @@ async def get_traffic_violations(
@router.get('/traffic', response_model=BanTrafficResponse)
async def get_traffic(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanTrafficResponse:
"""Get full traffic statistics including top users."""
api = _get_ban_api()
@@ -681,7 +681,7 @@ async def get_traffic(
@router.get('/traffic/top')
async def get_traffic_top(
limit: int = Query(20, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> list[BanTrafficTopItem]:
"""Get top users by traffic."""
api = _get_ban_api()
@@ -744,7 +744,7 @@ def _parse_setting_response(key: str, data: Any, default_type: str = 'str') -> B
@router.get('/settings', response_model=BanSettingsResponse)
async def get_settings(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanSettingsResponse:
"""Get all Ban System settings."""
api = _get_ban_api()
@@ -802,7 +802,7 @@ async def get_settings(
@router.get('/settings/{key}')
async def get_setting(
key: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanSettingDefinition:
"""Get a specific setting."""
api = _get_ban_api()
@@ -815,7 +815,7 @@ async def get_setting(
async def set_setting(
key: str,
value: str = Query(...),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:edit')),
) -> BanSettingDefinition:
"""Set a setting value."""
api = _get_ban_api()
@@ -829,7 +829,7 @@ async def set_setting(
@router.post('/settings/{key}/toggle')
async def toggle_setting(
key: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:edit')),
) -> BanSettingDefinition:
"""Toggle a boolean setting."""
api = _get_ban_api()
@@ -846,7 +846,7 @@ async def toggle_setting(
@router.post('/settings/whitelist/add', response_model=UnbanResponse)
async def whitelist_add(
request: BanWhitelistRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:edit')),
) -> UnbanResponse:
"""Add user to whitelist."""
api = _get_ban_api()
@@ -863,7 +863,7 @@ async def whitelist_add(
@router.post('/settings/whitelist/remove', response_model=UnbanResponse)
async def whitelist_remove(
request: BanWhitelistRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:edit')),
) -> UnbanResponse:
"""Remove user from whitelist."""
api = _get_ban_api()
@@ -883,7 +883,7 @@ async def whitelist_remove(
@router.get('/report', response_model=BanReportResponse)
async def get_report(
hours: int = Query(24, ge=1, le=168),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanReportResponse:
"""Get period report."""
api = _get_ban_api()
@@ -913,7 +913,7 @@ async def get_report(
@router.get('/health', response_model=BanHealthResponse)
async def get_health(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanHealthResponse:
"""Get Ban System health status."""
api = _get_ban_api()
@@ -947,7 +947,7 @@ async def get_health(
@router.get('/health/detailed', response_model=BanHealthDetailedResponse)
async def get_health_detailed(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanHealthDetailedResponse:
"""Get detailed health information."""
api = _get_ban_api()
@@ -967,7 +967,7 @@ async def get_health_detailed(
async def get_agent_history(
node_name: str,
hours: int = Query(24, ge=1, le=168),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanAgentHistoryResponse:
"""Get agent statistics history."""
api = _get_ban_api()
@@ -1003,7 +1003,7 @@ async def get_agent_history(
async def get_user_punishment_history(
email: str,
limit: int = Query(20, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('ban_system:read')),
) -> BanHistoryResponse:
"""Get punishment history for a specific user."""
api = _get_ban_api()
+17 -15
View File
@@ -1,6 +1,6 @@
"""Admin routes for broadcasts in cabinet."""
from datetime import datetime
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -18,7 +18,7 @@ from app.services.broadcast_service import (
email_broadcast_service,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.broadcasts import (
BroadcastButton,
BroadcastButtonsResponse,
@@ -118,9 +118,10 @@ EMAIL_FILTER_GROUPS = {
def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
"""Serialize broadcast to response model."""
blocked = broadcast.blocked_count or 0
progress = 0.0
if broadcast.total_count > 0:
progress = round((broadcast.sent_count + broadcast.failed_count) / broadcast.total_count * 100, 1)
progress = round((broadcast.sent_count + broadcast.failed_count + blocked) / broadcast.total_count * 100, 1)
return BroadcastResponse(
id=broadcast.id,
@@ -133,6 +134,7 @@ 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,
@@ -245,7 +247,7 @@ def _validate_buttons(buttons: list[str]) -> bool:
@router.get('/filters', response_model=BroadcastFiltersResponse)
async def get_filters(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastFiltersResponse:
"""Get all available filters with user counts."""
@@ -308,7 +310,7 @@ async def get_filters(
@router.get('/tariffs', response_model=BroadcastTariffsResponse)
async def get_tariffs(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastTariffsResponse:
"""Get tariffs for broadcast filtering."""
@@ -331,7 +333,7 @@ async def get_tariffs(
@router.get('/buttons', response_model=BroadcastButtonsResponse)
async def get_buttons(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
) -> BroadcastButtonsResponse:
"""Get available buttons for broadcasts."""
default_buttons = set(DEFAULT_BROADCAST_BUTTONS)
@@ -350,7 +352,7 @@ async def get_buttons(
@router.post('/preview', response_model=BroadcastPreviewResponse)
async def preview_broadcast(
request: BroadcastPreviewRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastPreviewResponse:
"""Preview broadcast recipients count."""
@@ -379,7 +381,7 @@ async def preview_broadcast(
@router.post('', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_broadcast(
request: BroadcastCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Create and start a broadcast."""
@@ -459,7 +461,7 @@ async def create_broadcast(
@router.get('', response_model=BroadcastListResponse)
async def list_broadcasts(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
@@ -485,7 +487,7 @@ async def list_broadcasts(
@router.get('/email-filters', response_model=EmailFiltersResponse)
async def get_email_filters(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> EmailFiltersResponse:
"""Get all available email filters with user counts."""
@@ -521,7 +523,7 @@ async def get_email_filters(
@router.post('/email-preview', response_model=EmailPreviewResponse)
async def preview_email_broadcast(
request: EmailPreviewRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> EmailPreviewResponse:
"""Preview email broadcast recipients count."""
@@ -546,7 +548,7 @@ async def preview_email_broadcast(
@router.post('/send', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_combined_broadcast(
request: CombinedBroadcastCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:send')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Create and start a combined broadcast (telegram/email/both)."""
@@ -677,7 +679,7 @@ async def create_combined_broadcast(
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
async def get_broadcast(
broadcast_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Get broadcast details."""
@@ -693,7 +695,7 @@ async def get_broadcast(
@router.post('/{broadcast_id}/stop', response_model=BroadcastResponse)
async def stop_broadcast(
broadcast_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('broadcasts:send')),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Stop a running broadcast (telegram or email)."""
@@ -724,7 +726,7 @@ async def stop_broadcast(
broadcast.status = 'cancelling'
else:
broadcast.status = 'cancelled'
broadcast.completed_at = datetime.utcnow()
broadcast.completed_at = datetime.now(UTC)
await db.commit()
await db.refresh(broadcast)
+4 -4
View File
@@ -17,7 +17,7 @@ from app.utils.button_styles_cache import (
load_button_styles_cache,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -112,7 +112,7 @@ def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
@router.get('', response_model=ButtonStylesResponse)
async def get_button_styles(
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('settings:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return current per-section button styles. Admin only."""
@@ -145,7 +145,7 @@ async def get_button_styles(
@router.patch('', response_model=ButtonStylesResponse)
async def update_button_styles(
payload: ButtonStylesUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Partially update per-section button styles. Admin only."""
@@ -243,7 +243,7 @@ async def update_button_styles(
@router.post('/reset', response_model=ButtonStylesResponse)
async def reset_button_styles(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset all button styles to defaults. Admin only."""
+213 -97
View File
@@ -1,11 +1,13 @@
"""Admin routes for managing advertising campaigns in cabinet."""
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.cabinet.utils.links import get_campaign_deep_link, get_campaign_web_link
from app.database.crud.campaign import (
create_campaign,
delete_campaign,
@@ -20,14 +22,19 @@ from app.database.crud.campaign import (
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import get_all_tariffs
from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
PartnerStatus,
Subscription,
Tariff,
User,
)
from app.services.partner_stats_service import PartnerStatsService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.campaigns import (
AdminCampaignChartDataResponse,
AvailablePartnerItem,
CampaignCreateRequest,
CampaignDetailResponse,
CampaignListItem,
@@ -49,45 +56,59 @@ logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/campaigns', tags=['Cabinet Admin Campaigns'])
def _get_deep_link(start_parameter: str) -> str:
"""Generate deep link for campaign."""
bot_username = settings.get_bot_username()
if bot_username:
return f'https://t.me/{bot_username}?start={start_parameter}'
return f'?start={start_parameter}'
def _safe_div(value: float | None, divisor: int = 100) -> float:
"""Safely divide kopeks to rubles, handling None values."""
return (value or 0) / divisor
def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
"""Get partner display name from campaign."""
if not campaign.partner_user_id or not campaign.partner:
return None
partner = campaign.partner
return partner.first_name or partner.username or f'#{partner.id}'
@router.get('/overview', response_model=CampaignsOverviewResponse)
async def get_overview(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get campaigns overview statistics."""
overview = await get_campaigns_overview(db)
try:
overview = await get_campaigns_overview(db)
# Count tariff bonuses
tariff_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.bonus_type == 'tariff'
# Count tariff bonuses
tariff_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.bonus_type == 'tariff'
)
)
)
tariff_count = tariff_result.scalar() or 0
tariff_count = tariff_result.scalar() or 0
return CampaignsOverviewResponse(
total=overview['total'],
active=overview['active'],
inactive=overview['inactive'],
total_registrations=overview['registrations'],
total_balance_issued_kopeks=overview['balance_total'],
total_balance_issued_rubles=overview['balance_total'] / 100,
total_subscription_issued=overview['subscription_total'],
total_tariff_issued=tariff_count,
)
return CampaignsOverviewResponse(
total=overview['total'],
active=overview['active'],
inactive=overview['inactive'],
total_registrations=overview['registrations'],
total_balance_issued_kopeks=overview['balance_total'],
total_balance_issued_rubles=_safe_div(overview['balance_total']),
total_subscription_issued=overview['subscription_total'],
total_tariff_issued=tariff_count,
)
except HTTPException:
raise
except Exception as e:
logger.error('Failed to get campaigns overview', error=str(e), exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load campaigns overview',
)
@router.get('/available-servers', response_model=list[ServerSquadInfo])
async def get_available_servers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available server squads for campaign subscription bonus."""
@@ -105,7 +126,7 @@ async def get_available_servers(
@router.get('/available-tariffs', response_model=list[TariffListItem])
async def get_available_tariffs(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available tariffs for campaign tariff bonus."""
@@ -132,17 +153,37 @@ async def get_available_tariffs(
]
@router.get('/available-partners', response_model=list[AvailablePartnerItem])
async def get_available_partners(
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of approved partners for campaign partner selector."""
result = await db.execute(
select(User).where(User.partner_status == PartnerStatus.APPROVED.value).order_by(User.first_name, User.username)
)
partners = result.scalars().all()
return [
AvailablePartnerItem(
user_id=p.id,
username=p.username,
first_name=p.first_name,
)
for p in partners
]
@router.get('', response_model=CampaignListResponse)
async def list_campaigns(
include_inactive: bool = True,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all campaigns."""
campaigns = await get_campaigns_list(db, offset=offset, limit=limit, include_inactive=include_inactive)
total = await get_campaigns_count(db)
total = await get_campaigns_count(db, is_active=True if not include_inactive else None)
items = []
for campaign in campaigns:
@@ -158,6 +199,8 @@ async def list_campaigns(
registrations_count=stats['registrations'],
total_revenue_kopeks=stats['total_revenue_kopeks'],
conversion_rate=stats['conversion_rate'],
partner_user_id=campaign.partner_user_id,
partner_name=_get_partner_name(campaign),
created_at=campaign.created_at,
)
)
@@ -168,7 +211,7 @@ async def list_campaigns(
@router.get('/{campaign_id}', response_model=CampaignDetailResponse)
async def get_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed campaign info."""
@@ -193,7 +236,7 @@ async def get_campaign(
bonus_type=campaign.bonus_type,
is_active=campaign.is_active,
balance_bonus_kopeks=campaign.balance_bonus_kopeks or 0,
balance_bonus_rubles=(campaign.balance_bonus_kopeks or 0) / 100,
balance_bonus_rubles=_safe_div(campaign.balance_bonus_kopeks),
subscription_duration_days=campaign.subscription_duration_days,
subscription_traffic_gb=campaign.subscription_traffic_gb,
subscription_device_limit=campaign.subscription_device_limit,
@@ -201,54 +244,94 @@ async def get_campaign(
tariff_id=campaign.tariff_id,
tariff_duration_days=campaign.tariff_duration_days,
tariff=tariff_info,
partner_user_id=campaign.partner_user_id,
partner_name=_get_partner_name(campaign),
created_by=campaign.created_by,
created_at=campaign.created_at,
updated_at=campaign.updated_at,
deep_link=_get_deep_link(campaign.start_parameter),
deep_link=get_campaign_deep_link(campaign.start_parameter),
web_link=get_campaign_web_link(campaign.start_parameter),
)
@router.get('/{campaign_id}/chart-data', response_model=AdminCampaignChartDataResponse)
async def get_campaign_chart_data(
campaign_id: int,
admin: User = Depends(require_permission('campaigns:stats')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get chart data for admin campaign analytics."""
try:
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Campaign not found',
)
data = await PartnerStatsService.get_admin_campaign_chart_data(db, campaign_id)
return AdminCampaignChartDataResponse(**data)
except HTTPException:
raise
except Exception as e:
logger.error('Failed to get campaign chart data', error=str(e), campaign_id=campaign_id, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load campaign chart data',
)
@router.get('/{campaign_id}/stats', response_model=CampaignStatisticsResponse)
async def get_campaign_stats(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:stats')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed campaign statistics."""
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Campaign not found',
try:
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Campaign not found',
)
stats = await get_campaign_statistics(db, campaign_id)
return CampaignStatisticsResponse(
id=campaign.id,
name=campaign.name,
start_parameter=campaign.start_parameter,
bonus_type=campaign.bonus_type,
is_active=campaign.is_active,
registrations=stats['registrations'],
balance_issued_kopeks=stats['balance_issued'],
balance_issued_rubles=_safe_div(stats['balance_issued']),
subscription_issued=stats['subscription_issued'],
last_registration=stats['last_registration'],
total_revenue_kopeks=stats['total_revenue_kopeks'],
total_revenue_rubles=_safe_div(stats['total_revenue_kopeks']),
avg_revenue_per_user_kopeks=stats['avg_revenue_per_user_kopeks'],
avg_revenue_per_user_rubles=_safe_div(stats['avg_revenue_per_user_kopeks']),
avg_first_payment_kopeks=stats['avg_first_payment_kopeks'],
avg_first_payment_rubles=_safe_div(stats['avg_first_payment_kopeks']),
trial_users_count=stats['trial_users_count'],
active_trials_count=stats['active_trials_count'],
conversion_count=stats['conversion_count'],
paid_users_count=stats['paid_users_count'],
conversion_rate=stats['conversion_rate'],
trial_conversion_rate=stats['trial_conversion_rate'],
deep_link=get_campaign_deep_link(campaign.start_parameter),
web_link=get_campaign_web_link(campaign.start_parameter),
)
except HTTPException:
raise
except Exception as e:
logger.error('Failed to get campaign stats', error=str(e), campaign_id=campaign_id, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load campaign statistics',
)
stats = await get_campaign_statistics(db, campaign_id)
return CampaignStatisticsResponse(
id=campaign.id,
name=campaign.name,
start_parameter=campaign.start_parameter,
bonus_type=campaign.bonus_type,
is_active=campaign.is_active,
registrations=stats['registrations'],
balance_issued_kopeks=stats['balance_issued'],
balance_issued_rubles=stats['balance_issued'] / 100,
subscription_issued=stats['subscription_issued'],
last_registration=stats['last_registration'],
total_revenue_kopeks=stats['total_revenue_kopeks'],
total_revenue_rubles=stats['total_revenue_kopeks'] / 100,
avg_revenue_per_user_kopeks=stats['avg_revenue_per_user_kopeks'],
avg_revenue_per_user_rubles=stats['avg_revenue_per_user_kopeks'] / 100,
avg_first_payment_kopeks=stats['avg_first_payment_kopeks'],
avg_first_payment_rubles=stats['avg_first_payment_kopeks'] / 100,
trial_users_count=stats['trial_users_count'],
active_trials_count=stats['active_trials_count'],
conversion_count=stats['conversion_count'],
paid_users_count=stats['paid_users_count'],
conversion_rate=stats['conversion_rate'],
trial_conversion_rate=stats['trial_conversion_rate'],
deep_link=_get_deep_link(campaign.start_parameter),
)
@router.get('/{campaign_id}/registrations', response_model=CampaignRegistrationsResponse)
@@ -256,7 +339,7 @@ async def get_campaign_registrations(
campaign_id: int,
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of users registered through campaign."""
@@ -288,19 +371,22 @@ async def get_campaign_registrations(
)
total = count_result.scalar() or 0
items = []
for reg, user in rows:
# Check if user has subscription
# 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)
select(Subscription.user_id)
.where(
Subscription.user_id == user.id,
Subscription.user_id.in_(user_ids),
Subscription.status == 'active',
)
.limit(1)
.distinct()
)
has_sub = sub_result.scalar_one_or_none() is not None
active_sub_user_ids = set(sub_result.scalars().all())
items = []
for reg, user in rows:
items.append(
CampaignRegistrationItem(
id=reg.id,
@@ -315,7 +401,7 @@ async def get_campaign_registrations(
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_subscription=user.id in active_sub_user_ids,
has_paid=user.has_had_paid_subscription or False,
)
)
@@ -331,7 +417,7 @@ async def get_campaign_registrations(
@router.post('', response_model=CampaignDetailResponse)
async def create_new_campaign(
request: CampaignCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:create')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new advertising campaign."""
@@ -358,6 +444,15 @@ async def create_new_campaign(
detail='Tariff not found',
)
# Validate partner exists and is approved
if request.partner_user_id is not None:
partner_user = await db.get(User, request.partner_user_id)
if not partner_user or partner_user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Partner not found or not approved',
)
campaign = await create_campaign(
db,
name=request.name,
@@ -372,11 +467,9 @@ async def create_new_campaign(
tariff_id=request.tariff_id,
tariff_duration_days=request.tariff_duration_days,
is_active=request.is_active,
partner_user_id=request.partner_user_id,
)
# Reload to get tariff relationship
campaign = await get_campaign_by_id(db, campaign.id)
logger.info('Admin created campaign', admin_id=admin.id, campaign_id=campaign.id, campaign_name=campaign.name)
return await get_campaign(campaign.id, admin, db)
@@ -386,7 +479,7 @@ async def create_new_campaign(
async def update_existing_campaign(
campaign_id: int,
request: CampaignUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing campaign."""
@@ -418,33 +511,51 @@ async def update_existing_campaign(
detail='Tariff not found',
)
# Build updates
# Build updates using model_fields_set to distinguish "not sent" from "sent as None"
updates = {}
if request.name is not None:
if 'name' in request.model_fields_set:
updates['name'] = request.name
if request.start_parameter is not None:
if 'start_parameter' in request.model_fields_set:
updates['start_parameter'] = request.start_parameter
if request.bonus_type is not None:
if 'bonus_type' in request.model_fields_set:
updates['bonus_type'] = request.bonus_type
if request.is_active is not None:
if 'is_active' in request.model_fields_set:
updates['is_active'] = request.is_active
if request.balance_bonus_kopeks is not None:
if 'balance_bonus_kopeks' in request.model_fields_set:
updates['balance_bonus_kopeks'] = request.balance_bonus_kopeks
if request.subscription_duration_days is not None:
if 'subscription_duration_days' in request.model_fields_set:
updates['subscription_duration_days'] = request.subscription_duration_days
if request.subscription_traffic_gb is not None:
if 'subscription_traffic_gb' in request.model_fields_set:
updates['subscription_traffic_gb'] = request.subscription_traffic_gb
if request.subscription_device_limit is not None:
if 'subscription_device_limit' in request.model_fields_set:
updates['subscription_device_limit'] = request.subscription_device_limit
if request.subscription_squads is not None:
if 'subscription_squads' in request.model_fields_set:
updates['subscription_squads'] = request.subscription_squads
if request.tariff_id is not None:
if 'tariff_id' in request.model_fields_set:
updates['tariff_id'] = request.tariff_id
if request.tariff_duration_days is not None:
if 'tariff_duration_days' in request.model_fields_set:
updates['tariff_duration_days'] = request.tariff_duration_days
# Handle partner_user_id separately (allows explicit None to unassign)
partner_changed = False
if 'partner_user_id' in request.model_fields_set:
new_partner_id = request.partner_user_id
if new_partner_id is not None:
partner_user = await db.get(User, new_partner_id)
if not partner_user or partner_user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Partner not found or not approved',
)
campaign.partner_user_id = new_partner_id
campaign.updated_at = datetime.now(UTC)
partner_changed = True
if updates:
await update_campaign(db, campaign, **updates)
elif partner_changed:
await db.commit()
await db.refresh(campaign)
logger.info('Admin updated campaign', admin_id=admin.id, campaign_id=campaign_id)
@@ -454,7 +565,7 @@ async def update_existing_campaign(
@router.delete('/{campaign_id}')
async def delete_existing_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a campaign."""
@@ -465,8 +576,13 @@ async def delete_existing_campaign(
detail='Campaign not found',
)
# Check if campaign has registrations
reg_count = len(campaign.registrations) if campaign.registrations else 0
# Check if campaign has registrations (COUNT query instead of loading all)
reg_count_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.campaign_id == campaign_id
)
)
reg_count = reg_count_result.scalar() or 0
if reg_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -482,7 +598,7 @@ async def delete_existing_campaign(
@router.post('/{campaign_id}/toggle', response_model=CampaignToggleResponse)
async def toggle_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('campaigns:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle campaign active status."""
+98
View File
@@ -0,0 +1,98 @@
"""Admin API for managing required channels."""
import structlog
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.required_channel import (
add_channel,
delete_channel,
get_all_channels,
toggle_channel,
update_channel,
)
from app.database.models import User
from app.services.channel_subscription_service import channel_subscription_service
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.channel import (
ChannelCreateRequest,
ChannelListResponse,
ChannelResponse,
ChannelUpdateRequest,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/channel-subscriptions', tags=['Cabinet Admin Channels'])
@router.get('', response_model=ChannelListResponse)
async def list_channels(
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(require_permission('channels:read')),
) -> ChannelListResponse:
channels = await get_all_channels(db)
return ChannelListResponse(
items=[ChannelResponse.model_validate(ch) for ch in channels],
total=len(channels),
)
@router.post('', response_model=ChannelResponse, status_code=201)
async def create_channel(
data: ChannelCreateRequest,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(require_permission('channels:edit')),
) -> ChannelResponse:
ch = await add_channel(
db,
channel_id=data.channel_id,
channel_link=data.channel_link,
title=data.title,
disable_trial_on_leave=data.disable_trial_on_leave,
disable_paid_on_leave=data.disable_paid_on_leave,
)
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.patch('/{channel_db_id}', response_model=ChannelResponse)
async def update_channel_endpoint(
channel_db_id: int,
data: ChannelUpdateRequest,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(require_permission('channels:edit')),
) -> ChannelResponse:
update_data = data.model_dump(exclude_unset=True)
ch = await update_channel(db, channel_db_id, **update_data)
if not ch:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.post('/{channel_db_id}/toggle', response_model=ChannelResponse)
async def toggle_channel_endpoint(
channel_db_id: int,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(require_permission('channels:edit')),
) -> ChannelResponse:
ch = await toggle_channel(db, channel_db_id)
if not ch:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.delete('/{channel_db_id}', status_code=204)
async def delete_channel_endpoint(
channel_db_id: int,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(require_permission('channels:edit')),
) -> None:
ok = await delete_channel(db, channel_db_id)
if not ok:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
+105 -7
View File
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..services.email_template_overrides import (
delete_template_override,
get_all_overrides,
@@ -298,6 +298,77 @@ TEMPLATE_TYPES = [
},
'context_vars': ['username', 'reset_url', 'expire_hours'],
},
{
'type': 'guest_subscription_delivered',
'label': {
'ru': 'Быстрая покупка: подписка доставлена',
'en': 'Quick Purchase: Subscription Delivered',
'zh': '快捷购买:订阅已交付',
'ua': 'Швидка покупка: підписка доставлена',
},
'description': {
'ru': 'Письмо покупателю после успешной оплаты через лендинг',
'en': 'Email to buyer after successful landing page payment',
'zh': '通过落地页成功付款后发送给买家的邮件',
'ua': 'Лист покупцю після успішної оплати через лендінг',
},
'context_vars': ['tariff_name', 'period_days', 'cabinet_url'],
},
{
'type': 'guest_activation_required',
'label': {
'ru': 'Быстрая покупка: требуется активация',
'en': 'Quick Purchase: Activation Required',
'zh': '快捷购买:需要激活',
'ua': 'Швидка покупка: потрібна активація',
},
'description': {
'ru': 'Письмо когда у покупателя уже есть активная подписка',
'en': 'Email when buyer already has an active subscription',
'zh': '买家已有活跃订阅时发送的邮件',
'ua': 'Лист коли у покупця вже є активна підписка',
},
'context_vars': ['tariff_name', 'period_days', 'success_page_url', 'gift_message'],
},
{
'type': 'guest_gift_received',
'label': {
'ru': 'Быстрая покупка: подарок получен',
'en': 'Quick Purchase: Gift Received',
'zh': '快捷购买:收到礼物',
'ua': 'Швидка покупка: подарунок отримано',
},
'description': {
'ru': 'Письмо получателю подарочной подписки',
'en': 'Email to gift subscription recipient',
'zh': '发送给礼物订阅接收者的邮件',
'ua': 'Лист отримувачу подарункової підписки',
},
'context_vars': [
'tariff_name',
'period_days',
'cabinet_url',
'gift_message',
'cabinet_email',
'cabinet_password',
],
},
{
'type': 'guest_cabinet_credentials',
'label': {
'ru': 'Быстрая покупка: данные для входа',
'en': 'Quick Purchase: Login Credentials',
'zh': '快捷购买:登录凭据',
'ua': 'Швидка покупка: дані для входу',
},
'description': {
'ru': 'Письмо с логином и паролем для личного кабинета',
'en': 'Email with login credentials for the cabinet',
'zh': '包含个人中心登录信息的邮件',
'ua': 'Лист з логіном та паролем для особистого кабінету',
},
'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'],
},
]
SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
@@ -335,6 +406,33 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
'expire_hours': 24,
},
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
'guest_subscription_delivered': {
'tariff_name': 'Premium',
'period_days': 30,
'cabinet_url': 'https://example.com/cabinet',
},
'guest_activation_required': {
'tariff_name': 'Premium',
'period_days': 30,
'success_page_url': 'https://example.com/cabinet/buy/success/abc123',
'is_gift': True,
'gift_message': 'Happy birthday!',
},
'guest_gift_received': {
'tariff_name': 'Premium',
'period_days': 30,
'cabinet_url': 'https://example.com/cabinet',
'gift_message': 'Happy birthday!',
'cabinet_email': 'recipient@example.com',
'cabinet_password': 'SecurePass123',
},
'guest_cabinet_credentials': {
'tariff_name': 'Premium',
'period_days': 30,
'cabinet_url': 'https://example.com/cabinet',
'cabinet_email': 'user@example.com',
'cabinet_password': 'SecurePass123',
},
}
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
@@ -370,7 +468,7 @@ class EmailTemplateSendTestRequest(BaseModel):
@router.get('', summary='List all email template types')
async def list_template_types(
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('email_templates:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""List all available email template types with override status."""
@@ -405,7 +503,7 @@ async def list_template_types(
@router.get('/{notification_type}', summary='Get templates for a notification type')
async def get_templates_for_type(
notification_type: str,
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('email_templates:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Get all language templates for a specific notification type."""
@@ -479,7 +577,7 @@ async def update_template(
notification_type: str,
language: str,
data: EmailTemplateUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('email_templates:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Save a custom email template override."""
@@ -515,7 +613,7 @@ async def update_template(
async def reset_template(
notification_type: str,
language: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('email_templates:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Delete custom template override, reverting to default."""
@@ -543,7 +641,7 @@ async def reset_template(
async def preview_template(
notification_type: str,
data: EmailTemplatePreviewRequest,
_admin: User = Depends(get_current_admin_user),
_admin: User = Depends(require_permission('email_templates:read')),
) -> dict[str, Any]:
"""Preview a rendered email template with sample data."""
valid_types = [t['type'] for t in TEMPLATE_TYPES]
@@ -588,7 +686,7 @@ async def preview_template(
async def send_test_email(
notification_type: str,
data: EmailTemplateSendTestRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('email_templates:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Send a test email to the admin's email address."""
+736
View File
@@ -0,0 +1,736 @@
"""Admin routes for landing page management in cabinet."""
from datetime import UTC, datetime
from urllib.parse import urlparse
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.cabinet.utils.locale import (
ensure_locale_dict,
validate_locale_dict,
)
from app.database.crud.landing import (
create_landing,
delete_landing,
get_all_landing_purchase_stats,
get_all_landings,
get_landing_by_id,
get_landing_by_slug,
update_landing,
update_landing_order,
)
from app.database.models import LandingPage, User
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/landings', tags=['Cabinet Admin Landings'])
# Slugs that conflict with public landing router path segments
_RESERVED_SLUGS = frozenset(
{
'purchase',
'admin',
'api',
'health',
'static',
'assets',
'favicon',
'robots',
'sitemap',
'well-known',
}
)
# ============ Schemas ============
class LandingFeatureInput(BaseModel):
icon: str = Field(default='', max_length=100)
title: dict[str, str] = Field(default_factory=dict)
description: dict[str, str] = Field(default_factory=dict)
@field_validator('title', 'description', mode='before')
@classmethod
def coerce_to_dict(cls, v: dict[str, str] | str | None) -> dict[str, str]:
return ensure_locale_dict(v)
@field_validator('title')
@classmethod
def validate_title_length(cls, v: dict[str, str]) -> dict[str, str]:
return validate_locale_dict(v, max_length=200, field_name='feature.title')
@field_validator('description')
@classmethod
def validate_description_length(cls, v: dict[str, str]) -> dict[str, str]:
return validate_locale_dict(v, max_length=500, field_name='feature.description')
class LandingPaymentMethodInput(BaseModel):
method_id: str = Field(max_length=50)
display_name: str = Field(max_length=200)
description: str | None = Field(default=None, max_length=500)
icon_url: str | None = Field(default=None, max_length=500)
sort_order: int = 0
min_amount_kopeks: int | None = None
max_amount_kopeks: int | None = None
currency: str | None = Field(default=None, max_length=10)
return_url: str | None = Field(default=None, max_length=500)
sub_options: dict[str, bool] | None = None
@field_validator('sub_options', mode='before')
@classmethod
def validate_sub_options(cls, v: dict[str, bool] | None) -> dict[str, bool] | None:
if not v:
return None
if len(v) > 20:
raise ValueError('sub_options cannot have more than 20 keys')
for key in v:
if not isinstance(key, str) or len(key) > 50:
raise ValueError('sub_options keys must be strings of at most 50 characters')
return v
@field_validator('icon_url', mode='before')
@classmethod
def validate_icon_url(cls, v: str | None) -> str | None:
if not v:
return None
if not v.startswith(('https://', '/')):
raise ValueError('icon_url must use HTTPS or be a relative path')
return v
@field_validator('return_url', mode='before')
@classmethod
def validate_return_url(cls, v: str | None) -> str | None:
if not v:
return None
if not v.startswith('https://'):
raise ValueError('return_url must use HTTPS')
parsed = urlparse(v)
if not parsed.hostname or parsed.username or parsed.password:
raise ValueError('return_url must be a valid HTTPS URL without credentials')
return v
@field_validator('currency', mode='before')
@classmethod
def validate_currency(cls, v: str | None) -> str | None:
if not v:
return None
return v.strip().upper()
@field_validator('min_amount_kopeks', 'max_amount_kopeks')
@classmethod
def validate_amounts(cls, v: int | None) -> int | None:
if v is not None and v < 0:
raise ValueError('Amount cannot be negative')
return v
@model_validator(mode='after')
def validate_amount_range(self) -> 'LandingPaymentMethodInput':
if (
self.min_amount_kopeks is not None
and self.max_amount_kopeks is not None
and self.min_amount_kopeks > self.max_amount_kopeks
):
raise ValueError('min_amount_kopeks cannot be greater than max_amount_kopeks')
return self
class LandingCreateRequest(BaseModel):
slug: str = Field(pattern=r'^[a-z0-9\-]+$', min_length=1, max_length=100)
title: dict[str, str] = Field(default_factory=lambda: {'ru': ''})
subtitle: dict[str, str] | None = None
is_active: bool = True
features: list[LandingFeatureInput] = Field(default_factory=list, max_length=20)
footer_text: dict[str, str] | None = None
allowed_tariff_ids: list[int] = Field(default_factory=list, max_length=50)
allowed_periods: dict[str, list[int]] = Field(default_factory=dict)
payment_methods: list[LandingPaymentMethodInput] = Field(default_factory=list, max_length=10)
@field_validator('allowed_periods')
@classmethod
def validate_allowed_periods_size(cls, v: dict[str, list[int]]) -> dict[str, list[int]]:
if len(v) > 50:
raise ValueError('allowed_periods cannot have more than 50 entries')
for key, periods in v.items():
if len(periods) > 20:
raise ValueError(f'allowed_periods[{key}] cannot have more than 20 periods')
return v
gift_enabled: bool = True
custom_css: str | None = Field(default=None, max_length=10000)
meta_title: dict[str, str] | None = None
meta_description: dict[str, str] | None = None
discount_percent: int | None = Field(default=None, ge=1, le=99)
discount_overrides: dict[str, int] | None = None # {"tariff_id": percent}
discount_starts_at: datetime | None = None
discount_ends_at: datetime | None = None
discount_badge_text: dict[str, str] | None = None
@field_validator(
'title', 'subtitle', 'footer_text', 'meta_title', 'meta_description', 'discount_badge_text', mode='before'
)
@classmethod
def coerce_text_to_dict(cls, v: dict[str, str] | str | None) -> dict[str, str] | None:
if v is None:
return None
return ensure_locale_dict(v)
@field_validator('title')
@classmethod
def validate_title(cls, v: dict[str, str]) -> dict[str, str]:
return validate_locale_dict(v, max_length=500, field_name='title')
@field_validator('subtitle')
@classmethod
def validate_subtitle(cls, v: dict[str, str] | None) -> dict[str, str] | None:
if v is None:
return None
return validate_locale_dict(v, max_length=1000, field_name='subtitle')
@field_validator('footer_text')
@classmethod
def validate_footer_text(cls, v: dict[str, str] | None) -> dict[str, str] | None:
if v is None:
return None
return validate_locale_dict(v, max_length=5000, field_name='footer_text')
@field_validator('meta_title')
@classmethod
def validate_meta_title(cls, v: dict[str, str] | None) -> dict[str, str] | None:
if v is None:
return None
return validate_locale_dict(v, max_length=200, field_name='meta_title')
@field_validator('meta_description')
@classmethod
def validate_meta_description(cls, v: dict[str, str] | None) -> dict[str, str] | None:
if v is None:
return None
return validate_locale_dict(v, max_length=500, field_name='meta_description')
@field_validator('discount_badge_text')
@classmethod
def validate_discount_badge_text(cls, v: dict[str, str] | None) -> dict[str, str] | None:
if v is None:
return None
return validate_locale_dict(v, max_length=200, field_name='discount_badge_text')
@field_validator('discount_starts_at', 'discount_ends_at', mode='after')
@classmethod
def ensure_aware_datetime(cls, v: datetime | None) -> datetime | None:
if v is not None and v.tzinfo is None:
v = v.replace(tzinfo=UTC)
return v
@model_validator(mode='after')
def validate_discount(self) -> 'LandingCreateRequest':
has_discount = self.discount_percent is not None
has_dates = self.discount_starts_at is not None or self.discount_ends_at is not None
if has_dates and not has_discount:
raise ValueError('discount_percent is required when discount dates are set')
if has_discount and not (self.discount_starts_at and self.discount_ends_at):
raise ValueError('discount_starts_at and discount_ends_at are required when discount_percent is set')
if self.discount_starts_at and self.discount_ends_at:
if self.discount_starts_at >= self.discount_ends_at:
raise ValueError('discount_starts_at must be before discount_ends_at')
if self.discount_overrides:
if len(self.discount_overrides) > 100:
raise ValueError('discount_overrides cannot have more than 100 entries')
for key, val in self.discount_overrides.items():
if not key.isdigit():
raise ValueError('discount_overrides keys must be tariff ID strings')
if not (1 <= val <= 99):
raise ValueError('discount_overrides values must be 1-99')
if self.allowed_tariff_ids:
allowed_set = {str(tid) for tid in self.allowed_tariff_ids}
invalid = set(self.discount_overrides.keys()) - allowed_set
if invalid:
raise ValueError(f'discount_overrides contains tariff IDs not in allowed_tariff_ids: {invalid}')
return self
class LandingUpdateRequest(BaseModel):
slug: str | None = Field(default=None, pattern=r'^[a-z0-9\-]+$', min_length=1, max_length=100)
title: dict[str, str] | None = None
subtitle: dict[str, str] | None = None
is_active: bool | None = None
features: list[LandingFeatureInput] | None = Field(default=None, max_length=20)
footer_text: dict[str, str] | None = None
allowed_tariff_ids: list[int] | None = Field(default=None, max_length=50)
allowed_periods: dict[str, list[int]] | None = None
payment_methods: list[LandingPaymentMethodInput] | None = Field(default=None, max_length=10)
gift_enabled: bool | None = None
custom_css: str | None = Field(default=None, max_length=10000)
meta_title: dict[str, str] | None = None
meta_description: dict[str, str] | None = None
discount_percent: int | None = Field(default=None, ge=1, le=99)
discount_overrides: dict[str, int] | None = None
discount_starts_at: datetime | None = None
discount_ends_at: datetime | None = None
discount_badge_text: dict[str, str] | None = None
@field_validator('allowed_periods')
@classmethod
def validate_allowed_periods_size(cls, v: dict[str, list[int]] | None) -> dict[str, list[int]] | None:
if v is None:
return None
if len(v) > 50:
raise ValueError('allowed_periods cannot have more than 50 entries')
for key, periods in v.items():
if len(periods) > 20:
raise ValueError(f'allowed_periods[{key}] cannot have more than 20 periods')
return v
@field_validator(
'title', 'subtitle', 'footer_text', 'meta_title', 'meta_description', 'discount_badge_text', mode='before'
)
@classmethod
def coerce_text_to_dict(cls, v: dict[str, str] | str | None) -> dict[str, str] | None:
if v is None:
return None
return ensure_locale_dict(v)
@field_validator('title')
@classmethod
def validate_title(cls, v: dict[str, str] | None) -> dict[str, str] | None:
if v is None:
return None
return validate_locale_dict(v, max_length=500, field_name='title')
@field_validator('subtitle')
@classmethod
def validate_subtitle(cls, v: dict[str, str] | None) -> dict[str, str] | None:
if v is None:
return None
return validate_locale_dict(v, max_length=1000, field_name='subtitle')
@field_validator('footer_text')
@classmethod
def validate_footer_text(cls, v: dict[str, str] | None) -> dict[str, str] | None:
if v is None:
return None
return validate_locale_dict(v, max_length=5000, field_name='footer_text')
@field_validator('meta_title')
@classmethod
def validate_meta_title(cls, v: dict[str, str] | None) -> dict[str, str] | None:
if v is None:
return None
return validate_locale_dict(v, max_length=200, field_name='meta_title')
@field_validator('meta_description')
@classmethod
def validate_meta_description(cls, v: dict[str, str] | None) -> dict[str, str] | None:
if v is None:
return None
return validate_locale_dict(v, max_length=500, field_name='meta_description')
@field_validator('discount_badge_text')
@classmethod
def validate_discount_badge_text(cls, v: dict[str, str] | None) -> dict[str, str] | None:
if v is None:
return None
return validate_locale_dict(v, max_length=200, field_name='discount_badge_text')
@field_validator('discount_starts_at', 'discount_ends_at', mode='after')
@classmethod
def ensure_aware_datetime(cls, v: datetime | None) -> datetime | None:
if v is not None and v.tzinfo is None:
v = v.replace(tzinfo=UTC)
return v
@model_validator(mode='after')
def validate_discount(self) -> 'LandingUpdateRequest':
if self.discount_starts_at is not None and self.discount_ends_at is not None:
if self.discount_starts_at >= self.discount_ends_at:
raise ValueError('discount_starts_at must be before discount_ends_at')
if self.discount_overrides:
if len(self.discount_overrides) > 100:
raise ValueError('discount_overrides cannot have more than 100 entries')
for key, val in self.discount_overrides.items():
if not key.isdigit():
raise ValueError('discount_overrides keys must be tariff ID strings')
if not (1 <= val <= 99):
raise ValueError('discount_overrides values must be 1-99')
return self
class PurchaseStats(BaseModel):
total: int = 0
pending: int = 0
paid: int = 0
delivered: int = 0
pending_activation: int = 0
failed: int = 0
expired: int = 0
class LandingListItem(BaseModel):
id: int
slug: str
title: dict[str, str]
is_active: bool
display_order: int
gift_enabled: bool
tariff_count: int
method_count: int
purchase_stats: PurchaseStats
has_active_discount: bool = False
created_at: datetime | None = None
updated_at: datetime | None = None
@field_validator('title', mode='before')
@classmethod
def coerce_title(cls, v: dict[str, str] | str | None) -> dict[str, str]:
return ensure_locale_dict(v)
class Config:
from_attributes = True
class LandingDetailResponse(BaseModel):
id: int
slug: str
title: dict[str, str]
subtitle: dict[str, str] | None = None
is_active: bool
display_order: int
features: list[LandingFeatureInput]
footer_text: dict[str, str] | None = None
allowed_tariff_ids: list[int]
allowed_periods: dict[str, list[int]]
payment_methods: list[LandingPaymentMethodInput]
gift_enabled: bool
custom_css: str | None = None
meta_title: dict[str, str] | None = None
meta_description: dict[str, str] | None = None
discount_percent: int | None = None
discount_overrides: dict[str, int] | None = None
discount_starts_at: datetime | None = None
discount_ends_at: datetime | None = None
discount_badge_text: dict[str, str] | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
@field_validator(
'title', 'subtitle', 'footer_text', 'meta_title', 'meta_description', 'discount_badge_text', mode='before'
)
@classmethod
def coerce_to_dict(cls, v: dict[str, str] | str | None) -> dict[str, str] | None:
if v is None:
return None
return ensure_locale_dict(v)
class Config:
from_attributes = True
class OrderRequest(BaseModel):
landing_ids: list[int]
# ============ Routes ============
# IMPORTANT: /order MUST come before /{landing_id} to avoid "order" being
# parsed as a landing_id path parameter.
@router.get('', response_model=list[LandingListItem])
async def list_landings(
admin: User = Depends(require_permission('landings:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all landing pages with purchase stats."""
landings = await get_all_landings(db)
all_stats = await get_all_landing_purchase_stats(db)
empty_stats = {
'total': 0,
'pending': 0,
'paid': 0,
'delivered': 0,
'pending_activation': 0,
'failed': 0,
'expired': 0,
}
now = datetime.now(UTC)
items = []
for landing in landings:
stats = all_stats.get(landing.id, empty_stats)
discount_active = bool(
landing.discount_percent
and landing.discount_starts_at
and landing.discount_ends_at
and landing.discount_starts_at <= now < landing.discount_ends_at
)
items.append(
LandingListItem(
id=landing.id,
slug=landing.slug,
title=landing.title,
is_active=landing.is_active,
display_order=landing.display_order,
gift_enabled=landing.gift_enabled,
tariff_count=len(landing.allowed_tariff_ids or []),
method_count=len(landing.payment_methods or []),
purchase_stats=PurchaseStats(
total=stats.get('total', 0),
pending=stats.get('pending', 0),
paid=stats.get('paid', 0),
delivered=stats.get('delivered', 0),
pending_activation=stats.get('pending_activation', 0),
failed=stats.get('failed', 0),
expired=stats.get('expired', 0),
),
has_active_discount=discount_active,
created_at=landing.created_at,
updated_at=landing.updated_at,
)
)
return items
@router.post('', response_model=LandingDetailResponse, status_code=status.HTTP_201_CREATED)
async def create_landing_page(
request: LandingCreateRequest,
admin: User = Depends(require_permission('landings:create')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new landing page."""
if request.slug in _RESERVED_SLUGS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Slug "{request.slug}" is reserved and cannot be used',
)
existing = await get_landing_by_slug(db, request.slug)
if existing is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f'Landing page with slug "{request.slug}" already exists',
)
landing = await create_landing(
db,
slug=request.slug,
title=request.title,
subtitle=request.subtitle,
is_active=request.is_active,
features=[f.model_dump() for f in request.features],
footer_text=request.footer_text,
allowed_tariff_ids=request.allowed_tariff_ids,
allowed_periods=request.allowed_periods,
payment_methods=[m.model_dump() for m in request.payment_methods],
gift_enabled=request.gift_enabled,
custom_css=request.custom_css,
meta_title=request.meta_title,
meta_description=request.meta_description,
discount_percent=request.discount_percent,
discount_overrides=request.discount_overrides,
discount_starts_at=request.discount_starts_at,
discount_ends_at=request.discount_ends_at,
discount_badge_text=request.discount_badge_text,
)
logger.info('Admin created landing page', admin_id=admin.id, slug=landing.slug, landing_id=landing.id)
return _landing_to_detail(landing)
@router.put('/order')
async def update_landings_order(
request: OrderRequest,
admin: User = Depends(require_permission('landings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Batch update display order for landing pages."""
await update_landing_order(db, request.landing_ids)
logger.info('Admin updated landing page order', admin_id=admin.id, landing_ids=request.landing_ids)
return {'success': True}
@router.get('/{landing_id}', response_model=LandingDetailResponse)
async def get_landing_detail(
landing_id: int,
admin: User = Depends(require_permission('landings:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get a single landing page with full details."""
landing = await get_landing_by_id(db, landing_id)
if landing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Landing page not found',
)
return _landing_to_detail(landing)
@router.put('/{landing_id}', response_model=LandingDetailResponse)
async def update_landing_page(
landing_id: int,
request: LandingUpdateRequest,
admin: User = Depends(require_permission('landings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update a landing page."""
data = request.model_dump(exclude_unset=True)
# If slug is being changed, check reserved and uniqueness
if 'slug' in data:
if data['slug'] in _RESERVED_SLUGS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Slug "{data["slug"]}" is reserved and cannot be used',
)
existing = await get_landing_by_slug(db, data['slug'])
if existing is not None and existing.id != landing_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f'Landing page with slug "{data["slug"]}" already exists',
)
# Serialize nested Pydantic models to dicts for JSON storage
if 'features' in data and data['features'] is not None:
data['features'] = [f.model_dump() if hasattr(f, 'model_dump') else f for f in data['features']]
if 'payment_methods' in data and data['payment_methods'] is not None:
data['payment_methods'] = [m.model_dump() if hasattr(m, 'model_dump') else m for m in data['payment_methods']]
# Cascade-clear all discount fields when discount_percent is explicitly set to None
if 'discount_percent' in data and data['discount_percent'] is None:
data['discount_overrides'] = None
data['discount_starts_at'] = None
data['discount_ends_at'] = None
data['discount_badge_text'] = None
# Validate merged discount dates on partial update
if 'discount_starts_at' in data or 'discount_ends_at' in data:
existing_landing = await get_landing_by_id(db, landing_id)
if existing_landing is not None:
effective_starts = data.get('discount_starts_at', existing_landing.discount_starts_at)
effective_ends = data.get('discount_ends_at', existing_landing.discount_ends_at)
if effective_starts and effective_ends and effective_starts >= effective_ends:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='discount_starts_at must be before discount_ends_at',
)
landing = await update_landing(db, landing_id, data)
if landing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Landing page not found',
)
logger.info('Admin updated landing page', admin_id=admin.id, slug=landing.slug, landing_id=landing.id)
return _landing_to_detail(landing)
@router.delete('/{landing_id}')
async def delete_landing_page(
landing_id: int,
admin: User = Depends(require_permission('landings:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a landing page."""
deleted = await delete_landing(db, landing_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Landing page not found',
)
logger.info('Admin deleted landing page', admin_id=admin.id, landing_id=landing_id)
return {'success': True}
@router.post('/{landing_id}/toggle', response_model=LandingDetailResponse)
async def toggle_landing_active(
landing_id: int,
admin: User = Depends(require_permission('landings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle active/inactive state of a landing page."""
landing = await get_landing_by_id(db, landing_id)
if landing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Landing page not found',
)
new_active = not landing.is_active
landing = await update_landing(db, landing_id, {'is_active': new_active})
logger.info(
'Admin toggled landing page',
admin_id=admin.id,
landing_id=landing_id,
is_active=new_active,
)
return _landing_to_detail(landing)
# ============ Helpers ============
def _landing_to_detail(landing: LandingPage) -> LandingDetailResponse:
"""Convert a LandingPage model to LandingDetailResponse.
Admin detail view returns full locale dicts for all text fields.
"""
features = [
LandingFeatureInput(
icon=f.get('icon', ''),
title=f.get('title', {}),
description=f.get('description', {}),
)
for f in (landing.features or [])
]
payment_methods = [
LandingPaymentMethodInput(
method_id=m.get('method_id', ''),
display_name=m.get('display_name', ''),
description=m.get('description'),
icon_url=m.get('icon_url'),
sort_order=m.get('sort_order', 0),
min_amount_kopeks=m.get('min_amount_kopeks'),
max_amount_kopeks=m.get('max_amount_kopeks'),
currency=m.get('currency'),
return_url=m.get('return_url'),
sub_options=m.get('sub_options'),
)
for m in (landing.payment_methods or [])
]
return LandingDetailResponse(
id=landing.id,
slug=landing.slug,
title=landing.title or {},
subtitle=landing.subtitle,
is_active=landing.is_active,
display_order=landing.display_order,
features=features,
footer_text=landing.footer_text,
allowed_tariff_ids=landing.allowed_tariff_ids or [],
allowed_periods=landing.allowed_periods or {},
payment_methods=payment_methods,
gift_enabled=landing.gift_enabled,
custom_css=landing.custom_css,
meta_title=landing.meta_title,
meta_description=landing.meta_description,
discount_percent=landing.discount_percent,
discount_overrides=landing.discount_overrides,
discount_starts_at=landing.discount_starts_at,
discount_ends_at=landing.discount_ends_at,
discount_badge_text=landing.discount_badge_text,
created_at=landing.created_at,
updated_at=landing.updated_at,
)
+607
View File
@@ -0,0 +1,607 @@
"""Admin routes for managing partners in cabinet."""
from datetime import UTC, datetime
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import desc, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import (
AdvertisingCampaign,
PartnerApplication,
PartnerStatus,
ReferralEarning,
User,
)
from app.services.partner_application_service import partner_application_service
from app.services.partner_stats_service import PartnerStatsService
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.partners import (
AdminApproveRequest,
AdminPartnerApplicationItem,
AdminPartnerApplicationsResponse,
AdminPartnerDetailResponse,
AdminPartnerItem,
AdminPartnerListResponse,
AdminRejectRequest,
AdminUpdateCommissionRequest,
CampaignSummary,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/partners', tags=['Cabinet Admin Partners'])
# ==================== Settings ====================
class PartnerSettingsResponse(BaseModel):
withdrawal_enabled: bool
withdrawal_min_amount_kopeks: int
withdrawal_cooldown_days: int
withdrawal_requisites_text: str
partner_section_visible: bool
referral_program_enabled: bool
class PartnerSettingsUpdateRequest(BaseModel):
withdrawal_enabled: bool | None = None
withdrawal_min_amount_kopeks: int | None = Field(None, ge=0, le=100_000_000)
withdrawal_cooldown_days: int | None = Field(None, ge=0, le=365)
withdrawal_requisites_text: str | None = Field(None, max_length=2000)
partner_section_visible: bool | None = None
referral_program_enabled: bool | None = None
def _build_partner_settings_response() -> PartnerSettingsResponse:
return PartnerSettingsResponse(
withdrawal_enabled=settings.REFERRAL_WITHDRAWAL_ENABLED,
withdrawal_min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
withdrawal_cooldown_days=settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS,
withdrawal_requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
referral_program_enabled=settings.REFERRAL_PROGRAM_ENABLED,
)
@router.get('/settings', response_model=PartnerSettingsResponse)
async def get_partner_settings(
admin: User = Depends(require_permission('partners:settings')),
):
"""Get partner system settings."""
return _build_partner_settings_response()
@router.patch('/settings', response_model=PartnerSettingsResponse)
async def update_partner_settings(
request: PartnerSettingsUpdateRequest,
admin: User = Depends(require_permission('partners:settings')),
):
"""Update partner system settings."""
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(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List partner applications."""
applications, total = await partner_application_service.get_all_applications(
db, status=application_status, limit=limit, offset=offset
)
# Batch-fetch users to avoid N+1
user_ids = list({app.user_id for app in applications})
if user_ids:
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
else:
users_map = {}
items = []
for app in applications:
user = users_map.get(app.user_id)
items.append(
AdminPartnerApplicationItem(
id=app.id,
user_id=app.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
company_name=app.company_name,
website_url=app.website_url,
telegram_channel=app.telegram_channel,
description=app.description,
expected_monthly_referrals=app.expected_monthly_referrals,
desired_commission_percent=app.desired_commission_percent,
status=app.status,
admin_comment=app.admin_comment,
approved_commission_percent=app.approved_commission_percent,
created_at=app.created_at,
processed_at=app.processed_at,
)
)
return AdminPartnerApplicationsResponse(items=items, total=total)
@router.post('/applications/{application_id}/approve')
async def approve_application(
application_id: int,
request: AdminApproveRequest,
admin: User = Depends(require_permission('partners:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a partner application."""
success, error = await partner_application_service.approve_application(
db,
application_id=application_id,
admin_id=admin.id,
commission_percent=request.commission_percent,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about approval
try:
from 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(require_permission('partners:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a partner application."""
success, error = await partner_application_service.reject_application(
db,
application_id=application_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about rejection
try:
from 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(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get overall partner statistics."""
total_partners = await db.execute(
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
)
pending_apps = await db.execute(
select(func.count())
.select_from(PartnerApplication)
.where(PartnerApplication.status == PartnerStatus.PENDING.value)
)
total_referrals = await db.execute(select(func.count()).select_from(User).where(User.referred_by_id.isnot(None)))
total_earnings = await db.execute(select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
return {
'total_partners': total_partners.scalar() or 0,
'pending_applications': pending_apps.scalar() or 0,
'total_referrals': total_referrals.scalar() or 0,
'total_earnings_kopeks': total_earnings.scalar() or 0,
}
# ==================== Partners list ====================
@router.get('', response_model=AdminPartnerListResponse)
async def list_partners(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List approved partners."""
count_result = await db.execute(
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
)
total = count_result.scalar() or 0
result = await db.execute(
select(User)
.where(User.partner_status == PartnerStatus.APPROVED.value)
.order_by(desc(User.created_at))
.offset(offset)
.limit(limit)
)
partners = result.scalars().all()
# Batch-fetch earnings and referral counts to avoid N+1
partner_ids = [u.id for u in partners]
earnings_map: dict[int, int] = {}
referral_count_map: dict[int, int] = {}
if partner_ids:
earnings_result = await db.execute(
select(ReferralEarning.user_id, func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.user_id.in_(partner_ids))
.group_by(ReferralEarning.user_id)
)
earnings_map = {row[0]: int(row[1]) for row in earnings_result.all()}
referral_result = await db.execute(
select(User.referred_by_id, func.count())
.where(User.referred_by_id.in_(partner_ids))
.group_by(User.referred_by_id)
)
referral_count_map = {row[0]: row[1] for row in referral_result.all()}
items = []
for user in partners:
items.append(
AdminPartnerItem(
user_id=user.id,
username=user.username,
first_name=user.first_name,
telegram_id=user.telegram_id,
commission_percent=user.referral_commission_percent,
total_referrals=referral_count_map.get(user.id, 0),
total_earnings_kopeks=earnings_map.get(user.id, 0),
balance_kopeks=user.balance_kopeks,
partner_status=user.partner_status,
created_at=user.created_at,
)
)
return AdminPartnerListResponse(items=items, total=total)
# ==================== Partner detail (parametric paths last) ====================
@router.get('/{user_id}', response_model=AdminPartnerDetailResponse)
async def get_partner_detail(
user_id: int,
admin: User = Depends(require_permission('partners:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed partner info."""
user = await db.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Пользователь не найден',
)
stats = await PartnerStatsService.get_referrer_detailed_stats(db, user_id)
# Get assigned campaigns with per-campaign stats
campaigns_result = await db.execute(
select(AdvertisingCampaign).where(AdvertisingCampaign.partner_user_id == user_id)
)
campaigns = campaigns_result.scalars().all()
campaign_ids = [c.id for c in campaigns]
per_campaign_stats = await PartnerStatsService.get_per_campaign_stats(db, user_id, campaign_ids)
campaign_list = [
CampaignSummary(
id=c.id,
name=c.name,
start_parameter=c.start_parameter,
is_active=c.is_active,
registrations_count=per_campaign_stats.get(c.id, {}).get('registrations_count', 0),
referrals_count=per_campaign_stats.get(c.id, {}).get('referrals_count', 0),
earnings_kopeks=per_campaign_stats.get(c.id, {}).get('earnings_kopeks', 0),
)
for c in campaigns
]
summary = stats['summary']
earnings = stats['earnings']
return AdminPartnerDetailResponse(
user_id=user.id,
username=user.username,
first_name=user.first_name,
telegram_id=user.telegram_id,
commission_percent=user.referral_commission_percent,
partner_status=user.partner_status,
balance_kopeks=user.balance_kopeks,
total_referrals=summary['total_referrals'],
paid_referrals=summary['paid_referrals'],
active_referrals=summary['active_referrals'],
earnings_all_time=earnings['all_time_kopeks'],
earnings_today=earnings['today_kopeks'],
earnings_week=earnings['week_kopeks'],
earnings_month=earnings['month_kopeks'],
conversion_to_paid=summary['conversion_to_paid_percent'],
campaigns=campaign_list,
created_at=user.created_at,
)
@router.patch('/{user_id}/commission')
async def update_commission(
user_id: int,
request: AdminUpdateCommissionRequest,
admin: User = Depends(require_permission('partners:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update partner commission percent."""
user = await db.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Пользователь не найден',
)
if user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Пользователь не является партнёром',
)
old_commission = user.referral_commission_percent
user.referral_commission_percent = request.commission_percent
await db.commit()
logger.info(
'Комиссия партнёра обновлена',
user_id=user_id,
old_commission=old_commission,
new_commission=request.commission_percent,
admin_id=admin.id,
)
return {'success': True, 'commission_percent': request.commission_percent}
@router.post('/{user_id}/revoke')
async def revoke_partner(
user_id: int,
admin: User = Depends(require_permission('partners:revoke')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Revoke partner status."""
success, error = await partner_application_service.revoke_partner(db, user_id=user_id, admin_id=admin.id)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
return {'success': True}
@router.post('/{user_id}/campaigns/{campaign_id}/assign')
async def assign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(require_permission('partners:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Assign a campaign to a partner."""
campaign = await db.get(AdvertisingCampaign, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Кампания не найдена',
)
user = await db.get(User, user_id)
if not user or user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Пользователь не является партнёром',
)
# Atomic check-and-set to prevent race conditions
result = await db.execute(
update(AdvertisingCampaign)
.where(
AdvertisingCampaign.id == campaign_id,
or_(
AdvertisingCampaign.partner_user_id.is_(None),
AdvertisingCampaign.partner_user_id == user_id,
),
)
.values(partner_user_id=user_id, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Кампания уже привязана к другому партнёру',
)
await db.commit()
logger.info(
'Кампания привязана к партнёру',
campaign_id=campaign_id,
partner_user_id=user_id,
admin_id=admin.id,
)
return {'success': True}
@router.post('/{user_id}/campaigns/{campaign_id}/unassign')
async def unassign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(require_permission('partners:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unassign a campaign from a partner."""
# Atomic check-and-unset to prevent race conditions
result = await db.execute(
update(AdvertisingCampaign)
.where(
AdvertisingCampaign.id == campaign_id,
AdvertisingCampaign.partner_user_id == user_id,
)
.values(partner_user_id=None, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
campaign = await db.get(AdvertisingCampaign, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Кампания не найдена',
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Кампания не привязана к этому партнёру',
)
await db.commit()
logger.info(
'Кампания откреплена от партнёра',
campaign_id=campaign_id,
partner_user_id=user_id,
admin_id=admin.id,
)
return {'success': True}
+21 -8
View File
@@ -4,7 +4,7 @@ from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
@@ -17,7 +17,7 @@ from app.services.payment_method_config_service import (
update_sort_order,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -60,10 +60,23 @@ class PaymentMethodConfigResponse(BaseModel):
class PaymentMethodConfigUpdateRequest(BaseModel):
is_enabled: bool | None = None
display_name: str | None = Field(default=None, description='Null to reset to default')
sub_options: dict | None = None
sub_options: dict[str, bool] | None = None
min_amount_kopeks: int | None = Field(default=None, ge=0)
max_amount_kopeks: int | None = Field(default=None, ge=0)
user_type_filter: str | None = Field(default=None, pattern='^(all|telegram|email)$')
@field_validator('sub_options', mode='before')
@classmethod
def validate_sub_options(cls, v: dict[str, bool] | None) -> dict[str, bool] | None:
if not v:
return None
if len(v) > 20:
raise ValueError('sub_options cannot have more than 20 keys')
for key in v:
if not isinstance(key, str) or len(key) > 50:
raise ValueError('sub_options keys must be strings of at most 50 characters')
return v
first_topup_filter: str | None = Field(default=None, pattern='^(any|yes|no)$')
promo_group_filter_mode: str | None = Field(default=None, pattern='^(all|selected)$')
allowed_promo_group_ids: list[int] | None = None
@@ -124,7 +137,7 @@ def _enrich_config(config, defaults: dict) -> PaymentMethodConfigResponse:
@router.get('', response_model=list[PaymentMethodConfigResponse])
async def list_payment_methods(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all payment method configurations."""
@@ -135,7 +148,7 @@ async def list_payment_methods(
@router.get('/promo-groups', response_model=list[PromoGroupSimple])
async def list_promo_groups(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all promo groups for filter selector."""
@@ -146,7 +159,7 @@ async def list_promo_groups(
@router.get('/{method_id}', response_model=PaymentMethodConfigResponse)
async def get_payment_method(
method_id: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get a single payment method configuration."""
@@ -163,7 +176,7 @@ async def get_payment_method(
@router.put('/order')
async def update_payment_methods_order(
request: SortOrderRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Batch update sort order for payment methods."""
@@ -176,7 +189,7 @@ async def update_payment_methods_order(
async def update_payment_method(
method_id: str,
request: PaymentMethodConfigUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payment_methods:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update a payment method configuration."""
+15 -7
View File
@@ -4,10 +4,14 @@ import math
from datetime import 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 pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, User
from app.services.payment_service import PaymentService
from app.services.payment_verification_service import (
@@ -19,7 +23,7 @@ from app.services.payment_verification_service import (
run_manual_check,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -272,7 +276,7 @@ async def get_all_pending_payments(
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
method_filter: str | None = Query(None, description='Filter by payment method'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all pending payments for admin verification."""
@@ -306,7 +310,7 @@ async def get_all_pending_payments(
@router.get('/stats', response_model=PaymentsStatsResponse)
async def get_payments_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get statistics about pending payments."""
@@ -329,7 +333,7 @@ async def get_payments_stats(
async def get_pending_payment_details(
method: str,
payment_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get details of a specific pending payment."""
@@ -356,7 +360,7 @@ async def get_pending_payment_details(
async def check_payment_status(
method: str,
payment_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('payments:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Manually check and update payment status."""
@@ -390,8 +394,12 @@ async def check_payment_status(
old_is_paid = record.is_paid
# Run manual check
payment_service = PaymentService()
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
try:
payment_service = PaymentService(bot=bot)
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
finally:
await bot.session.close()
if not updated:
return ManualCheckResponse(
+17 -17
View File
@@ -1,7 +1,7 @@
"""Admin routes for pinned messages in cabinet."""
import time
from datetime import datetime
from datetime import UTC, datetime
import structlog
from aiogram import Bot
@@ -22,7 +22,7 @@ from app.services.pinned_message_service import (
)
from app.utils.validators import sanitize_html, validate_html_tags
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.pinned_messages import (
PinnedMessageBroadcastResponse,
PinnedMessageCreateRequest,
@@ -89,7 +89,7 @@ def _get_bot() -> Bot:
@router.get('', response_model=PinnedMessageListResponse)
async def list_pinned_messages(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
@@ -117,7 +117,7 @@ async def list_pinned_messages(
@router.get('/active', response_model=PinnedMessageResponse | None)
async def get_active_message(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse | None:
"""Get current active pinned message."""
@@ -130,7 +130,7 @@ async def get_active_message(
@router.get('/{message_id}', response_model=PinnedMessageResponse)
async def get_pinned_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Get pinned message by ID."""
@@ -147,7 +147,7 @@ async def get_pinned_message(
@router.post('', response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_pinned_message(
payload: PinnedMessageCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""
@@ -201,7 +201,7 @@ async def create_pinned_message(
async def update_pinned_message(
message_id: int,
payload: PinnedMessageUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Update a pinned message content, media, or settings."""
@@ -227,7 +227,7 @@ async def update_pinned_message(
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.utcnow()
msg.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(msg)
@@ -240,7 +240,7 @@ async def update_pinned_message(
async def update_pinned_message_settings(
message_id: int,
payload: PinnedMessageSettingsRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Update only pinned message display settings."""
@@ -255,7 +255,7 @@ async def update_pinned_message_settings(
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.utcnow()
msg.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(msg)
@@ -267,7 +267,7 @@ async def update_pinned_message_settings(
@router.post('/active/deactivate', response_model=PinnedMessageResponse | None)
async def deactivate_active_message(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse | None:
"""Deactivate the current active pinned message without unpinning from users."""
@@ -282,7 +282,7 @@ async def deactivate_active_message(
@router.post('/active/unpin', response_model=PinnedMessageUnpinResponse)
async def unpin_active_message(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageUnpinResponse:
"""Unpin messages from all users and deactivate the active pinned message."""
@@ -311,7 +311,7 @@ async def unpin_active_message(
async def activate_pinned_message(
message_id: int,
broadcast: bool = Query(False),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""
@@ -332,11 +332,11 @@ async def activate_pinned_message(
await db.execute(
update(PinnedMessage)
.where(PinnedMessage.is_active.is_(True))
.values(is_active=False, updated_at=datetime.utcnow())
.values(is_active=False, updated_at=datetime.now(UTC))
)
msg.is_active = True
msg.updated_at = datetime.utcnow()
msg.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(msg)
@@ -360,7 +360,7 @@ async def activate_pinned_message(
@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse)
async def broadcast_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""Broadcast a pinned message to all active users."""
@@ -391,7 +391,7 @@ async def broadcast_message(
@router.delete('/{message_id}', status_code=status.HTTP_204_NO_CONTENT, response_model=None)
async def delete_pinned_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('pinned_messages:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete a pinned message. Active messages must be deactivated first."""
+227
View File
@@ -0,0 +1,227 @@
"""Admin RBAC access policies management routes."""
from __future__ import annotations
from datetime import datetime
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.rbac import AccessPolicyCRUD, AdminRoleCRUD
from app.database.models import User
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/rbac/policies', tags=['Admin RBAC Policies'])
# ============ Schemas ============
class PolicyResponse(BaseModel):
"""Access policy response."""
id: int
name: str
description: str | None = None
role_id: int | None = None
role_name: str | None = None
priority: int
effect: str
conditions: dict[str, Any] = Field(default_factory=dict)
resource: str
actions: list[str] = Field(default_factory=list)
is_active: bool
created_by: int | None = None
created_at: datetime | None = None
class PolicyCreateRequest(BaseModel):
"""Create a new access policy."""
name: str = Field(min_length=1, max_length=200)
description: str | None = None
role_id: int | None = None
priority: int = Field(default=0, ge=0, le=1000)
effect: str = Field(pattern=r'^(allow|deny)$')
conditions: dict[str, Any] = Field(default_factory=dict)
resource: str = Field(min_length=1, max_length=100)
actions: list[str] = Field(default_factory=list)
class PolicyUpdateRequest(BaseModel):
"""Update policy fields (all optional)."""
name: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = None
role_id: int | None = None
priority: int | None = Field(default=None, ge=0, le=1000)
effect: str | None = Field(default=None, pattern=r'^(allow|deny)$')
conditions: dict[str, Any] | None = None
resource: str | None = Field(default=None, min_length=1, max_length=100)
actions: list[str] | None = None
is_active: bool | None = None
# ============ Helper Functions ============
async def _policy_to_response(db: AsyncSession, policy) -> PolicyResponse:
"""Convert AccessPolicy model to PolicyResponse with role name."""
role_name = None
if policy.role_id is not None:
role = await AdminRoleCRUD.get_by_id(db, policy.role_id)
if role:
role_name = role.name
return PolicyResponse(
id=policy.id,
name=policy.name,
description=policy.description,
role_id=policy.role_id,
role_name=role_name,
priority=policy.priority,
effect=policy.effect,
conditions=policy.conditions or {},
resource=policy.resource,
actions=policy.actions or [],
is_active=policy.is_active,
created_by=policy.created_by,
created_at=policy.created_at,
)
# ============ Routes ============
@router.get('', response_model=list[PolicyResponse])
async def list_policies(
admin: User = Depends(require_permission('roles:read')),
db: AsyncSession = Depends(get_cabinet_db),
role_id: int | None = None,
):
"""List all access policies. Optionally filter by role_id."""
policies = await AccessPolicyCRUD.get_all(db, role_id=role_id)
return [await _policy_to_response(db, p) for p in policies]
@router.post('', response_model=PolicyResponse, status_code=status.HTTP_201_CREATED)
async def create_policy(
payload: PolicyCreateRequest,
admin: User = Depends(require_permission('roles:create')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new access policy (ABAC rule)."""
# Validate role_id if provided
if payload.role_id is not None:
role = await AdminRoleCRUD.get_by_id(db, payload.role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Referenced role not found',
)
policy = await AccessPolicyCRUD.create(
db,
name=payload.name,
description=payload.description,
role_id=payload.role_id,
priority=payload.priority,
effect=payload.effect,
conditions=payload.conditions,
resource=payload.resource,
actions=payload.actions,
created_by=admin.id,
)
await db.commit()
logger.info(
'Admin created access policy',
admin_id=admin.id,
policy_id=policy.id,
policy_name=policy.name,
effect=policy.effect,
)
return await _policy_to_response(db, policy)
@router.put('/{policy_id}', response_model=PolicyResponse)
async def update_policy(
policy_id: int,
payload: PolicyUpdateRequest,
admin: User = Depends(require_permission('roles:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing access policy."""
existing = await AccessPolicyCRUD.get_by_id(db, policy_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Policy not found',
)
update_data = payload.model_dump(exclude_unset=True)
# Validate role_id if changing
if 'role_id' in update_data and update_data['role_id'] is not None:
role = await AdminRoleCRUD.get_by_id(db, update_data['role_id'])
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Referenced role not found',
)
updated = await AccessPolicyCRUD.update(db, policy_id, **update_data)
if not updated:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Policy not found',
)
await db.commit()
logger.info(
'Admin updated access policy',
admin_id=admin.id,
policy_id=policy_id,
fields=list(update_data.keys()),
)
return await _policy_to_response(db, updated)
@router.delete('/{policy_id}')
async def delete_policy(
policy_id: int,
admin: User = Depends(require_permission('roles:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete an access policy."""
existing = await AccessPolicyCRUD.get_by_id(db, policy_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Policy not found',
)
deleted = await AccessPolicyCRUD.delete(db, policy_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to delete policy',
)
await db.commit()
logger.info(
'Admin deleted access policy',
admin_id=admin.id,
policy_id=policy_id,
policy_name=existing.name,
)
return {'message': 'Policy deleted', 'policy_id': policy_id}
+7 -7
View File
@@ -34,7 +34,7 @@ from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate
from app.handlers.admin.messages import get_custom_users, get_target_users
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -272,7 +272,7 @@ async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
@router.get('/templates', response_model=PromoOfferTemplateListResponse)
async def list_templates(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateListResponse:
"""Get list of promo offer templates."""
@@ -288,7 +288,7 @@ async def list_templates(
@router.get('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
async def get_template(
template_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateResponse:
"""Get a promo offer template."""
@@ -302,7 +302,7 @@ async def get_template(
async def update_template(
template_id: int,
payload: PromoOfferTemplateUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateResponse:
"""Update a promo offer template."""
@@ -338,7 +338,7 @@ async def update_template(
@router.get('', response_model=PromoOfferListResponse)
async def list_offers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
@@ -491,7 +491,7 @@ async def _send_promo_notifications(
@router.post('/broadcast', response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED)
async def broadcast_offer(
payload: PromoOfferBroadcastRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:send')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferBroadcastResponse:
"""Broadcast promo offer to users with optional Telegram notification."""
@@ -605,7 +605,7 @@ async def broadcast_offer(
@router.get('/logs', response_model=PromoOfferLogListResponse)
async def get_logs(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_offers:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
+14 -14
View File
@@ -30,7 +30,7 @@ from app.database.crud.promocode import (
)
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, PromoGroup, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
router = APIRouter(prefix='/admin/promocodes', tags=['Admin Promocodes'])
@@ -162,9 +162,9 @@ def _normalize_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is not None and value.utcoffset() is not None:
return value.astimezone(UTC).replace(tzinfo=None)
return value.astimezone(UTC)
if value.tzinfo is not None:
return value.replace(tzinfo=None)
return value
return value
@@ -305,7 +305,7 @@ def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCo
@router.get('', response_model=PromoCodeListResponse)
async def list_promocodes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
@@ -326,7 +326,7 @@ async def list_promocodes(
@router.get('/{promocode_id}', response_model=PromoCodeDetailResponse)
async def get_promocode(
promocode_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeDetailResponse:
"""Get promocode details with usage statistics."""
@@ -349,7 +349,7 @@ async def get_promocode(
@router.post('', response_model=PromoCodeResponse, status_code=status.HTTP_201_CREATED)
async def create_promocode_endpoint(
payload: PromoCodeCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeResponse:
"""Create a new promocode."""
@@ -399,7 +399,7 @@ async def create_promocode_endpoint(
async def update_promocode_endpoint(
promocode_id: int,
payload: PromoCodeUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeResponse:
"""Update an existing promocode."""
@@ -460,7 +460,7 @@ async def update_promocode_endpoint(
)
async def delete_promocode_endpoint(
promocode_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> Response:
"""Delete a promocode."""
@@ -486,7 +486,7 @@ class DeactivateDiscountResponse(BaseModel):
@router.post('/deactivate-discount/{user_id}', response_model=DeactivateDiscountResponse)
async def admin_deactivate_discount_promocode(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promocodes:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> DeactivateDiscountResponse:
"""Admin: deactivate a user's active discount promo code."""
@@ -537,7 +537,7 @@ promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo
@promo_groups_router.get('', response_model=PromoGroupListResponse)
async def list_promo_groups(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:read')),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
@@ -561,7 +561,7 @@ async def list_promo_groups(
@promo_groups_router.get('/{group_id}', response_model=PromoGroupResponse)
async def get_promo_group(
group_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Get promo group details."""
@@ -576,7 +576,7 @@ async def get_promo_group(
@promo_groups_router.post('', response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
async def create_promo_group_endpoint(
payload: PromoGroupCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:create')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Create a new promo group."""
@@ -608,7 +608,7 @@ async def create_promo_group_endpoint(
async def update_promo_group_endpoint(
group_id: int,
payload: PromoGroupUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:edit')),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Update a promo group."""
@@ -645,7 +645,7 @@ async def update_promo_group_endpoint(
@promo_groups_router.delete('/{group_id}', status_code=status.HTTP_204_NO_CONTENT)
async def delete_promo_group_endpoint(
group_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('promo_groups:delete')),
db: AsyncSession = Depends(get_cabinet_db),
) -> Response:
"""Delete a promo group."""
+36 -33
View File
@@ -1,6 +1,6 @@
"""Admin routes for RemnaWave management in cabinet."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
@@ -16,7 +16,7 @@ from app.database.crud.server_squad import (
from app.database.models import User
from app.utils.cache import cache
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.remnawave import (
AutoSyncRunResponse,
# Auto Sync
@@ -109,7 +109,10 @@ def _parse_datetime(value: Any) -> datetime | None:
return value
if isinstance(value, str):
try:
return datetime.fromisoformat(value)
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed
except ValueError:
return None
return None
@@ -150,7 +153,7 @@ def _serialize_node(node_data: dict[str, Any]) -> NodeInfo:
@router.get('/status', response_model=RemnaWaveStatusResponse)
async def get_remnawave_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> RemnaWaveStatusResponse:
"""Get RemnaWave configuration and connection status."""
service = _get_service()
@@ -173,7 +176,7 @@ async def get_remnawave_status(
@router.get('/system', response_model=SystemStatsResponse)
async def get_system_statistics(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> SystemStatsResponse:
"""Get full system statistics from RemnaWave."""
service = _get_service()
@@ -235,7 +238,7 @@ async def get_system_statistics(
@router.get('/nodes', response_model=NodesListResponse)
async def list_nodes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodesListResponse:
"""Get list of all nodes."""
service = _get_service()
@@ -249,7 +252,7 @@ async def list_nodes(
@router.get('/nodes/overview', response_model=NodesOverview)
async def get_nodes_overview(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodesOverview:
"""Get nodes overview with statistics."""
service = _get_service()
@@ -275,7 +278,7 @@ async def get_nodes_overview(
@router.get('/nodes/realtime')
async def get_nodes_realtime(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> list[dict[str, Any]]:
"""Get realtime node usage data."""
service = _get_service()
@@ -287,7 +290,7 @@ async def get_nodes_realtime(
@router.get('/nodes/{node_uuid}', response_model=NodeInfo)
async def get_node_details(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodeInfo:
"""Get detailed information about a specific node."""
service = _get_service()
@@ -306,7 +309,7 @@ async def get_node_details(
@router.get('/nodes/{node_uuid}/statistics', response_model=NodeStatisticsResponse)
async def get_node_statistics(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodeStatisticsResponse:
"""Get node statistics with usage history."""
service = _get_service()
@@ -332,13 +335,13 @@ async def get_node_usage(
node_uuid: str,
start: datetime | None = Query(default=None),
end: datetime | None = Query(default=None),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> NodeUsageResponse:
"""Get node usage history for a date range."""
service = _get_service()
_ensure_configured(service)
end_dt = end or datetime.utcnow()
end_dt = end or datetime.now(UTC)
start_dt = start or (end_dt - timedelta(days=7))
if start_dt >= end_dt:
@@ -355,7 +358,7 @@ async def get_node_usage(
async def perform_node_action(
node_uuid: str,
payload: NodeActionRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> NodeActionResponse:
"""Perform an action on a node (enable/disable/restart)."""
service = _get_service()
@@ -396,7 +399,7 @@ async def perform_node_action(
@router.post('/nodes/restart-all', response_model=NodeActionResponse)
async def restart_all_nodes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> NodeActionResponse:
"""Restart all nodes."""
service = _get_service()
@@ -418,7 +421,7 @@ async def restart_all_nodes(
@router.get('/squads', response_model=SquadsListResponse)
async def list_squads(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SquadsListResponse:
"""Get list of all squads with local database info."""
@@ -460,7 +463,7 @@ async def list_squads(
@router.get('/squads/{squad_uuid}', response_model=SquadDetailResponse)
async def get_squad_details(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SquadDetailResponse:
"""Get detailed information about a squad."""
@@ -503,7 +506,7 @@ async def get_squad_details(
@router.post('/squads', response_model=SquadOperationResponse, status_code=status.HTTP_201_CREATED)
async def create_squad(
payload: SquadCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> SquadOperationResponse:
"""Create a new squad in RemnaWave."""
service = _get_service()
@@ -530,7 +533,7 @@ async def create_squad(
async def update_squad(
squad_uuid: str,
payload: SquadUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> SquadOperationResponse:
"""Update a squad in RemnaWave."""
service = _get_service()
@@ -561,7 +564,7 @@ async def update_squad(
async def perform_squad_action(
squad_uuid: str,
payload: SquadActionRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> SquadOperationResponse:
"""Perform an action on a squad."""
service = _get_service()
@@ -606,7 +609,7 @@ async def perform_squad_action(
@router.delete('/squads/{squad_uuid}', response_model=SquadOperationResponse)
async def delete_squad(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
) -> SquadOperationResponse:
"""Delete a squad."""
service = _get_service()
@@ -629,7 +632,7 @@ async def delete_squad(
@router.get('/squads/{squad_uuid}/migration-preview', response_model=MigrationPreviewResponse)
async def preview_migration(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> MigrationPreviewResponse:
"""Get migration preview for a squad."""
@@ -654,7 +657,7 @@ async def preview_migration(
@router.post('/squads/migrate', response_model=MigrationResponse)
async def migrate_squad_users(
payload: MigrationRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
db: AsyncSession = Depends(get_cabinet_db),
) -> MigrationResponse:
"""Migrate users from one squad to another."""
@@ -728,7 +731,7 @@ async def migrate_squad_users(
@router.get('/inbounds', response_model=InboundsListResponse)
async def list_inbounds(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> InboundsListResponse:
"""Get list of all available inbounds."""
service = _get_service()
@@ -743,7 +746,7 @@ async def list_inbounds(
@router.get('/sync/auto/status', response_model=AutoSyncStatus)
async def get_auto_sync_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
) -> AutoSyncStatus:
"""Get auto sync status."""
if remnawave_sync_service is None:
@@ -772,7 +775,7 @@ async def get_auto_sync_status(
@router.post('/sync/auto/toggle', response_model=SyncResponse)
async def toggle_auto_sync(
payload: AutoSyncToggleRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
) -> SyncResponse:
"""Toggle auto sync on/off."""
if remnawave_sync_service is None:
@@ -808,7 +811,7 @@ async def toggle_auto_sync(
@router.post('/sync/auto/run', response_model=AutoSyncRunResponse)
async def run_auto_sync_now(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
) -> AutoSyncRunResponse:
"""Run auto sync immediately."""
if remnawave_sync_service is None:
@@ -836,7 +839,7 @@ async def run_auto_sync_now(
@router.post('/sync/from-panel', response_model=SyncResponse)
async def sync_from_panel(
payload: SyncMode,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync users from RemnaWave panel to bot."""
@@ -860,7 +863,7 @@ async def sync_from_panel(
@router.post('/sync/to-panel', response_model=SyncResponse)
async def sync_to_panel(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync users from bot to RemnaWave panel."""
@@ -879,7 +882,7 @@ async def sync_to_panel(
@router.post('/sync/servers', response_model=SyncResponse)
async def sync_servers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync servers/squads from RemnaWave."""
@@ -922,7 +925,7 @@ async def sync_servers(
@router.post('/sync/subscriptions/validate', response_model=SyncResponse)
async def validate_subscriptions(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Validate and fix subscriptions."""
@@ -941,7 +944,7 @@ async def validate_subscriptions(
@router.post('/sync/subscriptions/cleanup', response_model=SyncResponse)
async def cleanup_subscriptions(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Cleanup orphaned subscriptions."""
@@ -960,7 +963,7 @@ async def cleanup_subscriptions(
@router.post('/sync/subscriptions/statuses', response_model=SyncResponse)
async def sync_subscription_statuses(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:sync')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync subscription statuses."""
@@ -979,7 +982,7 @@ async def sync_subscription_statuses(
@router.get('/sync/recommendations', response_model=SyncResponse)
async def get_sync_recommendations(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:read')),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Get sync recommendations."""
+542
View File
@@ -0,0 +1,542 @@
"""Admin RBAC roles management routes."""
from __future__ import annotations
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.rbac import AdminRoleCRUD, UserRoleCRUD
from app.database.models import User
from app.services.permission_service import PERMISSION_REGISTRY, get_all_permissions
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/rbac', tags=['Admin RBAC'])
# ============ Schemas ============
class RoleResponse(BaseModel):
"""Admin role with user count."""
id: int
name: str
description: str | None = None
level: int
permissions: list[str] = Field(default_factory=list)
color: str | None = None
icon: str | None = None
is_system: bool
is_active: bool
user_count: int = 0
created_at: datetime | None = None
class RoleCreateRequest(BaseModel):
"""Create a new custom role."""
name: str = Field(min_length=1, max_length=100)
description: str | None = None
level: int = Field(ge=0, le=998)
permissions: list[str] = Field(default_factory=list)
color: str | None = Field(default=None, max_length=7)
icon: str | None = Field(default=None, max_length=50)
class RoleUpdateRequest(BaseModel):
"""Update role fields (all optional)."""
name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = None
level: int | None = Field(default=None, ge=0, le=998)
permissions: list[str] | None = None
color: str | None = Field(default=None, max_length=7)
icon: str | None = Field(default=None, max_length=50)
is_active: bool | None = None
class RoleAssignRequest(BaseModel):
"""Assign a role to a user."""
user_id: int
role_id: int
expires_at: datetime | None = None
class PermissionSection(BaseModel):
"""Permission section with available actions."""
section: str
actions: list[str]
class UserRoleResponse(BaseModel):
"""User-role assignment details."""
id: int
user_id: int
role_id: int
role_name: str | None = None
user_telegram_id: int | None = None
user_username: str | None = None
user_first_name: str | None = None
user_email: str | None = None
assigned_by: int | None = None
assigned_at: datetime | None = None
expires_at: datetime | None = None
is_active: bool
class AdminWithRolesResponse(BaseModel):
"""User that has at least one admin role."""
user_id: int
telegram_id: int | None = None
username: str | None = None
first_name: str | None = None
last_name: str | None = None
email: str | None = None
role_names: list[str] = Field(default_factory=list)
# ============ Helper Functions ============
async def _role_to_response(db: AsyncSession, role) -> RoleResponse:
"""Convert AdminRole model to RoleResponse with user count."""
user_count = await AdminRoleCRUD.count_users(db, role.id)
return RoleResponse(
id=role.id,
name=role.name,
description=role.description,
level=role.level,
permissions=role.permissions or [],
color=role.color,
icon=role.icon,
is_system=role.is_system,
is_active=role.is_active,
user_count=user_count,
created_at=role.created_at,
)
async def _get_admin_level(db: AsyncSession, admin: User) -> int:
"""Get the maximum role level of the current admin.
Legacy config-based admins (ADMIN_IDS) get superadmin level (999+1=1000)
so they can manage all roles including level 999.
"""
from app.config import settings
_perms, _names, max_level = await UserRoleCRUD.get_user_permissions(db, admin.id)
# Legacy config-based admins always get the highest level
if settings.is_admin(
telegram_id=admin.telegram_id,
email=admin.email if admin.email_verified else None,
):
max_level = max(max_level, 1000)
return max_level
def _validate_permissions(permissions: list[str]) -> None:
"""Validate that all provided permissions exist in the registry."""
all_valid = set(get_all_permissions())
# Also allow wildcard patterns
all_valid.add('*:*')
for section in PERMISSION_REGISTRY:
all_valid.add(f'{section}:*')
invalid = [p for p in permissions if p not in all_valid]
if invalid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid permissions: {", ".join(invalid)}',
)
# ============ Routes ============
@router.get('/permissions', response_model=list[PermissionSection])
async def get_permission_registry(
admin: User = Depends(require_permission('roles:read')),
):
"""Get all available permissions grouped by section."""
return [
PermissionSection(section=section, actions=list(actions)) for section, actions in PERMISSION_REGISTRY.items()
]
@router.get('/users', response_model=list[AdminWithRolesResponse])
async def list_rbac_users(
admin: User = Depends(require_permission('roles:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all users that have at least one active RBAC role."""
from sqlalchemy import select as _sa_select
from sqlalchemy.orm import selectinload as _sel
from app.database.models import UserRole as _UserRole
result = await db.execute(
_sa_select(_UserRole)
.options(_sel(_UserRole.user), _sel(_UserRole.role))
.where(_UserRole.is_active.is_(True))
.order_by(_UserRole.user_id)
)
assignments = result.scalars().all()
users_map: dict[int, AdminWithRolesResponse] = {}
for a in assignments:
if not a.user:
continue
if a.user_id not in users_map:
users_map[a.user_id] = AdminWithRolesResponse(
user_id=a.user_id,
telegram_id=a.user.telegram_id,
username=a.user.username,
first_name=a.user.first_name,
last_name=a.user.last_name,
email=a.user.email,
role_names=[],
)
if a.role:
users_map[a.user_id].role_names.append(a.role.name)
return list(users_map.values())
@router.get('/roles/{role_id}/users', response_model=list[UserRoleResponse])
async def list_role_users(
role_id: int,
admin: User = Depends(require_permission('roles:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List user-role assignments for a specific role."""
from sqlalchemy.orm import selectinload as _sel
role = await AdminRoleCRUD.get_by_id(db, role_id)
if not role:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Role not found')
from sqlalchemy import select as _sa_select
from app.database.models import UserRole as _UserRole
result = await db.execute(
_sa_select(_UserRole)
.options(_sel(_UserRole.user), _sel(_UserRole.role))
.where(_UserRole.role_id == role_id, _UserRole.is_active.is_(True))
.order_by(_UserRole.assigned_at.desc())
)
assignments = result.scalars().all()
return [
UserRoleResponse(
id=a.id,
user_id=a.user_id,
role_id=a.role_id,
role_name=a.role.name if a.role else None,
user_telegram_id=a.user.telegram_id if a.user else None,
user_username=a.user.username if a.user else None,
user_first_name=a.user.first_name if a.user else None,
user_email=a.user.email if a.user else None,
assigned_by=a.assigned_by,
assigned_at=a.assigned_at,
expires_at=a.expires_at,
is_active=a.is_active,
)
for a in assignments
]
@router.get('/roles', response_model=list[RoleResponse])
async def list_roles(
admin: User = Depends(require_permission('roles:read')),
db: AsyncSession = Depends(get_cabinet_db),
include_inactive: bool = False,
):
"""List all admin roles with user counts."""
roles = await AdminRoleCRUD.get_all(db, include_inactive=include_inactive)
return [await _role_to_response(db, role) for role in roles]
@router.post('/roles', response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
async def create_role(
payload: RoleCreateRequest,
admin: User = Depends(require_permission('roles:create')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new custom admin role."""
# Validate permissions list
_validate_permissions(payload.permissions)
# Hierarchy enforcement: cannot create role with level >= own level
admin_level = await _get_admin_level(db, admin)
if payload.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot create a role with level >= your own role level',
)
# Check name uniqueness
existing = await AdminRoleCRUD.get_by_name(db, payload.name)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Role with this name already exists',
)
role = await AdminRoleCRUD.create(
db,
name=payload.name,
description=payload.description,
level=payload.level,
permissions=payload.permissions,
color=payload.color,
icon=payload.icon,
created_by=admin.id,
)
await db.commit()
logger.info('Admin created role', admin_id=admin.id, role_id=role.id, role_name=role.name)
return await _role_to_response(db, role)
@router.put('/roles/{role_id}', response_model=RoleResponse)
async def update_role(
role_id: int,
payload: RoleUpdateRequest,
admin: User = Depends(require_permission('roles:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing admin role."""
role = await AdminRoleCRUD.get_by_id(db, role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Role not found',
)
admin_level = await _get_admin_level(db, admin)
# Cannot edit a role at or above own level
if role.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot edit a role at or above your own level',
)
update_data = payload.model_dump(exclude_unset=True)
# Validate level change
if 'level' in update_data and update_data['level'] >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot set role level >= your own role level',
)
# Validate permissions
if 'permissions' in update_data and update_data['permissions'] is not None:
_validate_permissions(update_data['permissions'])
# Check name uniqueness if name is changing
if 'name' in update_data and update_data['name'] != role.name:
existing = await AdminRoleCRUD.get_by_name(db, update_data['name'])
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Role with this name already exists',
)
updated = await AdminRoleCRUD.update(db, role_id, **update_data)
if not updated:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Role not found',
)
await db.commit()
logger.info('Admin updated role', admin_id=admin.id, role_id=role_id, fields=list(update_data.keys()))
return await _role_to_response(db, updated)
@router.delete('/roles/{role_id}')
async def delete_role(
role_id: int,
admin: User = Depends(require_permission('roles:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a custom admin role. System roles cannot be deleted."""
role = await AdminRoleCRUD.get_by_id(db, role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Role not found',
)
if role.is_system:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot delete a system role',
)
admin_level = await _get_admin_level(db, admin)
if role.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot delete a role at or above your own level',
)
deleted = await AdminRoleCRUD.delete(db, role_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to delete role',
)
await db.commit()
logger.info('Admin deleted role', admin_id=admin.id, role_id=role_id, role_name=role.name)
return {'message': 'Role deleted', 'role_id': role_id}
@router.post('/assignments', response_model=UserRoleResponse, status_code=status.HTTP_201_CREATED)
async def assign_role(
payload: RoleAssignRequest,
admin: User = Depends(require_permission('roles:assign')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Assign a role to a user. Hierarchy enforcement applies."""
role = await AdminRoleCRUD.get_by_id(db, payload.role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Role not found',
)
admin_level = await _get_admin_level(db, admin)
# Cannot assign a role with level >= own level
if role.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot assign a role with level >= your own role level',
)
# Verify target user exists
from app.database.crud.user import get_user_by_id
target_user = await get_user_by_id(db, payload.user_id)
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Target user not found',
)
user_role = await UserRoleCRUD.assign_role(
db,
user_id=payload.user_id,
role_id=payload.role_id,
assigned_by=admin.id,
expires_at=payload.expires_at,
)
await db.commit()
logger.info(
'Admin assigned role',
admin_id=admin.id,
target_user_id=payload.user_id,
role_id=payload.role_id,
role_name=role.name,
)
return UserRoleResponse(
id=user_role.id,
user_id=user_role.user_id,
role_id=user_role.role_id,
role_name=role.name,
user_telegram_id=target_user.telegram_id,
user_username=target_user.username,
user_first_name=target_user.first_name,
user_email=target_user.email,
assigned_by=user_role.assigned_by,
assigned_at=user_role.assigned_at,
expires_at=user_role.expires_at,
is_active=user_role.is_active,
)
@router.delete('/assignments/{assignment_id}')
async def revoke_role(
assignment_id: int,
admin: User = Depends(require_permission('roles:assign')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Revoke a role assignment. Cannot remove the last superadmin."""
from sqlalchemy import select as sa_select
from app.database.models import UserRole
# Load the assignment to check hierarchy
result = await db.execute(sa_select(UserRole).where(UserRole.id == assignment_id))
user_role = result.scalar_one_or_none()
if not user_role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Role assignment not found',
)
role = await AdminRoleCRUD.get_by_id(db, user_role.role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Associated role not found',
)
admin_level = await _get_admin_level(db, admin)
# Cannot revoke a role at or above own level
if role.level >= admin_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot revoke a role at or above your own level',
)
# Protect last superadmin (level 999)
superadmin_level = 999
if role.level == superadmin_level:
superadmin_count = await UserRoleCRUD.get_superadmin_count(db)
if superadmin_count <= 1:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot remove the last superadmin',
)
revoked = await UserRoleCRUD.revoke_role(db, assignment_id)
if not revoked:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to revoke role',
)
await db.commit()
logger.info(
'Admin revoked role assignment',
admin_id=admin.id,
assignment_id=assignment_id,
target_user_id=user_role.user_id,
role_name=role.name,
)
return {'message': 'Role revoked', 'assignment_id': assignment_id}
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -16,7 +16,7 @@ from app.database.crud.server_squad import (
from app.database.models import PromoGroup, ServerSquad, Subscription, Tariff, User
from app.services.subscription_service import SubscriptionService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.servers import (
PromoGroupInfo,
ServerDetailResponse,
@@ -66,7 +66,7 @@ async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> list[s
@router.get('', response_model=ServerListResponse)
async def list_servers(
include_unavailable: bool = True,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all servers."""
@@ -103,7 +103,7 @@ async def list_servers(
@router.get('/{server_id}', response_model=ServerDetailResponse)
async def get_server(
server_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed server info."""
@@ -146,7 +146,7 @@ async def get_server(
async def update_existing_server(
server_id: int,
request: ServerUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing server."""
@@ -191,7 +191,7 @@ async def update_existing_server(
@router.post('/{server_id}/toggle', response_model=ServerToggleResponse)
async def toggle_server(
server_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle server availability."""
@@ -218,7 +218,7 @@ async def toggle_server(
@router.post('/{server_id}/trial', response_model=ServerTrialToggleResponse)
async def toggle_server_trial(
server_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle server trial eligibility."""
@@ -245,7 +245,7 @@ async def toggle_server_trial(
@router.get('/{server_id}/stats', response_model=ServerStatsResponse)
async def get_server_stats(
server_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get server statistics."""
@@ -287,7 +287,7 @@ async def get_server_stats(
@router.post('/sync', response_model=ServerSyncResponse)
async def sync_servers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('servers:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Sync servers with RemnaWave."""
+6 -6
View File
@@ -13,7 +13,7 @@ from app.services.system_settings_service import (
bot_configuration_service,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -179,7 +179,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
@router.get('/categories', response_model=list[SettingCategorySummary])
async def list_categories(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:read')),
):
"""Get list of setting categories."""
categories = bot_configuration_service.get_categories()
@@ -196,7 +196,7 @@ async def list_categories(
@router.get('', response_model=list[SettingDefinition])
async def list_settings(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:read')),
category: str | None = Query(default=None, alias='category_key'),
):
"""Get list of all settings or settings for a specific category."""
@@ -217,7 +217,7 @@ async def list_settings(
@router.get('/{key}', response_model=SettingDefinition)
async def get_setting(
key: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:read')),
):
"""Get a specific setting by key."""
try:
@@ -232,7 +232,7 @@ async def get_setting(
async def update_setting(
key: str,
payload: SettingUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update a setting value."""
@@ -255,7 +255,7 @@ async def update_setting(
@router.delete('/{key}', response_model=SettingDefinition)
async def reset_setting(
key: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset a setting to its default value."""
+24 -67
View File
@@ -2,7 +2,7 @@
import sys
import time
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
@@ -26,7 +26,7 @@ from app.database.models import (
from app.services.remnawave_service import RemnaWaveService
from app.services.version_service import version_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -246,7 +246,7 @@ class RecentPaymentsResponse(BaseModel):
@router.get('/dashboard', response_model=DashboardStats)
async def get_dashboard_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get complete dashboard statistics for admin panel."""
@@ -258,10 +258,13 @@ async def get_dashboard_stats(
sub_stats = await get_subscriptions_statistics(db)
# Get financial statistics
now = datetime.utcnow()
now = datetime.now(UTC)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
trans_stats = await get_transactions_statistics(db, month_start, now)
all_time_stats = await get_transactions_statistics(
db, start_date=datetime(2020, 1, 1, tzinfo=UTC), end_date=now
)
# Get revenue chart data (last 30 days)
revenue_data = await get_revenue_by_period(db, days=30)
@@ -291,10 +294,11 @@ async def get_dashboard_stats(
income_today_rubles=trans_stats.get('today', {}).get('income_kopeks', 0) / 100,
income_month_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0),
income_month_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100,
income_total_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0),
income_total_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100,
subscription_income_kopeks=trans_stats.get('totals', {}).get('subscription_income_kopeks', 0),
subscription_income_rubles=trans_stats.get('totals', {}).get('subscription_income_kopeks', 0) / 100,
income_total_kopeks=all_time_stats.get('totals', {}).get('income_kopeks', 0),
income_total_rubles=all_time_stats.get('totals', {}).get('income_kopeks', 0) / 100,
subscription_income_kopeks=abs(all_time_stats.get('totals', {}).get('subscription_income_kopeks', 0)),
subscription_income_rubles=abs(all_time_stats.get('totals', {}).get('subscription_income_kopeks', 0))
/ 100,
),
servers=ServerStats(
total_servers=server_stats.get('total_servers', 0),
@@ -326,7 +330,7 @@ async def get_dashboard_stats(
@router.get('/system-info', response_model=SystemInfoResponse)
async def get_system_info(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get system information for admin dashboard."""
@@ -358,7 +362,7 @@ async def get_system_info(
@router.get('/nodes', response_model=NodesOverview)
async def get_nodes_status(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
):
"""Get status of all nodes."""
try:
@@ -374,7 +378,7 @@ async def get_nodes_status(
@router.post('/nodes/{node_uuid}/restart')
async def restart_node(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
):
"""Restart a node."""
try:
@@ -401,7 +405,7 @@ async def restart_node(
@router.post('/nodes/{node_uuid}/toggle')
async def toggle_node(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('remnawave:manage')),
):
"""Enable or disable a node."""
try:
@@ -503,7 +507,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
logger.info('📊 Нет тарифов в системе, пропускаем статистику')
return None
now = datetime.utcnow()
now = datetime.now(UTC)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
@@ -596,12 +600,12 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
@router.get('/referrals/top', response_model=TopReferrersResponse)
async def get_top_referrers(
limit: int = 20,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get top referrers with earnings breakdown by period."""
try:
now = datetime.utcnow()
now = datetime.now(UTC)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
@@ -686,53 +690,6 @@ async def get_top_referrers(
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_month'] = row.total or 0
# Also add REFERRAL_REWARD transactions
trans_total_query = await db.execute(
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
.group_by(Transaction.user_id)
)
for row in trans_total_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_total'] = referrers_data[row.referrer_id].get(
'earnings_total', 0
) + (row.total or 0)
trans_today_query = await db.execute(
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
.where(
and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= today_start)
)
.group_by(Transaction.user_id)
)
for row in trans_today_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_today'] = referrers_data[row.referrer_id].get(
'earnings_today', 0
) + (row.total or 0)
trans_week_query = await db.execute(
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
.where(and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= week_ago))
.group_by(Transaction.user_id)
)
for row in trans_week_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_week'] = referrers_data[row.referrer_id].get(
'earnings_week', 0
) + (row.total or 0)
trans_month_query = await db.execute(
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
.where(and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= month_ago))
.group_by(Transaction.user_id)
)
for row in trans_month_query:
if row.referrer_id in referrers_data:
referrers_data[row.referrer_id]['earnings_month'] = referrers_data[row.referrer_id].get(
'earnings_month', 0
) + (row.total or 0)
# Get user info for all referrers
referrer_ids = list(referrers_data.keys())
if referrer_ids:
@@ -812,7 +769,7 @@ async def get_top_referrers(
@router.get('/campaigns/top', response_model=TopCampaignsResponse)
async def get_top_campaigns(
limit: int = 20,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get top advertising campaigns with statistics."""
@@ -869,12 +826,12 @@ async def get_top_campaigns(
@router.get('/payments/recent', response_model=RecentPaymentsResponse)
async def get_recent_payments(
limit: int = 50,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('stats:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get recent payments with user info."""
try:
now = datetime.utcnow()
now = datetime.now(UTC)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
@@ -944,8 +901,8 @@ async def get_recent_payments(
email=user.email,
username=user.username,
display_name=display_name,
amount_kopeks=trans.amount_kopeks,
amount_rubles=trans.amount_kopeks / 100,
amount_kopeks=abs(trans.amount_kopeks),
amount_rubles=abs(trans.amount_kopeks) / 100,
type=trans.type,
type_display=type_display.get(trans.type, trans.type),
payment_method=trans.payment_method,
+44 -12
View File
@@ -19,8 +19,9 @@ from app.database.crud.tariff import (
)
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.tariffs import (
ExternalSquadInfoResponse,
PeriodPrice,
PromoGroupInfo,
ServerInfo,
@@ -107,7 +108,7 @@ def _period_prices_to_dict(period_prices: list[PeriodPrice]) -> dict:
@router.get('', response_model=TariffListResponse)
async def list_tariffs(
include_inactive: bool = True,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all tariffs."""
@@ -141,7 +142,7 @@ async def list_tariffs(
@router.get('/available-servers', response_model=list[ServerInfo])
async def get_available_servers(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all servers for tariff selection."""
@@ -158,10 +159,34 @@ async def get_available_servers(
]
@router.get('/available-external-squads', response_model=list[ExternalSquadInfoResponse])
async def get_available_external_squads(
admin: User = Depends(require_permission('tariffs:read')),
):
"""Fetch external squads from RemnaWave panel."""
from app.services.remnawave_service import RemnaWaveService
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
squads = await api.get_external_squads()
return [
{
'uuid': s.uuid,
'name': s.name,
'members_count': s.members_count,
}
for s in squads
]
except Exception:
logger.warning('Failed to fetch external squads from RemnaWave', exc_info=True)
return []
@router.put('/order')
async def update_tariff_order(
request: TariffSortOrderRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update the display order of tariffs."""
@@ -176,7 +201,7 @@ async def update_tariff_order(
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
async def get_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed tariff info."""
@@ -238,6 +263,8 @@ async def get_tariff(
daily_price_kopeks=tariff.daily_price_kopeks,
# Режим сброса трафика
traffic_reset_mode=tariff.traffic_reset_mode,
# Внешний сквад
external_squad_uuid=tariff.external_squad_uuid,
created_at=tariff.created_at,
updated_at=tariff.updated_at,
)
@@ -246,7 +273,7 @@ async def get_tariff(
@router.post('', response_model=TariffDetailResponse)
async def create_new_tariff(
request: TariffCreateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:create')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new tariff."""
@@ -292,6 +319,8 @@ async def create_new_tariff(
daily_price_kopeks=request.daily_price_kopeks,
# Режим сброса трафика
traffic_reset_mode=request.traffic_reset_mode,
# Внешний сквад
external_squad_uuid=request.external_squad_uuid,
)
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
@@ -307,7 +336,7 @@ async def create_new_tariff(
async def update_existing_tariff(
tariff_id: int,
request: TariffUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing tariff."""
@@ -381,6 +410,9 @@ async def update_existing_tariff(
# Режим сброса трафика (None допускается как значение для сброса к глобальной настройке)
if 'traffic_reset_mode' in request.model_fields_set:
updates['traffic_reset_mode'] = request.traffic_reset_mode
# Внешний сквад (None допускается для сброса)
if 'external_squad_uuid' in request.model_fields_set:
updates['external_squad_uuid'] = request.external_squad_uuid
if updates:
await update_tariff(db, tariff, **updates)
@@ -400,7 +432,7 @@ async def update_existing_tariff(
@router.delete('/{tariff_id}')
async def delete_existing_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a tariff."""
@@ -430,7 +462,7 @@ async def delete_existing_tariff(
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
async def toggle_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle tariff active status."""
@@ -460,7 +492,7 @@ async def toggle_tariff(
@router.post('/{tariff_id}/trial', response_model=TariffTrialResponse)
async def toggle_trial_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle tariff trial availability.
@@ -500,7 +532,7 @@ async def toggle_trial_tariff(
@router.get('/{tariff_id}/stats', response_model=TariffStatsResponse)
async def get_tariff_stats(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tariffs:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get tariff statistics."""
@@ -535,7 +567,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,
+16 -16
View File
@@ -1,7 +1,7 @@
"""Admin tickets routes for cabinet."""
import math
from datetime import datetime
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -16,7 +16,7 @@ from app.database.crud.ticket import TicketCRUD
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.database.models import Ticket, TicketMessage, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.tickets import TicketMessageResponse
@@ -197,7 +197,7 @@ def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) ->
@router.get('/stats', response_model=AdminStatsResponse)
async def get_ticket_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket statistics."""
@@ -222,7 +222,7 @@ async def get_ticket_stats(
@router.get('/settings', response_model=TicketSettingsResponse)
async def get_ticket_settings(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket system settings."""
@@ -242,7 +242,7 @@ async def get_ticket_settings(
@router.patch('/settings', response_model=TicketSettingsResponse)
async def update_ticket_settings(
request: TicketSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket system settings."""
@@ -269,7 +269,7 @@ async def update_ticket_settings(
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()
SupportSettingsService.set_system_mode(request.support_system_mode.strip().lower())
# Update cabinet notification settings
if request.cabinet_user_notifications_enabled is not None:
@@ -337,7 +337,7 @@ async def get_all_tickets(
status_filter: str | None = Query(None, alias='status', description='Filter by status'),
priority_filter: str | None = Query(None, alias='priority', description='Filter by priority'),
user_id: int | None = Query(None, description='Filter by user ID'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all tickets for admin."""
@@ -386,7 +386,7 @@ async def get_all_tickets(
@router.get('/{ticket_id}', response_model=AdminTicketDetailResponse)
async def get_ticket_detail(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket with all messages for admin."""
@@ -428,7 +428,7 @@ async def get_ticket_detail(
async def reply_to_ticket(
ticket_id: int,
request: AdminReplyRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:reply')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reply to a ticket as admin."""
@@ -447,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.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(message)
@@ -497,7 +497,7 @@ async def reply_to_ticket(
async def update_ticket_status(
ticket_id: int,
request: AdminStatusUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:close')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket status."""
@@ -522,9 +522,9 @@ async def update_ticket_status(
)
ticket.status = request.status
ticket.updated_at = datetime.utcnow()
ticket.updated_at = datetime.now(UTC)
if request.status == 'closed':
ticket.closed_at = datetime.utcnow()
ticket.closed_at = datetime.now(UTC)
else:
ticket.closed_at = None
@@ -556,7 +556,7 @@ async def update_ticket_status(
async def update_ticket_priority(
ticket_id: int,
request: AdminPriorityUpdateRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:close')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket priority."""
@@ -581,7 +581,7 @@ async def update_ticket_priority(
)
ticket.priority = request.priority
ticket.updated_at = datetime.utcnow()
ticket.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(ticket)
+12 -6
View File
@@ -20,7 +20,7 @@ from app.config import settings
from app.database.models import Subscription, Transaction, TransactionType, User
from app.services.remnawave_service import RemnaWaveService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.traffic import (
ExportCsvRequest,
ExportCsvResponse,
@@ -99,7 +99,13 @@ async def _aggregate_traffic(
user_uuids_set = set(user_uuids)
async with service.get_api_client() as api:
nodes = await api.get_all_nodes()
try:
nodes = await api.get_all_nodes()
except Exception:
logger.warning('Failed to fetch nodes for traffic aggregation', exc_info=True)
# Cache empty result to avoid hammering the failing API
_traffic_cache[cache_key] = (now, {}, [])
return {}, []
# Fetch per-node user stats — O(nodes) calls instead of O(users)
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
@@ -256,7 +262,7 @@ def _build_traffic_items(
@router.get('', response_model=TrafficUsageResponse)
async def get_traffic_usage(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('traffic:read')),
db: AsyncSession = Depends(get_cabinet_db),
period: int = Query(30, ge=1, le=30),
limit: int = Query(50, ge=1, le=200),
@@ -383,7 +389,7 @@ async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int,
if not user_ids:
return {}
result = await db.execute(
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
.where(
and_(
Transaction.user_id.in_(user_ids),
@@ -491,7 +497,7 @@ async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
async def get_traffic_enrichment(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('traffic:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return enrichment data: device counts, spending, dates, last node."""
@@ -524,7 +530,7 @@ async def get_traffic_enrichment(
@router.post('/export-csv', response_model=ExportCsvResponse)
async def export_traffic_csv(
request: ExportCsvRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('traffic:export')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Generate CSV with traffic usage and send to admin's Telegram DM."""
+5 -5
View File
@@ -1,6 +1,6 @@
"""Admin routes for version and release information."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import aiohttp
import structlog
@@ -10,7 +10,7 @@ from pydantic import BaseModel
from app.database.models import User
from app.services.version_service import version_service
from ..dependencies import get_current_admin_user
from ..dependencies import require_permission
logger = structlog.get_logger(__name__)
@@ -53,7 +53,7 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
global _cabinet_last_check
if not force and _cabinet_cache.get('releases') and _cabinet_last_check:
if datetime.now() - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
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'
@@ -75,7 +75,7 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
}
)
_cabinet_cache['releases'] = releases
_cabinet_last_check = datetime.now()
_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)
@@ -93,7 +93,7 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
@router.get('/releases', response_model=ReleasesResponse)
async def get_releases(
current_user: User = Depends(get_current_admin_user),
current_user: User = Depends(require_permission('updates:read')),
) -> ReleasesResponse:
"""Get release information for bot and cabinet."""
# Bot releases
+223 -118
View File
@@ -1,6 +1,6 @@
"""Admin routes for managing users in cabinet."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -26,6 +26,7 @@ from app.database.crud.user import (
)
from app.database.models import (
PromoGroup,
ReferralEarning,
Subscription,
SubscriptionServer,
SubscriptionStatus,
@@ -35,9 +36,9 @@ from app.database.models import (
User,
UserStatus,
)
from app.utils.timezone import panel_datetime_to_naive_utc
from app.utils.timezone import panel_datetime_to_utc
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
from ..schemas.users import (
DeleteDeviceResponse,
DeleteUserRequest,
@@ -144,9 +145,9 @@ def _build_subscription_info(subscription: Subscription, tariff_name: str | None
is_active = False
if subscription.end_date:
delta = subscription.end_date - datetime.utcnow()
delta = subscription.end_date - datetime.now(UTC)
days_remaining = max(0, delta.days)
is_active = subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > datetime.utcnow()
is_active = subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > datetime.now(UTC)
return UserSubscriptionInfo(
id=subscription.id,
@@ -174,7 +175,7 @@ async def _build_subscription_info_async(db: AsyncSession, subscription: Subscri
tariff_name = tariff.name
# Fetch traffic purchases
now = datetime.utcnow()
now = datetime.now(UTC)
tp_query = (
select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == subscription.id)
@@ -205,10 +206,17 @@ async def _build_subscription_info_async(db: AsyncSession, subscription: Subscri
return info
async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription: Subscription) -> dict:
async def _sync_subscription_to_panel(
db: AsyncSession,
user: User,
subscription: Subscription,
reset_traffic: bool = False,
reset_traffic_reason: str | None = None,
) -> dict:
"""
Sync user subscription to Remnawave panel.
Creates user if not exists, updates if exists.
Optionally resets traffic after sync.
Returns dict with changes/errors.
"""
try:
@@ -225,13 +233,13 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
is_active = (
subscription.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
and subscription.end_date
and subscription.end_date > datetime.utcnow()
and subscription.end_date > datetime.now(UTC)
)
panel_status = PanelUserStatus.ACTIVE if is_active else PanelUserStatus.DISABLED
expire_at = subscription.end_date
if expire_at and expire_at <= datetime.utcnow():
expire_at = datetime.utcnow() + timedelta(minutes=1)
if expire_at and expire_at <= datetime.now(UTC):
expire_at = datetime.now(UTC) + timedelta(minutes=1)
username = settings.format_remnawave_username(
full_name=user.full_name,
@@ -252,6 +260,13 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
traffic_limit_bytes = subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0
# Загружаем tariff для определения внешнего сквада
try:
await db.refresh(subscription, ['tariff'])
except Exception:
pass
ext_squad_uuid = subscription.tariff.external_squad_uuid if subscription.tariff else None
changes = {}
async with service.get_api_client() as api:
panel_uuid = user.remnawave_uuid
@@ -296,8 +311,17 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
# Внешний сквад: синхронизируем из тарифа или сбрасываем
if ext_squad_uuid is not None:
update_kwargs['external_squad_uuid'] = ext_squad_uuid
else:
update_kwargs['external_squad_uuid'] = None
try:
await api.update_user(**update_kwargs)
updated_panel_user = await api.update_user(**update_kwargs)
subscription.subscription_url = updated_panel_user.subscription_url
subscription.subscription_crypto_link = updated_panel_user.happ_crypto_link
subscription.remnawave_short_uuid = updated_panel_user.short_uuid
changes['action'] = 'updated'
logger.info('Updated user in Remnawave panel', user_id=user.id)
except Exception as update_error:
@@ -310,7 +334,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
# Create new user
create_kwargs = {
'username': username,
'expire_at': expire_at or (datetime.utcnow() + timedelta(days=30)),
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
'status': panel_status,
'traffic_limit_bytes': traffic_limit_bytes,
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
@@ -321,16 +345,29 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
}
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
if ext_squad_uuid is not None:
create_kwargs['external_squad_uuid'] = ext_squad_uuid
new_panel_user = await api.create_user(**create_kwargs)
user.remnawave_uuid = new_panel_user.uuid
subscription.remnawave_short_uuid = new_panel_user.short_uuid
subscription.subscription_url = new_panel_user.subscription_url
subscription.subscription_crypto_link = new_panel_user.happ_crypto_link
changes['action'] = 'created'
changes['panel_uuid'] = new_panel_user.uuid
logger.info('Created user in Remnawave panel', user_id=user.id, uuid=new_panel_user.uuid)
user.last_remnawave_sync = datetime.utcnow()
# Reset traffic on panel if requested
if reset_traffic and user.remnawave_uuid:
try:
await api.reset_user_traffic(user.remnawave_uuid)
changes['traffic_reset'] = True
reason_text = f' ({reset_traffic_reason})' if reset_traffic_reason else ''
logger.info('Reset RemnaWave traffic for user', user_id=user.id, reason=reason_text)
except Exception as reset_exc:
logger.warning('Failed to reset RemnaWave traffic', user_id=user.id, error=reset_exc)
user.last_remnawave_sync = datetime.now(UTC)
await db.commit()
return changes
@@ -351,7 +388,7 @@ async def list_users(
email: str | None = Query(None, max_length=255),
status: UserStatusEnum | None = Query(None),
sort_by: SortByEnum = Query(SortByEnum.CREATED_AT),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -408,7 +445,7 @@ async def list_users(
@router.get('/stats', response_model=UsersStatsResponse)
async def get_users_stats(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get overall users statistics."""
@@ -421,7 +458,7 @@ async def get_users_stats(
func.cast(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > datetime.utcnow(),
Subscription.end_date > datetime.now(UTC),
),
Integer,
)
@@ -431,7 +468,7 @@ async def get_users_stats(
func.cast(
or_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= datetime.utcnow(),
Subscription.end_date <= datetime.now(UTC),
),
Integer,
)
@@ -456,7 +493,7 @@ async def get_users_stats(
avg_balance = int(balance_row.avg or 0) if balance_row else 0
# Get activity stats
now = datetime.utcnow()
now = datetime.now(UTC)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
@@ -509,7 +546,7 @@ async def get_users_stats(
@router.get('/{user_id}', response_model=UserDetailResponse)
async def get_user_detail(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed user information by ID."""
@@ -542,11 +579,9 @@ async def get_user_detail(
referrals = await get_referrals(db, user.id)
referrals_count = len(referrals)
# Calculate total referral earnings
referral_earnings_q = select(func.sum(Transaction.amount_kopeks)).where(
Transaction.user_id == user.id,
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.is_completed == True,
# Calculate total referral earnings (canonical source: ReferralEarning)
referral_earnings_q = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == user.id
)
referral_earnings = (await db.execute(referral_earnings_q)).scalar() or 0
@@ -575,12 +610,14 @@ async def get_user_detail(
transactions_result = await db.execute(transactions_q)
transactions = transactions_result.scalars().all()
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
recent_transactions = [
UserTransactionItem(
id=t.id,
type=t.type,
amount_kopeks=t.amount_kopeks,
amount_rubles=t.amount_kopeks / 100,
amount_kopeks=abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
@@ -619,7 +656,7 @@ async def get_user_detail(
referral=referral_info,
total_spent_kopeks=user_stats.get('total_spent', 0),
purchase_count=user_stats.get('purchase_count', 0),
used_promocodes=user.used_promocodes,
used_promocodes=user.used_promocodes or 0,
has_had_paid_subscription=user.has_had_paid_subscription,
lifetime_used_traffic_bytes=user.lifetime_used_traffic_bytes or 0,
campaign_name=campaign_name,
@@ -638,7 +675,7 @@ async def get_user_detail(
@router.get('/by-telegram/{telegram_id}', response_model=UserDetailResponse)
async def get_user_by_telegram(
telegram_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user by Telegram ID."""
@@ -657,7 +694,7 @@ async def get_user_by_telegram(
@router.get('/{user_id}/panel-info', response_model=UserPanelInfoResponse)
async def get_user_panel_info(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user panel info from Remnawave (config links, traffic, connection data)."""
@@ -735,7 +772,7 @@ async def get_user_panel_info(
@router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse)
async def get_user_node_usage(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user per-node traffic usage (always 30 days with daily breakdown)."""
@@ -756,7 +793,7 @@ async def get_user_node_usage(
if not service.is_configured:
return UserNodeUsageResponse(items=[])
end_date = datetime.utcnow()
end_date = datetime.now(UTC)
start_date = end_date - timedelta(days=30)
start_str = start_date.strftime('%Y-%m-%d')
end_str = end_date.strftime('%Y-%m-%d')
@@ -823,7 +860,7 @@ async def get_user_node_usage(
async def update_user_balance(
user_id: int,
request: UpdateBalanceRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:balance')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -900,7 +937,7 @@ async def update_user_balance(
async def update_user_subscription(
user_id: int,
request: UpdateSubscriptionRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:subscription')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1011,7 +1048,7 @@ async def update_user_subscription(
)
subscription.end_date = request.end_date
if request.end_date > datetime.utcnow():
if request.end_date > datetime.now(UTC):
subscription.status = SubscriptionStatus.ACTIVE.value
else:
subscription.status = SubscriptionStatus.EXPIRED.value
@@ -1050,11 +1087,33 @@ async def update_user_subscription(
# Set squads from tariff
if tariff.allowed_squads:
subscription.connected_squads = tariff.allowed_squads
# Сбрасываем докупленный трафик при смене тарифа
from sqlalchemy import delete as sql_delete
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
from app.config import settings
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
subscription.traffic_used_gb = 0.0
await db.commit()
await db.refresh(subscription)
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
# Синхронизируем с RemnaWave (discovery/create + сброс трафика по админ-настройке)
try:
await _sync_subscription_to_panel(
db,
user,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_traffic_reason='смена тарифа (cabinet admin)',
)
except Exception as e:
logger.error('Failed to sync tariff switch with RemnaWave', error=e)
logger.info('Admin changed tariff for user to', admin_id=admin.id, user_id=user_id, tariff_name=tariff.name)
@@ -1107,7 +1166,7 @@ async def update_user_subscription(
if request.action == 'cancel':
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.end_date = datetime.utcnow()
subscription.end_date = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
@@ -1124,9 +1183,9 @@ async def update_user_subscription(
if request.action == 'activate':
subscription.status = SubscriptionStatus.ACTIVE.value
if subscription.end_date and subscription.end_date <= datetime.utcnow():
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
# Extend by 30 days if expired
subscription.end_date = datetime.utcnow() + timedelta(days=30)
subscription.end_date = datetime.now(UTC) + timedelta(days=30)
await db.commit()
await db.refresh(subscription)
@@ -1148,10 +1207,13 @@ async def update_user_subscription(
detail='traffic_gb parameter is required for add_traffic action',
)
from app.database.crud.subscription import add_subscription_traffic
from app.database.crud.subscription import add_subscription_traffic, reactivate_subscription
await add_subscription_traffic(db, subscription, request.traffic_gb)
await db.commit()
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
await reactivate_subscription(db, subscription)
await db.refresh(subscription)
# Sync to Remnawave panel
@@ -1196,7 +1258,7 @@ async def update_user_subscription(
await db.delete(traffic_purchase)
# Recalculate traffic_reset_at from remaining active purchases
now = datetime.utcnow()
now = datetime.now(UTC)
remaining_query = select(TrafficPurchase).where(
TrafficPurchase.subscription_id == subscription.id,
TrafficPurchase.expires_at > now,
@@ -1267,7 +1329,7 @@ async def update_user_subscription(
async def get_user_available_tariffs(
user_id: int,
include_inactive: bool = Query(False, description='Include inactive tariffs'),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1365,7 +1427,7 @@ async def get_user_available_tariffs(
async def update_user_status(
user_id: int,
request: UpdateUserStatusRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user status (active, blocked, deleted)."""
@@ -1388,7 +1450,7 @@ async def update_user_status(
)
user.status = new_status
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
@@ -1410,7 +1472,7 @@ async def update_user_status(
async def block_user(
user_id: int,
reason: str | None = None,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Block a user (shortcut for status update)."""
@@ -1421,7 +1483,7 @@ async def block_user(
@router.post('/{user_id}/unblock', response_model=UpdateUserStatusResponse)
async def unblock_user(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unblock a user (shortcut for status update)."""
@@ -1436,7 +1498,7 @@ async def unblock_user(
async def update_user_restrictions(
user_id: int,
request: UpdateRestrictionsRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user restrictions (topup, subscription)."""
@@ -1456,7 +1518,7 @@ async def update_user_restrictions(
if request.restriction_reason is not None:
user.restriction_reason = request.restriction_reason
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
@@ -1484,7 +1546,7 @@ async def update_user_restrictions(
async def update_user_promo_group(
user_id: int,
request: UpdatePromoGroupRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:promo_group')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user promo group."""
@@ -1511,7 +1573,7 @@ async def update_user_promo_group(
promo_group_name = promo_group.name
user.promo_group_id = new_promo_group_id
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
@@ -1539,7 +1601,7 @@ async def update_user_promo_group(
async def update_user_referral_commission(
user_id: int,
request: UpdateReferralCommissionRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:referral')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user's individual referral commission percentage."""
@@ -1552,7 +1614,7 @@ async def update_user_referral_commission(
old_commission = user.referral_commission_percent
user.referral_commission_percent = request.commission_percent
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
logger.info(
@@ -1577,7 +1639,7 @@ async def update_user_referral_commission(
@router.get('/{user_id}/devices', response_model=UserDevicesResponse)
async def get_user_devices(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user devices from Remnawave panel."""
@@ -1631,7 +1693,7 @@ async def get_user_devices(
async def delete_user_device(
user_id: int,
hwid: str,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a single device for user."""
@@ -1662,7 +1724,7 @@ async def delete_user_device(
@router.delete('/{user_id}/devices', response_model=ResetDevicesResponse)
async def reset_user_devices(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset all devices for user."""
@@ -1710,7 +1772,7 @@ async def reset_user_devices(
async def delete_user(
user_id: int,
request: DeleteUserRequest = DeleteUserRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1748,7 +1810,7 @@ async def delete_user(
async def full_delete_user(
user_id: int,
request: FullDeleteUserRequest = FullDeleteUserRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:delete')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1771,15 +1833,18 @@ async def full_delete_user(
panel_error: str | None = None
deleted_from_panel = False
# Pre-fetch admin.id to avoid MissingGreenlet after transaction rollback
admin_id_val = admin.id
# UserService.delete_user_account handles both bot DB and Remnawave panel
user_service = UserService()
success = await user_service.delete_user_account(db, user_id, admin.id)
success = await user_service.delete_user_account(db, user_id, admin_id_val)
if success:
deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info('Admin fully deleted user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
logger.info('Admin fully deleted user', admin_id=admin_id_val, user_id=user_id, reason_text=reason_text)
return FullDeleteUserResponse(
success=success,
@@ -1794,7 +1859,7 @@ async def full_delete_user(
async def reset_user_trial(
user_id: int,
request: ResetTrialRequest = ResetTrialRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:subscription')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1816,28 +1881,37 @@ async def reset_user_trial(
# Delete subscription if exists
if user.subscription:
# Deactivate in Remnawave panel first
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
from app.database.crud.subscription import is_active_paid_subscription
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск удаления подписки и RemnaWave: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
else:
# Deactivate in Remnawave panel first
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
# Delete subscription from database
from sqlalchemy import delete
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
subscription_id = user.subscription.id
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
# Delete subscription from database
from sqlalchemy import delete
subscription_id = user.subscription.id
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
# Reset trial flag
user.has_used_trial = False
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
@@ -1856,7 +1930,7 @@ async def reset_user_trial(
async def reset_user_subscription(
user_id: int,
request: ResetSubscriptionRequest = ResetSubscriptionRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:subscription')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1886,6 +1960,21 @@ async def reset_user_subscription(
panel_deactivated=False,
)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск сброса подписки: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
return ResetSubscriptionResponse(
success=False,
message='Cannot reset active paid subscription. Subscription is still active and paid.',
subscription_deleted=False,
panel_deactivated=False,
)
# Deactivate in Remnawave panel if requested
if request.deactivate_in_panel and user.remnawave_uuid:
try:
@@ -1907,7 +1996,7 @@ async def reset_user_subscription(
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
@@ -1926,7 +2015,7 @@ async def reset_user_subscription(
async def disable_user(
user_id: int,
request: DisableUserRequest = DisableUserRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:block')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -1948,8 +2037,16 @@ async def disable_user(
panel_deactivated = False
panel_error: str | None = None
# Deactivate subscription in panel
if user.remnawave_uuid:
# Deactivate subscription in panel (skip if active paid subscription)
from app.database.crud.subscription import is_active_paid_subscription
if is_active_paid_subscription(user.subscription):
logger.info(
'⏭️ Пропуск отключения RemnaWave: у пользователя активная оплаченная подписка',
user_id=user_id,
remnawave_uuid=user.remnawave_uuid,
)
elif user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
@@ -1961,8 +2058,8 @@ async def disable_user(
panel_error = str(e)
logger.warning('Failed to disable Remnawave user', error=e)
# Deactivate subscription in bot database
if user.subscription:
# Deactivate subscription in bot database (skip if active paid subscription)
if user.subscription and not is_active_paid_subscription(user.subscription):
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, user.subscription)
@@ -1971,7 +2068,7 @@ async def disable_user(
# Block user account
user.status = UserStatus.BLOCKED.value
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(UTC)
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
@@ -1995,7 +2092,7 @@ async def get_user_referrals(
user_id: int,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of users referred by this user."""
@@ -2035,7 +2132,7 @@ async def get_user_transactions(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
transaction_type: str | None = Query(None),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user transactions."""
@@ -2062,12 +2159,14 @@ async def get_user_transactions(
result = await db.execute(query)
transactions = result.scalars().all()
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
items = [
UserTransactionItem(
id=t.id,
type=t.type,
amount_kopeks=t.amount_kopeks,
amount_rubles=t.amount_kopeks / 100,
amount_kopeks=abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
amount_rubles=abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
@@ -2090,7 +2189,7 @@ async def get_user_transactions(
@router.get('/{user_id}/sync/status', response_model=PanelSyncStatusResponse)
async def get_user_sync_status(
user_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:sync')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -2179,13 +2278,8 @@ async def get_user_sync_status(
differences.append(f'Status: bot={bot_sub_status}, panel={panel_status}')
if bot_sub_end_date and panel_expire_at:
# Convert both to naive UTC for comparison
# Bot dates are stored as naive UTC
bot_end_utc = (
bot_sub_end_date.replace(tzinfo=None) if bot_sub_end_date.tzinfo else bot_sub_end_date
)
# Panel returns local time with misleading +00:00 offset
panel_end_utc = panel_datetime_to_naive_utc(panel_expire_at)
bot_end_utc = bot_sub_end_date if bot_sub_end_date.tzinfo else bot_sub_end_date
panel_end_utc = panel_datetime_to_utc(panel_expire_at)
diff_seconds = abs((bot_end_utc - panel_end_utc).total_seconds())
# Allow for timezone offset (3 hours = MSK) and small sync delays
@@ -2252,7 +2346,7 @@ async def get_user_sync_status(
async def sync_user_from_panel(
user_id: int,
request: SyncFromPanelRequest = SyncFromPanelRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:sync')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -2319,7 +2413,7 @@ async def sync_user_from_panel(
short_uuid=panel_user.short_uuid,
username=panel_user.username,
status=panel_user.status.value if panel_user.status else None,
expire_at=panel_datetime_to_naive_utc(panel_user.expire_at) if panel_user.expire_at else None,
expire_at=panel_datetime_to_utc(panel_user.expire_at) if panel_user.expire_at else None,
traffic_limit_gb=panel_user.traffic_limit_bytes / (1024**3) if panel_user.traffic_limit_bytes else 0,
traffic_used_gb=panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes else 0,
device_limit=panel_user.hwid_device_limit or 1,
@@ -2338,13 +2432,10 @@ async def sync_user_from_panel(
# Update end date (normalize timezone)
if panel_user.expire_at:
# Panel returns local time with misleading +00:00 offset
panel_expire_utc = panel_datetime_to_naive_utc(panel_user.expire_at)
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
sub_end_naive = (
sub.end_date.replace(tzinfo=None) if sub.end_date and sub.end_date.tzinfo else sub.end_date
)
if sub_end_naive != panel_expire_utc:
sub_end_utc = sub.end_date if sub.end_date and sub.end_date.tzinfo else sub.end_date
if sub_end_utc != panel_expire_utc:
changes['end_date'] = {
'old': sub.end_date.isoformat() if sub.end_date else None,
'new': panel_expire_utc.isoformat(),
@@ -2353,7 +2444,7 @@ async def sync_user_from_panel(
# Update status
panel_status_str = panel_user.status.value if panel_user.status else 'DISABLED'
now = datetime.utcnow()
now = datetime.now(UTC)
# Compare with normalized panel expire date
panel_expire_for_check = panel_expire_utc if panel_user.expire_at else None
if panel_status_str == 'ACTIVE' and panel_expire_for_check and panel_expire_for_check > now:
@@ -2410,9 +2501,8 @@ async def sync_user_from_panel(
panel_traffic_limit = (
int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100
)
# Panel returns local time with misleading +00:00 offset
panel_expire_naive = panel_datetime_to_naive_utc(panel_user.expire_at)
days_remaining = max(1, (panel_expire_naive - datetime.utcnow()).days)
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
days_remaining = max(1, (panel_expire_utc - datetime.now(UTC)).days)
new_sub = await create_paid_subscription(
db=db,
@@ -2427,8 +2517,8 @@ async def sync_user_from_panel(
changes['subscription_created'] = True
# Update last sync time
user.last_remnawave_sync = datetime.utcnow()
user.updated_at = datetime.utcnow()
user.last_remnawave_sync = datetime.now(UTC)
user.updated_at = datetime.now(UTC)
await db.commit()
@@ -2458,7 +2548,7 @@ async def sync_user_from_panel(
async def sync_user_to_panel(
user_id: int,
request: SyncToPanelRequest = SyncToPanelRequest(),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('users:sync')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -2502,14 +2592,14 @@ async def sync_user_to_panel(
is_active = (
sub.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
and sub.end_date
and sub.end_date > datetime.utcnow()
and sub.end_date > datetime.now(UTC)
)
panel_status = PanelUserStatus.ACTIVE if is_active else PanelUserStatus.DISABLED
# Ensure expire_at is in future for panel
expire_at = sub.end_date
if expire_at and expire_at <= datetime.utcnow():
expire_at = datetime.utcnow() + timedelta(minutes=1)
if expire_at and expire_at <= datetime.now(UTC):
expire_at = datetime.now(UTC) + timedelta(minutes=1)
username = settings.format_remnawave_username(
full_name=user.full_name,
@@ -2530,6 +2620,13 @@ async def sync_user_to_panel(
hwid_limit = resolve_hwid_device_limit_for_payload(sub)
traffic_limit_bytes = sub.traffic_limit_gb * (1024**3) if sub.traffic_limit_gb > 0 else 0
# Загружаем tariff для внешнего сквада
try:
await db.refresh(sub, ['tariff'])
except Exception:
pass
ext_squad_uuid = sub.tariff.external_squad_uuid if sub.tariff else None
async with service.get_api_client() as api:
# Validate existing UUID
if panel_uuid:
@@ -2581,6 +2678,12 @@ async def sync_user_to_panel(
update_kwargs['hwid_device_limit'] = hwid_limit
changes['device_limit'] = hwid_limit
# Внешний сквад: синхронизируем из тарифа или сбрасываем
if ext_squad_uuid is not None:
update_kwargs['external_squad_uuid'] = ext_squad_uuid
else:
update_kwargs['external_squad_uuid'] = None
try:
await api.update_user(**update_kwargs)
action = 'updated'
@@ -2595,7 +2698,7 @@ async def sync_user_to_panel(
# Create new user in panel
create_kwargs = {
'username': username,
'expire_at': expire_at or (datetime.utcnow() + timedelta(days=30)),
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
'status': panel_status,
'traffic_limit_bytes': traffic_limit_bytes,
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
@@ -2607,6 +2710,8 @@ async def sync_user_to_panel(
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
if ext_squad_uuid is not None:
create_kwargs['external_squad_uuid'] = ext_squad_uuid
new_panel_user = await api.create_user(**create_kwargs)
panel_uuid = new_panel_user.uuid
@@ -2620,8 +2725,8 @@ async def sync_user_to_panel(
action = 'created'
# Update last sync time
user.last_remnawave_sync = datetime.utcnow()
user.updated_at = datetime.utcnow()
user.last_remnawave_sync = datetime.now(UTC)
user.updated_at = datetime.now(UTC)
await db.commit()
+10 -10
View File
@@ -9,7 +9,7 @@ import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.cabinet.dependencies import get_cabinet_db, get_current_admin_user
from app.cabinet.dependencies import get_cabinet_db, require_permission
from app.cabinet.schemas.wheel import (
AdminSpinItem,
AdminSpinsResponse,
@@ -42,7 +42,7 @@ router = APIRouter(prefix='/admin/wheel', tags=['Admin Fortune Wheel'])
@router.get('/config', response_model=AdminWheelConfigResponse)
async def get_admin_wheel_config(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить полную конфигурацию колеса."""
@@ -93,7 +93,7 @@ async def get_admin_wheel_config(
@router.put('/config', response_model=AdminWheelConfigResponse)
async def update_admin_wheel_config(
request: UpdateWheelConfigRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Обновить конфигурацию колеса."""
@@ -155,7 +155,7 @@ async def update_admin_wheel_config(
@router.get('/prizes', response_model=list[WheelPrizeAdminResponse])
async def get_prizes(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить список призов."""
@@ -188,7 +188,7 @@ async def get_prizes(
@router.post('/prizes', response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED)
async def create_prize(
request: CreatePrizeRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Создать новый приз."""
@@ -237,7 +237,7 @@ async def create_prize(
async def update_prize(
prize_id: int,
request: UpdatePrizeRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Обновить приз."""
@@ -286,7 +286,7 @@ async def update_prize(
@router.delete('/prizes/{prize_id}', status_code=status.HTTP_204_NO_CONTENT)
async def delete_prize_endpoint(
prize_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Удалить приз."""
@@ -304,7 +304,7 @@ async def delete_prize_endpoint(
@router.post('/prizes/reorder', status_code=status.HTTP_200_OK)
async def reorder_prizes(
request: ReorderPrizesRequest,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Переупорядочить призы."""
@@ -317,7 +317,7 @@ async def reorder_prizes(
async def get_statistics(
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить статистику колеса."""
@@ -344,7 +344,7 @@ async def get_all_spins_endpoint(
date_to: datetime | None = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('wheel:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить все спины с фильтрами."""
+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, require_permission
from ..schemas.withdrawals import (
AdminApproveWithdrawalRequest,
AdminRejectWithdrawalRequest,
AdminWithdrawalDetailResponse,
AdminWithdrawalItem,
AdminWithdrawalListResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/withdrawals', tags=['Cabinet Admin Withdrawals'])
def _get_risk_level(risk_score: int) -> str:
"""Get risk level from score."""
if risk_score >= 70:
return 'critical'
if risk_score >= 50:
return 'high'
if risk_score >= 30:
return 'medium'
return 'low'
@router.get('', response_model=AdminWithdrawalListResponse)
async def list_withdrawals(
withdrawal_status: Literal['pending', 'approved', 'rejected', 'completed', 'cancelled'] | None = Query(
None, alias='status'
),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(require_permission('withdrawals:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all withdrawal requests."""
query = select(WithdrawalRequest)
count_query = select(func.count()).select_from(WithdrawalRequest)
if withdrawal_status:
query = query.where(WithdrawalRequest.status == withdrawal_status)
count_query = count_query.where(WithdrawalRequest.status == withdrawal_status)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Pending stats
pending_count_result = await db.execute(
select(func.count())
.select_from(WithdrawalRequest)
.where(WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value)
)
pending_count = pending_count_result.scalar() or 0
pending_total_result = await db.execute(
select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value
)
)
pending_total = pending_total_result.scalar() or 0
query = query.order_by(desc(WithdrawalRequest.created_at)).offset(offset).limit(limit)
result = await db.execute(query)
withdrawals = result.scalars().all()
# Batch-fetch users to avoid N+1
user_ids = list({w.user_id for w in withdrawals})
if user_ids:
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
else:
users_map = {}
items = []
for w in withdrawals:
user = users_map.get(w.user_id)
items.append(
AdminWithdrawalItem(
id=w.id,
user_id=w.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
amount_kopeks=w.amount_kopeks,
amount_rubles=w.amount_kopeks / 100,
status=w.status,
risk_score=w.risk_score or 0,
risk_level=_get_risk_level(w.risk_score or 0),
payment_details=w.payment_details,
admin_comment=w.admin_comment,
created_at=w.created_at,
processed_at=w.processed_at,
)
)
return AdminWithdrawalListResponse(
items=items,
total=total,
pending_count=pending_count,
pending_total_kopeks=pending_total,
)
@router.get('/{withdrawal_id}', response_model=AdminWithdrawalDetailResponse)
async def get_withdrawal_detail(
withdrawal_id: int,
admin: User = Depends(require_permission('withdrawals:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed withdrawal request with risk analysis."""
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Заявка не найдена',
)
user = await db.get(User, withdrawal.user_id)
# Parse risk analysis
risk_analysis = None
if withdrawal.risk_analysis:
try:
risk_analysis = json.loads(withdrawal.risk_analysis)
except (json.JSONDecodeError, TypeError):
pass
# Get referral stats
referral_count = await db.execute(
select(func.count()).select_from(User).where(User.referred_by_id == withdrawal.user_id)
)
total_earnings = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == withdrawal.user_id
)
)
return AdminWithdrawalDetailResponse(
id=withdrawal.id,
user_id=withdrawal.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
amount_kopeks=withdrawal.amount_kopeks,
amount_rubles=withdrawal.amount_kopeks / 100,
status=withdrawal.status,
risk_score=withdrawal.risk_score or 0,
risk_level=_get_risk_level(withdrawal.risk_score or 0),
risk_analysis=risk_analysis,
payment_details=withdrawal.payment_details,
admin_comment=withdrawal.admin_comment,
balance_kopeks=user.balance_kopeks if user else 0,
total_referrals=referral_count.scalar() or 0,
total_earnings_kopeks=total_earnings.scalar() or 0,
created_at=withdrawal.created_at,
processed_at=withdrawal.processed_at,
)
@router.post('/{withdrawal_id}/approve')
async def approve_withdrawal(
withdrawal_id: int,
request: AdminApproveWithdrawalRequest,
admin: User = Depends(require_permission('withdrawals:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a withdrawal request."""
success, error = await referral_withdrawal_service.approve_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about approval
try:
from 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(require_permission('withdrawals:reject')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a withdrawal request."""
success, error = await referral_withdrawal_service.reject_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error or 'Не удалось отклонить заявку',
)
# Notify user about rejection
try:
from 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(require_permission('withdrawals:approve')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark a withdrawal as completed (money transferred)."""
success, error = await referral_withdrawal_service.complete_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error or 'Не удалось завершить заявку',
)
return {'success': True}
+538 -82
View File
@@ -5,12 +5,18 @@ import hashlib
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.campaign import (
get_campaign_by_start_parameter,
get_campaign_registration_by_user,
)
from app.database.crud.rbac import UserRoleCRUD
from app.database.crud.system_setting import get_setting_value
from app.database.crud.user import (
clear_email_change_pending,
create_user,
@@ -23,9 +29,11 @@ from app.database.crud.user import (
verify_and_apply_email_change,
)
from app.database.models import CabinetRefreshToken, User
from app.services.campaign_service import AdvertisingCampaignService
from app.services.disposable_email_service import disposable_email_service
from app.services.referral_service import process_referral_registration
from app.utils.timezone import panel_datetime_to_naive_utc
from app.utils.cache import RateLimitCache, TokenReplayCache
from app.utils.timezone import panel_datetime_to_utc
from ..auth import (
create_access_token,
@@ -34,6 +42,7 @@ from ..auth import (
hash_password,
validate_telegram_init_data,
validate_telegram_login_widget,
validate_telegram_oidc_token,
verify_password,
)
from ..auth.email_verification import (
@@ -47,8 +56,11 @@ from ..auth.email_verification import (
)
from ..auth.jwt_handler import get_refresh_token_expires_at
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..ip_utils import get_client_ip
from ..schemas.auth import (
AuthResponse,
AutoLoginRequest,
CampaignBonusInfo,
EmailChangeRequest,
EmailChangeResponse,
EmailChangeVerifyRequest,
@@ -61,6 +73,7 @@ from ..schemas.auth import (
RefreshTokenRequest,
RegisterResponse,
TelegramAuthRequest,
TelegramOIDCAuthRequest,
TelegramWidgetAuthRequest,
TokenResponse,
UserResponse,
@@ -93,9 +106,17 @@ def _user_to_response(user: User) -> UserResponse:
)
def _create_auth_response(user: User) -> AuthResponse:
"""Create full auth response with tokens."""
access_token = create_access_token(user.id, user.telegram_id)
async def _create_auth_response(user: User, db: AsyncSession) -> AuthResponse:
"""Create full auth response with tokens and RBAC permissions."""
user_permissions, user_role_names, user_role_level = await UserRoleCRUD.get_user_permissions(db, user.id)
access_token = create_access_token(
user.id,
user.telegram_id,
permissions=user_permissions,
roles=user_role_names,
role_level=user_role_level,
)
refresh_token = create_refresh_token(user.id)
expires_in = settings.get_cabinet_access_token_expire_minutes() * 60
@@ -118,12 +139,6 @@ async def _store_refresh_token(
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
expires_at = get_refresh_token_expires_at()
# Check if token already exists (handles race conditions)
existing = await db.execute(select(CabinetRefreshToken).where(CabinetRefreshToken.token_hash == token_hash))
if existing.scalar_one_or_none():
# Token already stored, skip
return
token_record = CabinetRefreshToken(
user_id=user_id,
token_hash=token_hash,
@@ -133,9 +148,104 @@ async def _store_refresh_token(
db.add(token_record)
try:
await db.commit()
except Exception:
# Handle race condition if token was inserted between check and insert
except IntegrityError:
await db.rollback()
logger.debug('Refresh token already exists (duplicate)', user_id=user_id)
async def _process_campaign_bonus(
db: AsyncSession,
user: User,
campaign_slug: str | None,
) -> CampaignBonusInfo | None:
"""Process campaign bonus for user during auth. Never raises."""
if not campaign_slug:
return None
try:
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
if not campaign:
return None
# Skip if user IS the campaign partner — prevent self-referral
if campaign.partner_user_id and campaign.partner_user_id == user.id:
logger.debug(
'Skipping campaign attribution: user is the campaign partner',
user_id=user.id,
campaign_id=campaign.id,
)
return None
# Lock user row to prevent concurrent bonus application (race condition)
await db.execute(select(User).where(User.id == user.id).with_for_update())
existing = await get_campaign_registration_by_user(db, user.id)
if existing:
logger.debug('User already has campaign registration', user_id=user.id)
return None
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
if campaign.partner_user_id and not user.referred_by_id:
user.referred_by_id = campaign.partner_user_id
await db.flush()
try:
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=None)
logger.info(
'Referral set from campaign partner',
user_id=user.id,
partner_user_id=campaign.partner_user_id,
campaign_id=campaign.id,
)
except Exception as e:
logger.error('Failed to process referral from campaign partner', error=e)
service = AdvertisingCampaignService()
result = await service.apply_campaign_bonus(db, user, campaign)
if not result.success:
return None
# Refresh user to get updated balance after bonus
await db.refresh(user)
return CampaignBonusInfo(
campaign_name=campaign.name,
bonus_type=result.bonus_type or campaign.bonus_type,
balance_kopeks=result.balance_kopeks,
subscription_days=result.subscription_days,
tariff_name=result.tariff_name,
)
except Exception:
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
try:
await db.rollback()
# Re-fetch user so session stays usable for the caller
await db.refresh(user)
except Exception:
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
return None
async def _process_referral_code(
db: AsyncSession,
user: User,
referral_code: str | None,
) -> None:
"""Set referred_by_id for user if referral_code is valid. Never raises."""
if not referral_code or user.referred_by_id:
return
try:
referrer = await get_user_by_referral_code(db, referral_code)
if not referrer:
return
if referrer.id == user.id:
return
if referrer.email and user.email and referrer.email.lower() == user.email.lower():
return
user.referred_by_id = referrer.id
await db.flush()
await process_referral_registration(db, user.id, referrer.id, bot=None)
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
except Exception as e:
logger.error('Failed to process referral code', error=e, referral_code=referral_code)
async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -> None:
@@ -146,6 +256,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
if not user.email:
return
user_email = user.email # Save before try block — ORM access may fail after rollback
try:
from app.services.remnawave_service import RemnaWaveService
@@ -165,6 +277,19 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
panel_user = panel_users[0]
logger.info('Found subscription in panel for email', email=user.email, uuid=panel_user.uuid)
# Check if another user already owns this remnawave_uuid
from app.database.crud.user import get_user_by_remnawave_uuid
existing_owner = await get_user_by_remnawave_uuid(db, panel_user.uuid)
if existing_owner and existing_owner.id != user.id:
logger.warning(
'Panel UUID already belongs to another user, skipping sync',
email=user.email,
panel_uuid=panel_user.uuid,
existing_owner_id=existing_owner.id,
)
return
# Link user to panel
user.remnawave_uuid = panel_user.uuid
@@ -175,7 +300,7 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
existing_sub = await get_subscription_by_user_id(db, user.id)
# Parse panel data — panel returns local time with misleading +00:00 offset
expire_at = panel_datetime_to_naive_utc(panel_user.expire_at)
expire_at = panel_datetime_to_utc(panel_user.expire_at)
traffic_limit_gb = panel_user.traffic_limit_bytes // (1024**3) if panel_user.traffic_limit_bytes > 0 else 0
traffic_used_gb = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes > 0 else 0
@@ -183,10 +308,10 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
connected_squads = [s.get('uuid', '') for s in (panel_user.active_internal_squads or []) if s.get('uuid')]
# Device limit from panel
device_limit = panel_user.hwid_device_limit or 1
device_limit = panel_user.hwid_device_limit or 0
# Determine status — expire_at is now naive UTC
current_time = datetime.now(UTC).replace(tzinfo=None)
current_time = datetime.now(UTC)
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
sub_status = SubscriptionStatus.ACTIVE
@@ -240,14 +365,16 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
await db.commit()
except Exception as e:
logger.warning('Failed to sync subscription from panel for', email=user.email, error=e)
# Don't rollback - it detaches user object and breaks subsequent operations
# The sync is non-critical, main verification already succeeded
logger.warning('Failed to sync subscription from panel for', email=user_email, error=e)
await db.rollback()
# Refresh user after rollback — object is expired and lazy loads fail in async
await db.refresh(user)
@router.post('/telegram', response_model=AuthResponse)
async def auth_telegram(
request: TelegramAuthRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -256,6 +383,13 @@ async def auth_telegram(
This endpoint validates the initData from Telegram WebApp and returns
JWT tokens for authenticated access.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'telegram_initdata', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
user_data = validate_telegram_init_data(request.init_data)
if not user_data:
@@ -279,6 +413,16 @@ async def auth_telegram(
tg_last_name = user_data.get('last_name')
tg_language = user_data.get('language_code', 'ru')
# Resolve referral code to referrer ID for new users
referrer_id = None
if request.referral_code and not user:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
if not user:
# Create new user from Telegram initData
logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
@@ -289,6 +433,7 @@ async def auth_telegram(
first_name=tg_first_name,
last_name=tg_last_name,
language=tg_language,
referred_by_id=referrer_id,
)
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
else:
@@ -313,20 +458,29 @@ async def auth_telegram(
)
# Update last login
user.cabinet_last_login = datetime.utcnow()
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = _create_auth_response(user)
response = await _create_auth_response(user, db)
# Store refresh token
await _store_refresh_token(db, user.id, response.refresh_token)
# Process referral code (before campaign bonus, which may also set referrer)
await _process_referral_code(db, user, request.referral_code)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
return response
@router.post('/telegram/widget', response_model=AuthResponse)
async def auth_telegram_widget(
request: TelegramWidgetAuthRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -335,7 +489,16 @@ async def auth_telegram_widget(
This endpoint validates data from Telegram Login Widget and returns
JWT tokens for authenticated access.
"""
widget_data = request.model_dump()
# Rate limit
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'telegram_widget', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
widget_data = request.model_dump(exclude={'campaign_slug', 'referral_code'})
if not validate_telegram_login_widget(widget_data):
raise HTTPException(
@@ -345,6 +508,16 @@ async def auth_telegram_widget(
user = await get_user_by_telegram_id(db, request.id)
# Resolve referral code to referrer ID for new users
referrer_id = None
if request.referral_code and not user:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
except Exception as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
if not user:
# Create new user from Telegram data
logger.info(
@@ -357,6 +530,7 @@ async def auth_telegram_widget(
first_name=request.first_name,
last_name=request.last_name,
language='ru',
referred_by_id=referrer_id,
)
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
@@ -374,12 +548,147 @@ async def auth_telegram_widget(
if request.last_name != user.last_name:
user.last_name = request.last_name
user.cabinet_last_login = datetime.utcnow()
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = _create_auth_response(user)
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process referral code (before campaign bonus, which may also set referrer)
await _process_referral_code(db, user, request.referral_code)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
return response
@router.post('/telegram/oidc', response_model=AuthResponse)
async def auth_telegram_oidc(
request: TelegramOIDCAuthRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Authenticate using Telegram OIDC id_token (popup flow).
The frontend uses Telegram.Login.init() popup which returns an id_token.
We validate it via JWKS and create/login the user.
"""
# Rate limit
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'telegram_oidc', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
# Check OIDC enabled from DB first, fallback to env
oidc_enabled_val = await get_setting_value(db, 'TELEGRAM_OIDC_ENABLED')
oidc_client_id_val = await get_setting_value(db, 'TELEGRAM_OIDC_CLIENT_ID')
oidc_client_id = oidc_client_id_val or settings.TELEGRAM_OIDC_CLIENT_ID
oidc_enabled = (
oidc_enabled_val.lower() == 'true' if oidc_enabled_val is not None else settings.TELEGRAM_OIDC_ENABLED
) and bool(oidc_client_id)
if not oidc_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Telegram OIDC is not configured',
)
claims = await validate_telegram_oidc_token(
request.id_token,
oidc_client_id,
)
if not claims:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired Telegram OIDC token',
)
# Replay detection: reject if this exact token was already used
token_hash = hashlib.sha256(request.id_token.encode()).hexdigest()
token_ttl = max(int(claims.get('exp', 0) - datetime.now(UTC).timestamp()), 60)
if await TokenReplayCache.is_token_replayed(token_hash, ttl=min(token_ttl, 600)):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired Telegram OIDC token',
)
# Extract user info from OIDC claims
try:
telegram_id = int(claims.get('id', claims.get('sub', 0)))
except (ValueError, TypeError) as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid user ID in OIDC claims',
) from e
if not telegram_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Missing user ID in OIDC claims',
)
first_name = claims.get('name', claims.get('given_name', ''))
username = claims.get('preferred_username')
last_name = claims.get('family_name')
language = claims.get('locale', 'ru')[:2] if claims.get('locale') else 'ru'
user = await get_user_by_telegram_id(db, telegram_id)
# Resolve referral code for new users
referrer_id = None
if request.referral_code and not user:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
referrer_id = referrer.id
except (ValueError, LookupError) as e:
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=str(e))
if not user:
logger.info('Creating new user from cabinet OIDC', telegram_id=telegram_id, username=username)
user = await create_user(
db=db,
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
language=language,
referred_by_id=referrer_id,
)
logger.info('User created successfully', user_id=user.id, telegram_id=user.telegram_id)
if user.status != 'active':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User account is not active',
)
# Update user info from OIDC claims
if username and username != user.username:
user.username = username
if first_name and first_name != user.first_name:
user.first_name = first_name
if last_name is not None and last_name != user.last_name:
user.last_name = last_name
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
await _process_referral_code(db, user, request.referral_code)
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
return response
@@ -417,53 +726,61 @@ async def register_email(
detail='You already have a verified email',
)
# Generate verification token
verification_token = generate_verification_token()
verification_expires = get_verification_expires_at()
# Update user
user.email = request.email
user.email_verified = False
user.password_hash = hash_password(request.password)
user.email_verification_token = verification_token
user.email_verification_expires = verification_expires
await db.commit()
if not settings.is_cabinet_email_verification_enabled():
# Верификация отключена — сразу помечаем email как verified
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
await db.commit()
else:
# Generate verification token
verification_token = generate_verification_token()
verification_expires = get_verification_expires_at()
# Send verification email asynchronously (smtplib is blocking)
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
cabinet_url = settings.CABINET_URL
verification_url = f'{cabinet_url}/verify-email'
lang = user.language or 'ru'
full_url = f'{verification_url}?token={verification_token}'
expire_hours = settings.get_cabinet_email_verification_expire_hours()
user.email_verified = False
user.email_verification_token = verification_token
user.email_verification_expires = verification_expires
await db.commit()
# Check for admin template override
override = await get_rendered_override(
'email_verification',
lang,
context={
'username': user.first_name or '',
'verification_url': full_url,
'expire_hours': str(expire_hours),
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
# Send verification email asynchronously (smtplib is blocking)
if email_service.is_configured():
cabinet_url = settings.CABINET_URL
verification_url = f'{cabinet_url}/verify-email'
lang = user.language or 'ru'
full_url = f'{verification_url}?token={verification_token}'
expire_hours = settings.get_cabinet_email_verification_expire_hours()
await asyncio.to_thread(
email_service.send_verification_email,
to_email=request.email,
verification_token=verification_token,
verification_url=verification_url,
username=user.first_name,
language=lang,
custom_subject=custom_subject,
custom_body_html=custom_body,
)
# Check for admin template override
override = await get_rendered_override(
'email_verification',
lang,
context={
'username': user.first_name or '',
'verification_url': full_url,
'expire_hours': str(expire_hours),
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
await asyncio.to_thread(
email_service.send_verification_email,
to_email=request.email,
verification_token=verification_token,
verification_url=verification_url,
username=user.first_name,
language=lang,
custom_subject=custom_subject,
custom_body_html=custom_body,
)
return {
'message': 'Verification email sent',
'message': 'Email linked successfully'
if not settings.is_cabinet_email_verification_enabled()
else 'Verification email sent',
'email': request.email,
}
@@ -471,6 +788,7 @@ async def register_email(
@router.post('/email/register/standalone', response_model=RegisterResponse)
async def register_email_standalone(
request: EmailRegisterStandaloneRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""
@@ -483,6 +801,13 @@ async def register_email_standalone(
If TEST_EMAIL is configured, test email accounts are auto-verified.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'email_register', limit=5, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
# Check if this is a test email registration
is_test_email = settings.is_test_email(request.email)
@@ -543,12 +868,12 @@ async def register_email_standalone(
referred_by_id=referrer.id if referrer else None,
)
# Для тестового email - автоматически верифицировать
if is_test_email:
# Для тестового email или отключённой верификации - автоматически верифицировать
if is_test_email or not settings.is_cabinet_email_verification_enabled():
user.email_verified = True
user.email_verified_at = datetime.utcnow()
user.email_verified_at = datetime.now(UTC)
await db.commit()
logger.info('Test email auto-verified: user_id', email=request.email, user_id=user.id)
logger.info('Email auto-verified (test or verification disabled)', email=request.email, user_id=user.id)
else:
# Сгенерировать токен верификации
verification_token = generate_verification_token()
@@ -601,20 +926,29 @@ async def register_email_standalone(
# Не прерываем регистрацию из-за ошибки реферальной системы
# Для тестового email - сразу можно логиниться (уже verified)
# Для обычного email - требуется верификация
# Для обычного email - требуется верификация (если включена)
verification_required = not is_test_email and settings.is_cabinet_email_verification_enabled()
return RegisterResponse(
message='Verification email sent. Please check your inbox.',
email=request.email,
requires_verification=not is_test_email,
requires_verification=verification_required,
)
@router.post('/email/verify', response_model=AuthResponse)
async def verify_email(
request: EmailVerifyRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Verify email with token and return auth tokens."""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'email_verify', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
# Find user with this token
result = await db.execute(select(User).where(User.email_verification_token == request.token))
user = result.scalar_one_or_none()
@@ -633,10 +967,10 @@ async def verify_email(
# Mark email as verified
user.email_verified = True
user.email_verified_at = datetime.utcnow()
user.email_verified_at = datetime.now(UTC)
user.email_verification_token = None
user.email_verification_expires = None
user.cabinet_last_login = datetime.utcnow()
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
@@ -644,9 +978,14 @@ async def verify_email(
await _sync_subscription_from_panel_by_email(db, user)
# Return auth tokens so user is logged in after verification
response = _create_auth_response(user)
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
return response
@@ -724,12 +1063,21 @@ async def resend_verification(
@router.post('/email/login', response_model=AuthResponse)
async def login_email(
request: EmailLoginRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Login with email and password.
Test email accounts (configured via TEST_EMAIL) bypass email verification.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'email_login', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
# Check if this is a test email login
is_test_email = settings.is_test_email(request.email)
@@ -750,7 +1098,7 @@ async def login_email(
language='ru',
)
user.email_verified = True
user.email_verified_at = datetime.utcnow()
user.email_verified_at = datetime.now(UTC)
await db.commit()
else:
raise HTTPException(
@@ -770,8 +1118,8 @@ async def login_email(
detail='Invalid email or password',
)
# Test email bypasses verification check
if not user.email_verified and not is_test_email:
# Test email and disabled verification bypass the check
if not user.email_verified and not is_test_email and settings.is_cabinet_email_verification_enabled():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Please verify your email first',
@@ -783,12 +1131,17 @@ async def login_email(
detail='User account is not active',
)
user.cabinet_last_login = datetime.utcnow()
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = _create_auth_response(user)
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
return response
@@ -808,11 +1161,11 @@ async def refresh_token(
try:
user_id = int(payload.get('sub'))
except (TypeError, ValueError):
except (TypeError, ValueError) as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid token payload',
)
) from e
# Verify token exists in database and is not revoked
token_hash = hashlib.sha256(request.refresh_token.encode()).hexdigest()
@@ -844,7 +1197,14 @@ async def refresh_token(
detail='User not found or inactive',
)
access_token = create_access_token(user.id, user.telegram_id)
user_permissions, user_role_names, user_role_level = await UserRoleCRUD.get_user_permissions(db, user.id)
access_token = create_access_token(
user.id,
user.telegram_id,
permissions=user_permissions,
roles=user_role_names,
role_level=user_role_level,
)
expires_in = settings.get_cabinet_access_token_expire_minutes() * 60
return TokenResponse(
@@ -871,23 +1231,91 @@ async def logout(
token_record = result.scalar_one_or_none()
if token_record:
token_record.revoked_at = datetime.utcnow()
token_record.revoked_at = datetime.now(UTC)
await db.commit()
return {'message': 'Logged out successfully'}
@router.post('/login/auto', response_model=AuthResponse)
async def auto_login(
request: AutoLoginRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Auto-login using a short-lived JWT from guest purchase success page."""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'auto_login', limit=5, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
payload = get_token_payload(request.token, expected_type='auto_login')
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired auto-login token',
)
try:
user_id = int(payload['sub'])
except (KeyError, ValueError, TypeError) as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid token payload',
) from e
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not found',
)
if user.status != 'active':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is deactivated',
)
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
return response
@router.post('/password/forgot')
async def forgot_password(
request: PasswordForgotRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Request password reset."""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'password_forgot', limit=3, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
result = await db.execute(select(User).where(User.email == request.email))
user = result.scalar_one_or_none()
# Always return success to prevent email enumeration
if not user or not user.email_verified:
if not user:
return {'message': 'If the email exists, a password reset link has been sent'}
# Auto-fix guest-created email users who have a password but weren't verified
if not user.email_verified and user.password_hash and user.auth_type == 'email':
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
await db.commit()
if not user.email_verified:
return {'message': 'If the email exists, a password reset link has been sent'}
# Generate reset token
@@ -932,9 +1360,17 @@ async def forgot_password(
@router.post('/password/reset')
async def reset_password(
request: PasswordResetRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset password with token."""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'password_reset', limit=5, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
result = await db.execute(select(User).where(User.password_reset_token == request.token))
user = result.scalar_one_or_none()
@@ -968,12 +1404,32 @@ async def get_current_user(
return _user_to_response(user)
@router.get('/me/permissions')
async def get_my_permissions(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get current user's RBAC permissions, roles, and level."""
from app.services.permission_service import PermissionService
return await PermissionService.get_user_permissions(db, user.id, user=user)
@router.get('/me/is-admin')
async def check_is_admin(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Check if current user is an admin."""
"""Check if current user is an admin (legacy config or RBAC)."""
# Legacy check: config-based admin list
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
if not is_admin:
# RBAC check: user has any active role with level > 0
_permissions, _role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
if max_level > 0:
is_admin = True
return {'is_admin': is_admin}
+15 -2
View File
@@ -6,6 +6,9 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
import httpx
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 desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -314,6 +317,12 @@ async def create_topup(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create payment for balance top-up."""
if getattr(user, 'restriction_topup', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Balance top-up is restricted for this account',
)
# Validate payment method
methods = await get_payment_methods(user=user, db=db)
method = next((m for m in methods if m.id == request.payment_method), None)
@@ -1035,8 +1044,12 @@ async def check_payment_status(
old_is_paid = record.is_paid
# Run manual check
payment_service = PaymentService()
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
try:
payment_service = PaymentService(bot=bot)
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
finally:
await bot.session.close()
if not updated:
return ManualCheckResponse(
+236 -20
View File
@@ -3,18 +3,20 @@
import json
import os
from pathlib import Path
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.system_setting import get_setting_value
from app.database.models import SystemSetting, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..dependencies import get_cabinet_db, require_permission
logger = structlog.get_logger(__name__)
@@ -37,6 +39,23 @@ YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeri
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
ANIMATION_CONFIG_KEY = 'CABINET_ANIMATION_CONFIG' # Stores JSON with animation config
TELEGRAM_WIDGET_SIZE_KEY = 'TELEGRAM_WIDGET_SIZE'
TELEGRAM_WIDGET_RADIUS_KEY = 'TELEGRAM_WIDGET_RADIUS'
TELEGRAM_WIDGET_USERPIC_KEY = 'TELEGRAM_WIDGET_USERPIC'
TELEGRAM_WIDGET_REQUEST_ACCESS_KEY = 'TELEGRAM_WIDGET_REQUEST_ACCESS'
TELEGRAM_OIDC_ENABLED_KEY = 'TELEGRAM_OIDC_ENABLED'
TELEGRAM_OIDC_CLIENT_ID_KEY = 'TELEGRAM_OIDC_CLIENT_ID'
# Default animation config
DEFAULT_ANIMATION_CONFIG = {
'enabled': True,
'type': 'aurora',
'settings': {},
'opacity': 1.0,
'blur': 0,
'reducedOnMobile': True,
}
# Allowed image types
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
@@ -121,6 +140,92 @@ class AnimationEnabledUpdate(BaseModel):
enabled: bool
ALLOWED_BG_TYPES = (
'aurora',
'sparkles',
'vortex',
'shooting-stars',
'background-beams',
'background-beams-collision',
'gradient-animation',
'wavy',
'background-lines',
'boxes',
'meteors',
'grid',
'dots',
'spotlight',
'ripple',
'none',
)
MAX_SETTINGS_KEYS = 20
MAX_SETTINGS_VALUE_LEN = 200
def _validate_settings(v: dict) -> dict:
"""Validate settings dict: flat structure, bounded size, no nested objects."""
if len(v) > MAX_SETTINGS_KEYS:
raise ValueError(f'Settings must have at most {MAX_SETTINGS_KEYS} keys')
for key, val in v.items():
if not isinstance(key, str) or len(key) > 50:
raise ValueError('Setting keys must be strings under 50 characters')
if isinstance(val, dict | list):
raise ValueError('Nested objects/arrays not allowed in settings')
if isinstance(val, str) and len(val) > MAX_SETTINGS_VALUE_LEN:
raise ValueError(f'String setting values must be under {MAX_SETTINGS_VALUE_LEN} characters')
return v
class AnimationConfigResponse(BaseModel):
"""Full animation config."""
enabled: bool = True
type: str = 'aurora'
settings: dict = Field(default_factory=dict)
opacity: float = Field(default=1.0, ge=0.0, le=1.0)
blur: float = Field(default=0, ge=0, le=100)
reducedOnMobile: bool = True
class AnimationConfigUpdate(BaseModel):
"""Request to update animation config (partial update)."""
enabled: bool | None = None
type: (
Literal[
'aurora',
'sparkles',
'vortex',
'shooting-stars',
'background-beams',
'background-beams-collision',
'gradient-animation',
'wavy',
'background-lines',
'boxes',
'meteors',
'grid',
'dots',
'spotlight',
'ripple',
'none',
]
| None
) = None
settings: dict | None = None
opacity: float | None = Field(default=None, ge=0.0, le=1.0)
blur: float | None = Field(default=None, ge=0, le=100)
reducedOnMobile: bool | None = None
@field_validator('settings')
@classmethod
def validate_settings(cls, v: dict | None) -> dict | None:
if v is None:
return v
return _validate_settings(v)
class FullscreenEnabledResponse(BaseModel):
"""Fullscreen enabled setting."""
@@ -145,6 +250,20 @@ class EmailAuthEnabledUpdate(BaseModel):
enabled: bool
class TelegramWidgetConfigResponse(BaseModel):
"""Public Telegram Login Widget configuration."""
bot_username: str
size: Literal['large', 'medium', 'small'] = 'large'
radius: int = Field(default=8, ge=0, le=20)
userpic: bool = True
request_access: bool = True
# OIDC fields (frontend decides which flow to use)
oidc_enabled: bool = False
oidc_client_id: str = ''
class LiteModeEnabledResponse(BaseModel):
"""Lite mode enabled setting."""
@@ -198,13 +317,6 @@ def ensure_branding_dir():
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
async def get_setting_value(db: AsyncSession, key: str) -> str | None:
"""Get a setting value from database."""
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def set_setting_value(db: AsyncSession, key: str, value: str):
"""Set a setting value in database."""
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
@@ -296,7 +408,7 @@ async def get_logo():
@router.put('/name', response_model=BrandingResponse)
async def update_branding_name(
payload: BrandingNameUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update the project name. Admin only. Empty name allowed (logo only mode)."""
@@ -324,7 +436,7 @@ async def update_branding_name(
@router.post('/logo', response_model=BrandingResponse)
async def upload_logo(
file: UploadFile = File(...),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Upload a custom logo. Admin only."""
@@ -387,7 +499,7 @@ async def upload_logo(
@router.delete('/logo', response_model=BrandingResponse)
async def delete_logo(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete custom logo and revert to letter. Admin only."""
@@ -459,7 +571,7 @@ async def get_theme_colors(
@router.patch('/colors', response_model=ThemeColorsResponse)
async def update_theme_colors(
payload: ThemeColorsUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update theme colors. Admin only. Partial update supported."""
@@ -493,7 +605,7 @@ async def update_theme_colors(
@router.post('/colors/reset', response_model=ThemeColorsResponse)
async def reset_theme_colors(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset theme colors to defaults. Admin only."""
@@ -533,7 +645,7 @@ async def get_enabled_themes(
@router.patch('/themes', response_model=EnabledThemesResponse)
async def update_enabled_themes(
payload: EnabledThemesUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update which themes are enabled. Admin only. At least one theme must be enabled."""
@@ -587,7 +699,7 @@ async def get_animation_enabled(
@router.patch('/animation', response_model=AnimationEnabledResponse)
async def update_animation_enabled(
payload: AnimationEnabledUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update animation enabled setting. Admin only."""
@@ -598,6 +710,69 @@ async def update_animation_enabled(
return AnimationEnabledResponse(enabled=payload.enabled)
# ============ Animation Config Routes (new JSON-based) ============
@router.get('/animation-config', response_model=AnimationConfigResponse)
async def get_animation_config(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get full animation config. Public endpoint."""
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
if config_value is not None:
try:
config = json.loads(config_value)
return AnimationConfigResponse(**config)
except (json.JSONDecodeError, TypeError):
pass
# Auto-migrate from old ANIMATION_ENABLED_KEY
old_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
if old_value is not None:
config = {**DEFAULT_ANIMATION_CONFIG, 'enabled': old_value.lower() == 'true'}
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(config))
return AnimationConfigResponse(**config)
return AnimationConfigResponse(**DEFAULT_ANIMATION_CONFIG)
@router.patch('/animation-config', response_model=AnimationConfigResponse)
async def update_animation_config(
payload: AnimationConfigUpdate,
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update animation config (partial update). Admin only."""
# Get current config
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
if config_value:
try:
current = json.loads(config_value)
except (json.JSONDecodeError, TypeError):
current = dict(DEFAULT_ANIMATION_CONFIG)
else:
current = dict(DEFAULT_ANIMATION_CONFIG)
# Merge only provided fields
update_data = payload.model_dump(exclude_none=True)
current.update(update_data)
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(current))
# Also sync old key for backwards compat
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(current.get('enabled', True)).lower())
logger.info(
'Admin updated animation config',
telegram_id=admin.telegram_id,
type=current.get('type'),
enabled=current.get('enabled'),
)
return AnimationConfigResponse(**current)
# ============ Fullscreen Routes ============
@@ -622,7 +797,7 @@ async def get_fullscreen_enabled(
@router.patch('/fullscreen', response_model=FullscreenEnabledResponse)
async def update_fullscreen_enabled(
payload: FullscreenEnabledUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update fullscreen enabled setting. Admin only."""
@@ -658,7 +833,7 @@ async def get_email_auth_enabled(
@router.patch('/email-auth', response_model=EmailAuthEnabledResponse)
async def update_email_auth_enabled(
payload: EmailAuthEnabledUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update email auth enabled setting. Admin only."""
@@ -669,6 +844,47 @@ async def update_email_auth_enabled(
return EmailAuthEnabledResponse(enabled=payload.enabled)
# ============ Telegram Widget Config Routes ============
@router.get('/telegram-widget', response_model=TelegramWidgetConfigResponse)
async def get_telegram_widget_config(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get Telegram Login Widget configuration.
This is a public endpoint - no authentication required.
Returns widget display settings and bot username for the login page.
"""
bot_username = settings.BOT_USERNAME or ''
size_val = await get_setting_value(db, TELEGRAM_WIDGET_SIZE_KEY)
radius_val = await get_setting_value(db, TELEGRAM_WIDGET_RADIUS_KEY)
userpic_val = await get_setting_value(db, TELEGRAM_WIDGET_USERPIC_KEY)
request_access_val = await get_setting_value(db, TELEGRAM_WIDGET_REQUEST_ACCESS_KEY)
oidc_enabled_val = await get_setting_value(db, TELEGRAM_OIDC_ENABLED_KEY)
oidc_client_id_val = await get_setting_value(db, TELEGRAM_OIDC_CLIENT_ID_KEY)
oidc_client_id = oidc_client_id_val or settings.TELEGRAM_OIDC_CLIENT_ID
oidc_enabled = (
oidc_enabled_val.lower() == 'true' if oidc_enabled_val is not None else settings.TELEGRAM_OIDC_ENABLED
) and bool(oidc_client_id)
return TelegramWidgetConfigResponse(
bot_username=bot_username,
size=size_val if size_val in ('large', 'medium', 'small') else settings.TELEGRAM_WIDGET_SIZE,
radius=max(0, min(int(radius_val), 20))
if radius_val and radius_val.isdigit()
else settings.TELEGRAM_WIDGET_RADIUS,
userpic=userpic_val.lower() == 'true' if userpic_val is not None else settings.TELEGRAM_WIDGET_USERPIC,
request_access=request_access_val.lower() == 'true'
if request_access_val is not None
else settings.TELEGRAM_WIDGET_REQUEST_ACCESS,
oidc_enabled=oidc_enabled,
oidc_client_id=oidc_client_id if oidc_enabled else '',
)
# ============ Analytics Counters Routes ============
@@ -694,7 +910,7 @@ async def get_analytics_counters(
@router.patch('/analytics', response_model=AnalyticsCountersResponse)
async def update_analytics_counters(
payload: AnalyticsCountersUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update analytics counter settings. Admin only. Partial update supported."""
@@ -758,7 +974,7 @@ async def get_lite_mode_enabled(
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
async def update_lite_mode_enabled(
payload: LiteModeEnabledUpdate,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('settings:edit')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update lite mode enabled setting. Admin only."""
+3 -3
View File
@@ -1,7 +1,7 @@
"""Contests routes for cabinet - user participation in games/contests."""
import random
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
@@ -102,7 +102,7 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
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)
@@ -121,7 +121,7 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
if not user:
return 'Error: user not found'
user.balance += amount
user.balance_kopeks += int(round(amount * 100))
await db.commit()
await db.refresh(user)
+671
View File
@@ -0,0 +1,671 @@
"""Public landing page routes for guest quick-purchase flow."""
import re
from datetime import UTC, datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.cabinet.dependencies import get_cabinet_db
from app.cabinet.ip_utils import get_client_ip
from app.cabinet.utils.locale import DEFAULT_LOCALE, resolve_locale_text
from app.config import settings
from app.database.crud.landing import get_active_landing_by_slug, get_purchase_by_token
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage, Tariff
from app.services.guest_purchase_service import (
GuestPurchaseError,
activate_purchase as activate_guest_purchase,
create_purchase,
validate_and_calculate,
)
from app.services.payment_method_config_service import _get_method_defaults
from app.services.payment_service import PaymentService
from app.utils.cache import RateLimitCache
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/landing', tags=['Landing Pages'])
# ============ Schemas ============
class LandingFeature(BaseModel):
icon: str = ''
title: str = ''
description: str = ''
class LandingTariffPeriod(BaseModel):
days: int
label: str
price_kopeks: int
price_label: str
original_price_kopeks: int | None = None # set if discount active
original_price_label: str | None = None
discount_percent: int | None = None # effective discount for this tariff
class LandingTariff(BaseModel):
id: int
name: str
description: str | None = None
traffic_limit_gb: int
device_limit: int
tier_level: int
periods: list[LandingTariffPeriod]
class LandingPaymentMethodSubOption(BaseModel):
id: str
name: str
class LandingPaymentMethod(BaseModel):
method_id: str
display_name: str
description: str | None = None
icon_url: str | None = None
sort_order: int = 0
min_amount_kopeks: int | None = None
max_amount_kopeks: int | None = None
currency: str | None = None
# Enabled sub-options with display labels (e.g. СБП, Карта).
# None or empty means no sub-option selection needed.
sub_options: list[LandingPaymentMethodSubOption] | None = None
class LandingDiscountInfo(BaseModel):
percent: int # default discount
ends_at: str # ISO datetime
badge_text: str | None = None # resolved locale text
class LandingConfigResponse(BaseModel):
slug: str
title: str
subtitle: str | None = None
features: list[LandingFeature]
footer_text: str | None = None
tariffs: list[LandingTariff]
payment_methods: list[LandingPaymentMethod]
gift_enabled: bool
custom_css: str | None = None
meta_title: str | None = None
meta_description: str | None = None
discount: LandingDiscountInfo | None = None # null if no active discount
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{3,31}$')
def _validate_contact(contact_type: str, contact_value: str) -> None:
"""Validate contact value matches the declared type format."""
if contact_type == 'email' and not _EMAIL_RE.match(contact_value):
raise ValueError('Invalid email format')
if contact_type == 'telegram' and not _TELEGRAM_RE.match(contact_value):
raise ValueError('Invalid Telegram username format')
class PurchaseRequest(BaseModel):
tariff_id: int
period_days: int
contact_type: str = Field(pattern=r'^(email|telegram)$')
contact_value: str = Field(min_length=1, max_length=255)
payment_method: str = Field(min_length=1, max_length=50, pattern=r'^[a-z0-9_]+$')
is_gift: bool = False
gift_recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
gift_recipient_value: str | None = Field(default=None, max_length=255)
gift_message: str | None = Field(default=None, max_length=1000)
@model_validator(mode='after')
def validate_contacts(self) -> 'PurchaseRequest':
_validate_contact(self.contact_type, self.contact_value)
if self.is_gift:
if not self.gift_recipient_type or not self.gift_recipient_value:
raise ValueError('Gift recipient type and value are required for gift purchases')
_validate_contact(self.gift_recipient_type, self.gift_recipient_value)
return self
class PurchaseResponse(BaseModel):
purchase_token: str
payment_url: str
class PurchaseStatusResponse(BaseModel):
status: str
subscription_url: str | None = None
subscription_crypto_link: str | None = None
is_gift: bool = False
contact_value: str | None = None
recipient_contact_value: str | None = None
period_days: int | None = None
tariff_name: str | None = None
gift_message: str | None = None
contact_type: str | None = None
cabinet_email: str | None = None
cabinet_password: str | None = None
auto_login_token: str | None = None
# ============ Helpers ============
def _mask_contact(value: str) -> str:
"""Mask contact value to avoid leaking PII in API responses."""
if '@' in value and not value.startswith('@'):
# Email: show first 2 chars + mask + domain
local, domain = value.rsplit('@', 1)
return f'{local[:2]}***@{domain}'
if value.startswith('@'):
# Telegram: show first 3 chars + mask
return f'{value[:3]}***'
return value[:3] + '***'
_SUBSCRIPTION_URL_EXPIRY_HOURS = 24
def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusResponse:
"""Build a PurchaseStatusResponse from a GuestPurchase record."""
tariff_name = purchase.tariff.name if purchase.tariff else None
within_ttl = False
subscription_url = None
subscription_crypto_link = None
if purchase.delivered_at and purchase.subscription_url:
age = datetime.now(UTC) - purchase.delivered_at
if age < timedelta(hours=_SUBSCRIPTION_URL_EXPIRY_HOURS):
within_ttl = True
subscription_url = purchase.subscription_url
subscription_crypto_link = purchase.subscription_crypto_link
masked_contact = _mask_contact(purchase.contact_value) if purchase.contact_value else None
recipient_contact_value = None
gift_message = None
if purchase.is_gift:
if purchase.gift_recipient_value:
recipient_contact_value = _mask_contact(purchase.gift_recipient_value)
gift_message = purchase.gift_message
# Determine effective contact type for the recipient
if purchase.is_gift and purchase.gift_recipient_type:
effective_contact_type = purchase.gift_recipient_type
else:
effective_contact_type = purchase.contact_type
# Cabinet credentials for email self-purchases (not gifts)
cabinet_email = None
cabinet_password = None
auto_login_token = None
is_terminal = purchase.status in (GuestPurchaseStatus.DELIVERED.value, GuestPurchaseStatus.PENDING_ACTIVATION.value)
is_email_self_purchase = effective_contact_type == 'email' and not purchase.is_gift
if is_terminal and is_email_self_purchase:
cabinet_email = purchase.contact_value
# For PENDING_ACTIVATION: cap credential exposure at 72h from paid_at
pending_within_ttl = (
purchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value
and purchase.paid_at
and (datetime.now(UTC) - purchase.paid_at) < timedelta(hours=72)
)
if within_ttl or pending_within_ttl:
cabinet_password = purchase.cabinet_password
auto_login_token = purchase.auto_login_token
return PurchaseStatusResponse(
status=purchase.status,
subscription_url=subscription_url,
subscription_crypto_link=subscription_crypto_link,
is_gift=purchase.is_gift,
contact_value=masked_contact,
recipient_contact_value=recipient_contact_value,
period_days=purchase.period_days,
tariff_name=tariff_name,
gift_message=gift_message,
contact_type=effective_contact_type,
cabinet_email=cabinet_email,
cabinet_password=cabinet_password,
auto_login_token=auto_login_token,
)
def _period_label(days: int) -> str:
"""Human-readable label for a period in days."""
if days == 1:
return '1 day'
if days <= 6:
return f'{days} days'
if days == 7:
return '1 week'
if days == 14:
return '2 weeks'
if days == 30:
return '1 month'
if days == 60:
return '2 months'
if days == 90:
return '3 months'
if days == 180:
return '6 months'
if days == 365:
return '1 year'
if days == 456:
return '1 year + 3 mo.'
months = days // 30
remainder = days % 30
if months > 0 and remainder == 0:
return f'{months} mo.'
if months > 0:
return f'{months} mo. + {remainder} d.'
return f'{days} days'
def _get_active_discount(landing: LandingPage, lang: str) -> LandingDiscountInfo | None:
"""Return discount info if currently active, else None."""
if not landing.discount_percent or not landing.discount_starts_at or not landing.discount_ends_at:
return None
now = datetime.now(UTC)
if not (landing.discount_starts_at <= now < landing.discount_ends_at):
return None
badge = resolve_locale_text(landing.discount_badge_text, lang) if landing.discount_badge_text else None
return LandingDiscountInfo(
percent=landing.discount_percent,
ends_at=landing.discount_ends_at.isoformat(),
badge_text=badge or None,
)
async def _load_landing_tariffs(
db: AsyncSession, landing: LandingPage, discount: LandingDiscountInfo | None = None
) -> list[LandingTariff]:
"""Load tariffs for a landing page, filtered by allowed IDs and periods."""
allowed_ids = landing.allowed_tariff_ids or []
if not allowed_ids:
return []
result = await db.execute(
select(Tariff)
.where(Tariff.id.in_(allowed_ids), Tariff.is_active.is_(True))
.order_by(Tariff.display_order, Tariff.id)
)
tariffs = result.scalars().all()
allowed_periods = landing.allowed_periods or {}
landing_tariffs = []
for tariff in tariffs:
# Determine which periods to show
tariff_period_override = allowed_periods.get(str(tariff.id))
if tariff_period_override is not None:
period_days_list = sorted(tariff_period_override)
else:
period_days_list = tariff.get_available_periods()
periods = []
for days in period_days_list:
price = tariff.get_price_for_period(days)
if price is None:
continue
original_price_kopeks = None
original_price_label = None
effective_discount = None
if discount:
# Per-tariff override takes priority (read from landing model, not response DTO)
overrides = landing.discount_overrides or {}
tariff_override = overrides.get(str(tariff.id))
effective_discount = tariff_override if tariff_override is not None else discount.percent
original_price_kopeks = price
original_price_label = settings.format_price(price)
price = max(1, price - (price * effective_discount // 100))
periods.append(
LandingTariffPeriod(
days=days,
label=_period_label(days),
price_kopeks=price,
price_label=settings.format_price(price),
original_price_kopeks=original_price_kopeks,
original_price_label=original_price_label,
discount_percent=effective_discount,
)
)
if not periods:
continue
landing_tariffs.append(
LandingTariff(
id=tariff.id,
name=tariff.name,
description=tariff.description,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
tier_level=tariff.tier_level,
periods=periods,
)
)
return landing_tariffs
# ============ Routes ============
# IMPORTANT: /purchase/{token} must come BEFORE /{slug} to avoid shadowing
# (FastAPI checks routes in definition order; "purchase" would match {slug})
@router.get('/purchase/{token}', response_model=PurchaseStatusResponse)
async def get_purchase_status(
token: str,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get the status of a guest purchase by token.
No authentication required.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'purchase_status', limit=30, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
purchase = await get_purchase_by_token(db, token)
if purchase is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Purchase not found',
)
response = _build_purchase_status_response(purchase)
# Cleanup: null expired credentials from DB
needs_cleanup = False
if purchase.delivered_at and (purchase.cabinet_password or purchase.auto_login_token):
age = datetime.now(UTC) - purchase.delivered_at
if age >= timedelta(hours=_SUBSCRIPTION_URL_EXPIRY_HOURS):
needs_cleanup = True
elif (
purchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value
and purchase.paid_at
and (purchase.cabinet_password or purchase.auto_login_token)
and (datetime.now(UTC) - purchase.paid_at) >= timedelta(hours=72)
):
needs_cleanup = True
if needs_cleanup:
purchase.cabinet_password = None
purchase.auto_login_token = None
await db.commit()
return response
@router.post('/activate/{token}', response_model=PurchaseStatusResponse)
async def activate_purchase(
token: str,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Activate a pending guest purchase, replacing the user's current subscription.
No authentication required (token is the secret).
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'activate_purchase', limit=5, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
try:
purchase = await activate_guest_purchase(db, token)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
return _build_purchase_status_response(purchase)
@router.get('/{slug}', response_model=LandingConfigResponse)
async def get_landing_config(
raw_request: Request,
slug: str = Path(max_length=100),
lang: str = Query(DEFAULT_LOCALE, max_length=5, description='Locale: ru, en, zh, fa'),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get public landing page configuration with tariffs and payment methods.
No authentication required. Pass ``?lang=en`` to get localized text.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_config', limit=60, window=60, fail_closed=True):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
landing = await get_active_landing_by_slug(db, slug)
if landing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Landing page not found',
)
discount = _get_active_discount(landing, lang)
tariffs = await _load_landing_tariffs(db, landing, discount)
# Build payment methods from landing config
raw_methods = landing.payment_methods or []
method_defaults = _get_method_defaults()
payment_methods: list[LandingPaymentMethod] = []
for m in raw_methods:
method_id = m.get('method_id', '')
raw_sub_options = m.get('sub_options') # dict[str, bool] | None
# Resolve sub-options: filter enabled ones and attach display names
resolved_sub_options: list[LandingPaymentMethodSubOption] | None = None
method_def = method_defaults.get(method_id)
available = method_def.get('available_sub_options') if method_def else None
if available:
resolved = []
for opt in available:
opt_id = opt['id']
# If landing has explicit sub_options config, respect it; otherwise all enabled
if raw_sub_options is None or raw_sub_options.get(opt_id, True):
resolved.append(LandingPaymentMethodSubOption(id=opt_id, name=opt['name']))
if resolved:
resolved_sub_options = resolved
payment_methods.append(
LandingPaymentMethod(
method_id=method_id,
display_name=m.get('display_name', ''),
description=m.get('description'),
icon_url=m.get('icon_url'),
sort_order=m.get('sort_order', 0),
min_amount_kopeks=m.get('min_amount_kopeks'),
max_amount_kopeks=m.get('max_amount_kopeks'),
currency=m.get('currency'),
sub_options=resolved_sub_options,
)
)
# Resolve locale dicts to flat strings for the requested language
features = [
LandingFeature(
icon=f.get('icon', ''),
title=resolve_locale_text(f.get('title'), lang),
description=resolve_locale_text(f.get('description'), lang),
)
for f in (landing.features or [])
]
return LandingConfigResponse(
slug=landing.slug,
title=resolve_locale_text(landing.title, lang),
subtitle=resolve_locale_text(landing.subtitle, lang) or None,
features=features,
footer_text=resolve_locale_text(landing.footer_text, lang) or None,
tariffs=tariffs,
payment_methods=payment_methods,
gift_enabled=landing.gift_enabled,
custom_css=landing.custom_css,
meta_title=resolve_locale_text(landing.meta_title, lang) or None,
meta_description=resolve_locale_text(landing.meta_description, lang) or None,
discount=discount,
)
@router.post('/{slug}/purchase', response_model=PurchaseResponse)
async def create_landing_purchase(
slug: str,
body: PurchaseRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a guest purchase on a landing page.
No authentication required.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=5, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many purchase attempts, please try again later',
)
landing = await get_active_landing_by_slug(db, slug)
if landing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Landing page not found',
)
if body.is_gift and not landing.gift_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Gift purchases are not enabled for this landing page',
)
# Validate payment method is available on this landing.
# The frontend may send a suffixed method ID (e.g. "platega_2", "yookassa_sbp")
# to select a specific sub-option. We match against the base method_id and
# validate the suffix against known & enabled sub-options.
raw_methods = landing.payment_methods or []
method_defaults = _get_method_defaults()
method_config = next((m for m in raw_methods if m.get('method_id') == body.payment_method), None)
if method_config is None:
# Try matching by prefix: "platega_2" → base "platega"
# Sort by length descending so "freekassa_sbp" is checked before "freekassa"
sorted_methods = sorted(raw_methods, key=lambda m: len(m.get('method_id', '')), reverse=True)
for m in sorted_methods:
mid = m.get('method_id', '')
if body.payment_method.startswith(mid + '_'):
suffix = body.payment_method[len(mid) + 1 :]
# Validate suffix is a known sub-option
method_def = method_defaults.get(mid)
available = (method_def.get('available_sub_options') if method_def else None) or []
valid_ids = {opt['id'] for opt in available}
if suffix not in valid_ids:
break # invalid suffix → reject
# Validate suffix is enabled on this landing
raw_sub_options = m.get('sub_options') # dict[str, bool] | None
if raw_sub_options is not None and not raw_sub_options.get(suffix, True):
break # disabled sub-option → reject
method_config = m
break
if method_config is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Payment method is not available on this landing page',
)
# Validate tariff + period + calculate price
try:
tariff, amount_kopeks = await validate_and_calculate(db, landing, body.tariff_id, body.period_days)
except GuestPurchaseError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
# Validate amount against per-method min/max limits (before creating purchase record)
min_amount = method_config.get('min_amount_kopeks')
max_amount = method_config.get('max_amount_kopeks')
if min_amount is not None and amount_kopeks < min_amount:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Amount is below the minimum ({settings.format_price(min_amount)}) for this payment method',
)
if max_amount is not None and amount_kopeks > max_amount:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Amount exceeds the maximum ({settings.format_price(max_amount)}) for this payment method',
)
# Create purchase record (no commit yet — wait for payment creation)
purchase = await create_purchase(
db,
landing=landing,
tariff=tariff,
period_days=body.period_days,
amount_kopeks=amount_kopeks,
contact_type=body.contact_type,
contact_value=body.contact_value,
payment_method=body.payment_method,
is_gift=body.is_gift,
gift_recipient_type=body.gift_recipient_type,
gift_recipient_value=body.gift_recipient_value,
gift_message=body.gift_message,
commit=False,
)
# Determine return URL: per-method override → default cabinet URL
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
default_return_url = f'{cabinet_base}/buy/success/{purchase.token}'
method_return_url = method_config.get('return_url')
if method_return_url:
# Allow {token} placeholder in custom return URLs
return_url = method_return_url.replace('{token}', purchase.token)
else:
return_url = default_return_url
payment_service = PaymentService()
payment_result = await payment_service.create_guest_payment(
db=db,
amount_kopeks=amount_kopeks,
payment_method=body.payment_method,
description=f'{tariff.name}{body.period_days}d',
purchase_token=purchase.token,
return_url=return_url,
)
if payment_result is None:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Payment provider is unavailable, please try again later',
)
payment_url = payment_result.get('payment_url')
if not payment_url:
await db.rollback()
logger.error(
'Payment created but no payment_url returned',
purchase_token=purchase.token[:5],
provider=payment_result.get('provider'),
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Payment provider returned an invalid response',
)
await db.commit()
await db.refresh(purchase)
return PurchaseResponse(
purchase_token=purchase.token,
payment_url=payment_url,
)
+2 -2
View File
@@ -1,6 +1,6 @@
"""Notification settings routes for cabinet."""
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import structlog
@@ -112,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)
+95 -25
View File
@@ -12,6 +12,7 @@ from app.database.crud.user import (
create_user_by_oauth,
get_user_by_email,
get_user_by_oauth_provider,
get_user_by_referral_code,
set_user_oauth_provider_id,
)
from app.database.models import User
@@ -23,8 +24,9 @@ from ..auth.oauth_providers import (
validate_oauth_state,
)
from ..dependencies import get_cabinet_db
from ..routes.account_linking import OAuthProviderName
from ..schemas.auth import AuthResponse
from .auth import _create_auth_response, _store_refresh_token
from .auth import _create_auth_response, _process_campaign_bonus, _store_refresh_token
logger = structlog.get_logger(__name__)
@@ -32,12 +34,27 @@ logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
async def _finalize_oauth_login(db: AsyncSession, user: User, provider: str) -> AuthResponse:
async def _finalize_oauth_login(
db: AsyncSession,
user: User,
provider: str,
campaign_slug: str | None = None,
referral_code: str | None = None,
) -> AuthResponse:
"""Update last login, create tokens, store refresh token."""
user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None)
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
auth_response = _create_auth_response(user)
auth_response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
# Process referral code (before campaign bonus, which may also set referrer)
from .auth import _process_referral_code, _user_to_response
await _process_referral_code(db, user, referral_code)
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
if auth_response.campaign_bonus:
auth_response.user = _user_to_response(user)
return auth_response
@@ -59,8 +76,15 @@ class OAuthAuthorizeResponse(BaseModel):
class OAuthCallbackRequest(BaseModel):
code: str = Field(..., description='Authorization code from provider')
state: str = Field(..., description='CSRF state token')
code: str = Field(..., min_length=1, max_length=2048, description='Authorization code from provider')
state: str = Field(..., min_length=1, max_length=128, description='CSRF state token')
device_id: str | None = Field(None, max_length=256, description='Device ID from VK ID callback')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
# --- Endpoints ---
@@ -79,48 +103,68 @@ async def get_oauth_providers():
@router.get('/{provider}/authorize', response_model=OAuthAuthorizeResponse)
async def get_oauth_authorize_url(provider: str):
async def get_oauth_authorize_url(provider: OAuthProviderName):
"""Get authorization URL for an OAuth provider."""
oauth_provider = get_provider(provider)
if not oauth_provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'OAuth provider "{provider}" is not enabled',
detail='Requested OAuth provider is not available',
)
state = await generate_oauth_state(provider)
authorize_url = oauth_provider.get_authorization_url(state)
# Generate extra state data (e.g., PKCE code_verifier for VK)
auth_extra = oauth_provider.prepare_auth_state()
state = await generate_oauth_state(provider, extra_data=auth_extra or None)
# Only pass URL-safe params (prefixed with _) to authorize URL; exclude secrets like code_verifier
url_params = {k: v for k, v in auth_extra.items() if k.startswith('_')} if auth_extra else {}
authorize_url = oauth_provider.get_authorization_url(state, **url_params)
return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state)
@router.post('/{provider}/callback', response_model=AuthResponse)
async def oauth_callback(
provider: str,
provider: OAuthProviderName,
request: OAuthCallbackRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Handle OAuth callback: exchange code, find/create user, return JWT."""
# 1. Validate CSRF state
if not await validate_oauth_state(request.state, provider):
# 1. Validate CSRF state and retrieve stored data (e.g., PKCE code_verifier)
state_data = await validate_oauth_state(request.state, provider)
if not state_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid or expired OAuth state',
)
# 1b. Reject linking-flow state tokens (must use link_provider_callback instead)
if state_data.get('linking') == 'true':
logger.warning('Linking-flow state token used in login callback', provider=provider)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='OAuth state was initiated for account linking, not login',
)
# 2. Get provider instance
oauth_provider = get_provider(provider)
if not oauth_provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'OAuth provider "{provider}" is not enabled',
detail='Requested OAuth provider is not available',
)
# 3. Exchange code for tokens
# 3. Exchange code for tokens (pass PKCE code_verifier and device_id if present)
exchange_kwargs: dict[str, str] = {'state': request.state}
code_verifier = state_data.get('code_verifier')
if code_verifier:
exchange_kwargs['code_verifier'] = code_verifier
if request.device_id:
exchange_kwargs['device_id'] = request.device_id
try:
token_data = await oauth_provider.exchange_code(request.code)
token_data = await oauth_provider.exchange_code(request.code, **exchange_kwargs)
except Exception as exc:
logger.error('OAuth code exchange failed for', provider=provider, exc=exc)
logger.error('OAuth code exchange failed', provider=provider, exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to exchange authorization code',
@@ -130,7 +174,7 @@ async def oauth_callback(
try:
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
except Exception as exc:
logger.error('OAuth user info fetch failed for', provider=provider, exc=exc)
logger.error('OAuth user info fetch failed', provider=provider, exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to fetch user information from provider',
@@ -139,18 +183,43 @@ async def oauth_callback(
# 5. Find user by provider ID
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
if user:
logger.info('OAuth login via for existing user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider)
logger.info('OAuth login for existing user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
# 6. Find user by email (if verified) and link provider
if user_info.email and user_info.email_verified:
user = await get_user_by_email(db, user_info.email)
if user:
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider)
logger.info('OAuth provider linked to existing email user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
# 7. Create new user
# 7. Resolve referral code for new user
referrer_id = None
if request.referral_code:
try:
referrer = await get_user_by_referral_code(db, request.referral_code)
if referrer:
# Self-referral protection by email
if (
user_info.email
and user_info.email_verified
and referrer.email
and referrer.email.lower() == user_info.email.lower()
):
logger.warning(
'Self-referral attempt blocked via OAuth',
referral_code=request.referral_code,
email=user_info.email,
)
else:
referrer_id = referrer.id
except Exception:
logger.warning(
'Failed to resolve referral code during OAuth', referral_code=request.referral_code, exc_info=True
)
# 8. Create new user
user = await create_user_by_oauth(
db=db,
provider=provider,
@@ -160,6 +229,7 @@ async def oauth_callback(
first_name=user_info.first_name,
last_name=user_info.last_name,
username=user_info.username,
referred_by_id=referrer_id,
)
logger.info('OAuth new user created via with id', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider)
logger.info('New OAuth user created', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
+218
View File
@@ -0,0 +1,218 @@
"""User-facing partner application routes for cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.cabinet.utils.links import get_campaign_deep_link, get_campaign_web_link
from app.config import settings
from app.database.models import AdvertisingCampaign, User
from app.services.partner_application_service import partner_application_service
from app.services.partner_stats_service import PartnerStatsService
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.partners import (
CampaignReferralItem,
DailyStatItem,
PartnerApplicationInfo,
PartnerApplicationRequest,
PartnerCampaignDetailedStats,
PartnerCampaignInfo,
PartnerStatusResponse,
PeriodChange,
PeriodComparison,
PeriodStats,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/referral/partner', tags=['Cabinet Partner'])
@router.get('/status', response_model=PartnerStatusResponse)
async def get_partner_status(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get partner status and latest application for current user."""
latest_app = await partner_application_service.get_latest_application(db, user.id)
app_info = None
if latest_app:
app_info = PartnerApplicationInfo(
id=latest_app.id,
status=latest_app.status,
company_name=latest_app.company_name,
website_url=latest_app.website_url,
telegram_channel=latest_app.telegram_channel,
description=latest_app.description,
expected_monthly_referrals=latest_app.expected_monthly_referrals,
desired_commission_percent=latest_app.desired_commission_percent,
admin_comment=latest_app.admin_comment,
approved_commission_percent=latest_app.approved_commission_percent,
created_at=latest_app.created_at,
processed_at=latest_app.processed_at,
)
commission = user.referral_commission_percent
if commission is None and user.is_partner:
commission = settings.REFERRAL_COMMISSION_PERCENT
# Fetch campaigns assigned to this partner
campaigns: list[PartnerCampaignInfo] = []
if user.is_partner:
result = await db.execute(
select(AdvertisingCampaign).where(
AdvertisingCampaign.partner_user_id == user.id,
AdvertisingCampaign.is_active.is_(True),
)
)
campaign_models = result.scalars().all()
# Fetch per-campaign stats in one batch
campaign_ids = [c.id for c in campaign_models]
campaign_stats = await PartnerStatsService.get_per_campaign_stats(db, user.id, campaign_ids)
for c in campaign_models:
stats = campaign_stats.get(c.id, {})
campaigns.append(
PartnerCampaignInfo(
id=c.id,
name=c.name,
start_parameter=c.start_parameter,
bonus_type=c.bonus_type,
balance_bonus_kopeks=c.balance_bonus_kopeks or 0,
subscription_duration_days=c.subscription_duration_days,
subscription_traffic_gb=c.subscription_traffic_gb,
deep_link=get_campaign_deep_link(c.start_parameter),
web_link=get_campaign_web_link(c.start_parameter),
registrations_count=stats.get('registrations_count', 0),
referrals_count=stats.get('referrals_count', 0),
earnings_kopeks=stats.get('earnings_kopeks', 0),
)
)
return PartnerStatusResponse(
partner_status=user.partner_status,
commission_percent=commission,
latest_application=app_info,
campaigns=campaigns,
)
@router.get('/campaigns/{campaign_id}/stats', response_model=PartnerCampaignDetailedStats)
async def get_campaign_stats(
campaign_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed stats for a single campaign belonging to the current partner."""
if not user.is_partner:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Partner status required',
)
# Verify campaign belongs to this partner
campaign_result = await db.execute(
select(AdvertisingCampaign).where(
AdvertisingCampaign.id == campaign_id,
AdvertisingCampaign.partner_user_id == user.id,
)
)
campaign = campaign_result.scalar_one_or_none()
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Campaign not found or not assigned to you',
)
raw = await PartnerStatsService.get_campaign_detailed_stats(db, user.id, campaign_id)
return PartnerCampaignDetailedStats(
campaign_id=raw['campaign_id'],
campaign_name=campaign.name,
registrations_count=raw['registrations_count'],
referrals_count=raw['referrals_count'],
earnings_kopeks=raw['earnings_kopeks'],
conversion_rate=raw['conversion_rate'],
earnings_today=raw['earnings_today'],
earnings_week=raw['earnings_week'],
earnings_month=raw['earnings_month'],
daily_stats=[DailyStatItem(**d) for d in raw['daily_stats']],
period_comparison=PeriodComparison(
current=PeriodStats(**raw['period_comparison']['current']),
previous=PeriodStats(**raw['period_comparison']['previous']),
referrals_change=PeriodChange(**raw['period_comparison']['referrals_change']),
earnings_change=PeriodChange(**raw['period_comparison']['earnings_change']),
),
top_referrals=[CampaignReferralItem(**r) for r in raw['top_referrals']],
)
@router.post('/apply', response_model=PartnerApplicationInfo)
async def apply_for_partner(
request: PartnerApplicationRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Submit partner application."""
application, error = await partner_application_service.submit_application(
db,
user_id=user.id,
company_name=request.company_name,
website_url=request.website_url,
telegram_channel=request.telegram_channel,
description=request.description,
expected_monthly_referrals=request.expected_monthly_referrals,
desired_commission_percent=request.desired_commission_percent,
)
if not application:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Уведомляем админов о новой заявке
try:
from 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,
'desired_commission_percent': request.desired_commission_percent,
},
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for partner application', error=e)
return PartnerApplicationInfo(
id=application.id,
status=application.status,
company_name=application.company_name,
website_url=application.website_url,
telegram_channel=application.telegram_channel,
description=application.description,
expected_monthly_referrals=application.expected_monthly_referrals,
desired_commission_percent=application.desired_commission_percent,
admin_comment=application.admin_comment,
approved_commission_percent=application.approved_commission_percent,
created_at=application.created_at,
processed_at=application.processed_at,
)
+3 -3
View File
@@ -1,6 +1,6 @@
"""Polls routes for cabinet - user participation in polls/surveys."""
from datetime import datetime
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
@@ -247,7 +247,7 @@ async def start_poll(
# 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
@@ -346,7 +346,7 @@ async def answer_question(
)
# Poll completed
response.completed_at = datetime.utcnow()
response.completed_at = datetime.now(UTC)
await db.commit()
# Award reward if any
+5 -5
View File
@@ -1,6 +1,6 @@
"""Promo offers routes for cabinet - personal discounts and offers."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
@@ -112,7 +112,7 @@ async def get_promo_offers(
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)
@@ -151,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(
@@ -284,7 +284,7 @@ async def claim_promo_offer(
detail='Offer not found',
)
now = datetime.utcnow()
now = datetime.now(UTC)
if offer.claimed_at is not None:
raise HTTPException(
@@ -408,7 +408,7 @@ 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()
+2
View File
@@ -71,7 +71,9 @@ async def activate_promocode(
'used': 'Promo code has been fully used',
'already_used_by_user': 'You have already used this promo code',
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
'no_subscription_for_days': 'This promo code requires an active or expired subscription',
'not_first_purchase': 'This promo code is only available for first purchase',
'daily_limit': 'Too many promo code activations today',
'user_not_found': 'User not found',
'server_error': 'Server error occurred',
}
+60 -10
View File
@@ -9,7 +9,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import ReferralEarning, User
from app.database.models import (
AdvertisingCampaign,
ReferralEarning,
Subscription,
SubscriptionStatus,
User,
WithdrawalRequest,
WithdrawalRequestStatus,
)
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.referral import (
@@ -38,12 +46,15 @@ async def get_referral_info(
total_result = await db.execute(total_query)
total_referrals = total_result.scalar() or 0
# Get active referrals (with subscription)
# Get active referrals (with active subscription right now)
active_query = (
select(func.count())
.select_from(User)
.where(User.referred_by_id == user.id)
.where(User.has_had_paid_subscription == True)
select(func.count(func.distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
User.referred_by_id == user.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > func.now(),
)
)
active_result = await db.execute(active_query)
active_referrals = active_result.scalar() or 0
@@ -60,6 +71,26 @@ async def get_referral_info(
if commission_percent is None:
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
# Get withdrawn amount (approved + completed withdrawal requests)
withdrawn_query = select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
WithdrawalRequest.user_id == user.id,
WithdrawalRequest.status.in_([WithdrawalRequestStatus.APPROVED.value, WithdrawalRequestStatus.COMPLETED.value]),
)
withdrawn_result = await db.execute(withdrawn_query)
withdrawn = withdrawn_result.scalar() or 0
# Get pending withdrawal amount
pending_query = select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
WithdrawalRequest.user_id == user.id,
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value,
)
pending_result = await db.execute(pending_query)
pending = pending_result.scalar() or 0
# Доступный баланс: мин(кошелёк, заработано - выведено - в ожидании)
referral_entitlement = max(0, total_earnings - withdrawn - pending)
available_balance = min(user.balance_kopeks, referral_entitlement)
# Build referral link
bot_username = settings.get_bot_username() or 'bot'
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
@@ -72,6 +103,9 @@ async def get_referral_info(
total_earnings_kopeks=total_earnings,
total_earnings_rubles=total_earnings / 100,
commission_percent=commission_percent,
available_balance_kopeks=available_balance,
available_balance_rubles=available_balance / 100,
withdrawn_kopeks=withdrawn,
)
@@ -150,12 +184,26 @@ async def get_referral_earnings(
result = await db.execute(query)
earnings = result.scalars().all()
# Batch-fetch referral users to avoid N+1
referral_ids = list({e.referral_id for e in earnings if e.referral_id})
if referral_ids:
referral_users_result = await db.execute(select(User).where(User.id.in_(referral_ids)))
referral_users_map = {u.id: u for u in referral_users_result.scalars().all()}
else:
referral_users_map = {}
# Batch-fetch campaigns to avoid N+1
campaign_ids = list({e.campaign_id for e in earnings if e.campaign_id})
if campaign_ids:
campaigns_result = await db.execute(select(AdvertisingCampaign).where(AdvertisingCampaign.id.in_(campaign_ids)))
campaigns_map = {c.id: c for c in campaigns_result.scalars().all()}
else:
campaigns_map = {}
items = []
for e in earnings:
# Get referral user info
referral_query = select(User).where(User.id == e.referral_id)
referral_result = await db.execute(referral_query)
referral_user = referral_result.scalar_one_or_none()
referral_user = referral_users_map.get(e.referral_id) if e.referral_id else None
campaign = campaigns_map.get(e.campaign_id) if e.campaign_id else None
items.append(
ReferralEarningResponse(
@@ -165,6 +213,7 @@ async def get_referral_earnings(
reason=e.reason or 'Referral commission',
referral_username=referral_user.username if referral_user else None,
referral_first_name=referral_user.first_name if referral_user else None,
campaign_name=campaign.name if campaign else None,
created_at=e.created_at,
)
)
@@ -194,4 +243,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
+6 -6
View File
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user
from ..dependencies import get_cabinet_db, get_current_cabinet_user, require_permission
logger = structlog.get_logger(__name__)
@@ -132,7 +132,7 @@ async def get_admin_notifications(
unread_only: bool = Query(False, description='Only return unread notifications'),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket notifications for admins."""
@@ -149,7 +149,7 @@ async def get_admin_notifications(
@admin_router.get('/unread-count', response_model=UnreadCountResponse)
async def get_admin_unread_count(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get unread notifications count for admins."""
@@ -160,7 +160,7 @@ async def get_admin_unread_count(
@admin_router.post('/{notification_id}/read')
async def mark_admin_notification_as_read(
notification_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark an admin notification as read."""
@@ -185,7 +185,7 @@ async def mark_admin_notification_as_read(
@admin_router.post('/read-all')
async def mark_all_admin_notifications_as_read(
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all admin notifications as read."""
@@ -196,7 +196,7 @@ async def mark_all_admin_notifications_as_read(
@admin_router.post('/ticket/{ticket_id}/read')
async def mark_admin_ticket_notifications_as_read(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
admin: User = Depends(require_permission('tickets:settings')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all admin notifications for a specific ticket as read."""
+13 -7
View File
@@ -1,7 +1,7 @@
"""Support tickets routes for cabinet."""
import math
from datetime import datetime
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -137,8 +137,8 @@ async def create_ticket(
title=request.title,
status='open',
priority='normal',
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
db.add(ticket)
await db.flush()
@@ -152,7 +152,7 @@ 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()
@@ -268,21 +268,27 @@ 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()
ticket.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(message)
# Уведомить админов об ответе пользователя (Telegram)
try:
await notify_admins_about_ticket_reply(ticket, request.message, db)
await notify_admins_about_ticket_reply(
ticket,
request.message,
db,
media_file_id=request.media_file_id,
media_type=request.media_type,
)
except Exception as e:
logger.error('Error notifying admins about ticket reply from cabinet', error=e)
+17
View File
@@ -50,6 +50,12 @@ async def get_wheel_config(
# Проверяем доступность
availability = await wheel_service.check_availability(db, user)
# Проверяем наличие подписки
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user.id)
has_subscription = subscription is not None and subscription.is_active
prizes_display = [
WheelPrizeDisplay(
id=p.id,
@@ -77,6 +83,7 @@ async def get_wheel_config(
can_pay_days=availability.can_pay_days,
user_balance_kopeks=availability.user_balance_kopeks,
required_balance_kopeks=availability.required_balance_kopeks,
has_subscription=has_subscription,
)
@@ -213,6 +220,16 @@ async def create_stars_invoice(
detail='Оплата Stars не включена',
)
# Проверяем наличие активной подписки
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription or not subscription.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Для использования колеса необходима активная подписка',
)
# Проверяем лимит спинов
spins_today = await get_user_spins_today(db, user.id)
if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit:
+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}
+62 -13
View File
@@ -8,19 +8,43 @@ 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(..., max_length=4096, 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'
)
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
class TelegramWidgetAuthRequest(BaseModel):
"""Request for Telegram Login Widget authentication."""
id: int = Field(..., description='Telegram user ID')
first_name: str = Field(..., description="User's first name")
last_name: str | None = Field(None, description="User's last name")
username: str | None = Field(None, description="User's username")
photo_url: str | None = Field(None, description="User's photo URL")
first_name: str = Field(..., max_length=64, description="User's first name")
last_name: str | None = Field(None, max_length=64, description="User's last name")
username: str | None = Field(None, max_length=32, description="User's username")
photo_url: str | None = Field(None, max_length=512, description="User's photo URL")
auth_date: int = Field(..., description='Unix timestamp of authentication')
hash: str = Field(..., description='Authentication hash')
hash: str = Field(..., min_length=64, max_length=64, 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'
)
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
class TelegramOIDCAuthRequest(BaseModel):
"""Request for Telegram OIDC authentication (popup flow)."""
id_token: str = Field(..., max_length=4096, description='JWT id_token from Telegram OIDC popup')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
class EmailRegisterRequest(BaseModel):
@@ -33,20 +57,26 @@ class EmailRegisterRequest(BaseModel):
class EmailVerifyRequest(BaseModel):
"""Request to verify email with token."""
token: str = Field(..., description='Email verification token')
token: str = Field(..., max_length=2048, 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')
password: str = Field(..., min_length=1, max_length=128, 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(..., max_length=2048, description='Refresh token')
class PasswordForgotRequest(BaseModel):
@@ -58,10 +88,16 @@ class PasswordForgotRequest(BaseModel):
class PasswordResetRequest(BaseModel):
"""Request to reset password with token."""
token: str = Field(..., description='Password reset token')
token: str = Field(..., max_length=2048, description='Password reset token')
password: str = Field(..., min_length=8, max_length=128, description='New password (min 8 chars)')
class AutoLoginRequest(BaseModel):
"""Request for auto-login from guest purchase success page."""
token: str = Field(..., max_length=2048, description='Auto-login JWT token')
class TokenResponse(BaseModel):
"""Token pair response."""
@@ -98,8 +134,20 @@ class EmailRegisterStandaloneRequest(BaseModel):
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')
language: str = Field('ru', max_length=5, pattern=r'^[a-z]{2}$', description='Preferred language (ISO 639-1)')
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', 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):
@@ -110,6 +158,7 @@ class AuthResponse(BaseModel):
token_type: str = 'bearer'
expires_in: int
user: UserResponse
campaign_bonus: CampaignBonusInfo | None = None
class RegisterResponse(BaseModel):
@@ -129,7 +178,7 @@ class EmailChangeRequest(BaseModel):
class EmailChangeVerifyRequest(BaseModel):
"""Request to verify email change with code."""
code: str = Field(..., min_length=6, max_length=6, description='6-digit verification code')
code: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$', description='6-digit verification code')
class EmailChangeResponse(BaseModel):
+2 -2
View File
@@ -63,7 +63,7 @@ class PaymentMethodResponse(BaseModel):
class TopUpRequest(BaseModel):
"""Request to create payment for balance top-up."""
amount_kopeks: int = Field(..., ge=1000, description='Amount in kopeks (min 10 rubles)')
amount_kopeks: int = Field(..., ge=1000, le=2_000_000_000, description='Amount in kopeks (min 10 rubles)')
payment_method: str = Field(..., description='Payment method ID')
payment_option: str | None = Field(None, description='Payment option (e.g. Platega method code)')
@@ -82,7 +82,7 @@ class TopUpResponse(BaseModel):
class StarsInvoiceRequest(BaseModel):
"""Request to create Telegram Stars invoice for balance top-up."""
amount_kopeks: int = Field(..., ge=100, description='Amount in kopeks (min 1 ruble)')
amount_kopeks: int = Field(..., ge=100, le=2_000_000_000, description='Amount in kopeks (min 1 ruble)')
class StarsInvoiceResponse(BaseModel):
+1
View File
@@ -114,6 +114,7 @@ class BroadcastResponse(BaseModel):
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: int | None = None
admin_name: str | None = None
+83 -9
View File
@@ -3,7 +3,7 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
CampaignBonusType = Literal['balance', 'subscription', 'none', 'tariff']
@@ -27,10 +27,11 @@ 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:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class CampaignListResponse(BaseModel):
@@ -60,22 +61,25 @@ class CampaignDetailResponse(BaseModel):
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: int | None = None
created_at: datetime
updated_at: datetime | None = None
# Deep link
deep_link: str | None = None
web_link: str | None = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class CampaignCreateRequest(BaseModel):
"""Request to create a campaign."""
name: str = Field(..., min_length=1, max_length=255)
start_parameter: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$')
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
@@ -88,13 +92,15 @@ class CampaignCreateRequest(BaseModel):
# Tariff bonus
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: str | None = Field(None, min_length=1, max_length=255)
start_parameter: str | None = Field(None, min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$')
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
@@ -107,6 +113,8 @@ class CampaignUpdateRequest(BaseModel):
# Tariff bonus
tariff_id: int | None = None
tariff_duration_days: int | None = Field(None, ge=1)
# Partner
partner_user_id: int | None = None
class CampaignToggleResponse(BaseModel):
@@ -147,6 +155,7 @@ class CampaignStatisticsResponse(BaseModel):
trial_conversion_rate: float = 0.0
# Deep link
deep_link: str | None = None
web_link: str | None = None
class CampaignRegistrationItem(BaseModel):
@@ -168,8 +177,7 @@ class CampaignRegistrationItem(BaseModel):
has_subscription: bool = False
has_paid: bool = False
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class CampaignRegistrationsResponse(BaseModel):
@@ -194,6 +202,14 @@ 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."""
@@ -201,3 +217,61 @@ class ServerSquadInfo(BaseModel):
squad_uuid: str
display_name: str
country_code: str | None = None
# --- Admin campaign chart data schemas ---
class AdminDailyStatItem(BaseModel):
"""Daily stat item for admin campaign charts."""
date: str
referrals_count: int = 0 # actually registrations, named for frontend compat
earnings_kopeks: int = 0 # actually revenue, named for frontend compat
class AdminPeriodStats(BaseModel):
"""Period stats for admin campaign comparison."""
days: int
referrals_count: int = 0
earnings_kopeks: int = 0
class AdminPeriodChange(BaseModel):
"""Change metrics between periods."""
absolute: int = 0
percent: float = 0.0
trend: str = 'stable'
class AdminPeriodComparison(BaseModel):
"""Comparison of current vs previous period."""
current: AdminPeriodStats
previous: AdminPeriodStats
referrals_change: AdminPeriodChange
earnings_change: AdminPeriodChange
class AdminTopRegistrationItem(BaseModel):
"""Top user by spending in a campaign."""
id: int
full_name: str
created_at: datetime
has_paid: bool = False
is_active: bool = False
total_earnings_kopeks: int = 0 # actually total spending, named for frontend compat
class AdminCampaignChartDataResponse(BaseModel):
"""Chart data for admin campaign stats page."""
campaign_id: int
total_deposits_kopeks: int = 0
total_spending_kopeks: int = 0
daily_stats: list[AdminDailyStatItem] = []
period_comparison: AdminPeriodComparison
top_registrations: list[AdminTopRegistrationItem] = []
+84
View File
@@ -0,0 +1,84 @@
"""Pydantic v2 schemas for channel subscription management."""
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.database.crud.required_channel import validate_channel_id as _validate_channel_id_format
def _validate_channel_link_value(v: str | None) -> str | None:
"""Shared channel_link validation: t.me URL, @username auto-convert, http->https upgrade."""
if v is None:
return v
v = v.strip()
if v.startswith('http://t.me/'):
v = v.replace('http://', 'https://', 1)
if v.startswith('https://t.me/'):
return v
if v.startswith('@'):
return f'https://t.me/{v[1:]}'
raise ValueError('channel_link must be a t.me URL or @username')
class ChannelResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
channel_id: str
channel_link: str | None
title: str | None
is_active: bool
sort_order: int
disable_trial_on_leave: bool
disable_paid_on_leave: bool
class ChannelListResponse(BaseModel):
items: list[ChannelResponse]
total: int
class ChannelCreateRequest(BaseModel):
channel_id: str
channel_link: str | None = None
title: str | None = Field(None, max_length=255)
disable_trial_on_leave: bool = True
disable_paid_on_leave: bool = False
@field_validator('channel_id')
@classmethod
def validate_channel_id(cls, v: str) -> str:
return _validate_channel_id_format(v)
@field_validator('channel_link')
@classmethod
def validate_channel_link(cls, v: str | None) -> str | None:
return _validate_channel_link_value(v)
class ChannelUpdateRequest(BaseModel):
channel_id: str | None = None
channel_link: str | None = None
title: str | None = Field(None, max_length=255)
is_active: bool | None = None
sort_order: int | None = None
disable_trial_on_leave: bool | None = None
disable_paid_on_leave: bool | None = None
@field_validator('channel_id')
@classmethod
def validate_channel_id(cls, v: str | None) -> str | None:
if v is None:
return v
return _validate_channel_id_format(v)
@field_validator('channel_link')
@classmethod
def validate_channel_link(cls, v: str | None) -> str | None:
return _validate_channel_link_value(v)
class ChannelSubscriptionStatus(BaseModel):
channel_id: str
channel_link: str | None
title: str | None
is_subscribed: bool
+240
View File
@@ -0,0 +1,240 @@
"""Partner system schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, 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, le=2_000_000_000)
desired_commission_percent: int | None = Field(None, ge=1, le=100)
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
desired_commission_percent: int | None = None
admin_comment: str | None = None
approved_commission_percent: int | None = None
created_at: datetime
processed_at: datetime | None = None
model_config = ConfigDict(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
# Per-campaign statistics
registrations_count: int = 0
referrals_count: int = 0
earnings_kopeks: int = 0
class PartnerStatusResponse(BaseModel):
"""Partner status for current user."""
partner_status: str
commission_percent: int | None = None
latest_application: PartnerApplicationInfo | None = None
campaigns: list[PartnerCampaignInfo] = []
# ==================== Campaign detailed stats ====================
class DailyStatItem(BaseModel):
"""Single day of campaign stats."""
date: str
referrals_count: int = 0
earnings_kopeks: int = 0
class PeriodStats(BaseModel):
"""Stats for a single period."""
days: int
referrals_count: int = 0
earnings_kopeks: int = 0
class PeriodChange(BaseModel):
"""Change metrics between periods."""
absolute: int = 0
percent: float = 0.0
trend: str = 'stable'
class PeriodComparison(BaseModel):
"""Comparison between current and previous period."""
current: PeriodStats
previous: PeriodStats
referrals_change: PeriodChange
earnings_change: PeriodChange
class CampaignReferralItem(BaseModel):
"""Referral user in campaign stats."""
id: int
full_name: str
created_at: datetime
has_paid: bool = False
is_active: bool = False
total_earnings_kopeks: int = 0
class PartnerCampaignDetailedStats(BaseModel):
"""Detailed stats for a single campaign."""
campaign_id: int
campaign_name: str
# Summary
registrations_count: int = 0
referrals_count: int = 0
earnings_kopeks: int = 0
conversion_rate: float = 0.0
# Period earnings
earnings_today: int = 0
earnings_week: int = 0
earnings_month: int = 0
# Daily chart (30 days)
daily_stats: list[DailyStatItem] = []
# Period comparison (this week vs last week)
period_comparison: PeriodComparison
# Top referrals
top_referrals: list[CampaignReferralItem] = []
# ==================== Admin-facing ====================
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
desired_commission_percent: 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
registrations_count: int = 0
referrals_count: int = 0
earnings_kopeks: int = 0
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)
+5
View File
@@ -15,6 +15,9 @@ class ReferralInfoResponse(BaseModel):
total_earnings_kopeks: int
total_earnings_rubles: float
commission_percent: int
available_balance_kopeks: int = 0
available_balance_rubles: float = 0
withdrawn_kopeks: int = 0
class ReferralItemResponse(BaseModel):
@@ -47,6 +50,7 @@ class ReferralEarningResponse(BaseModel):
reason: str
referral_username: str | None = None
referral_first_name: str | None = None
campaign_name: str | None = None
created_at: datetime
class Config:
@@ -76,3 +80,4 @@ class ReferralTermsResponse(BaseModel):
first_topup_bonus_rubles: float
inviter_bonus_kopeks: int
inviter_bonus_rubles: float
partner_section_visible: bool = True
+11 -8
View File
@@ -56,6 +56,7 @@ class SubscriptionData(BaseModel):
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
@@ -85,7 +86,7 @@ class RenewalOptionResponse(BaseModel):
class RenewalRequest(BaseModel):
"""Request to renew subscription."""
period_days: int = Field(..., description='Renewal period in days')
period_days: int = Field(..., ge=1, le=3650, description='Renewal period in days')
class TrafficPackageResponse(BaseModel):
@@ -100,13 +101,13 @@ class TrafficPackageResponse(BaseModel):
class TrafficPurchaseRequest(BaseModel):
"""Request to purchase additional traffic."""
gb: int = Field(..., ge=0, description='GB to purchase (0 = unlimited)')
gb: int = Field(..., ge=0, le=100_000, description='GB to purchase (0 = unlimited)')
class DevicePurchaseRequest(BaseModel):
"""Request to purchase additional device slots."""
devices: int = Field(..., ge=1, description='Number of additional devices')
devices: int = Field(..., ge=1, le=100, description='Number of additional devices')
class AutopayUpdateRequest(BaseModel):
@@ -136,10 +137,10 @@ class PurchaseSelectionRequest(BaseModel):
"""User's selection for subscription purchase."""
period_id: str | None = Field(None, description="Period ID like 'days:30'")
period_days: int | None = Field(None, description='Period in days')
traffic_value: int | None = Field(None, description='Traffic in GB (0 = unlimited)')
period_days: int | None = Field(None, ge=1, le=3650, description='Period in days')
traffic_value: int | None = Field(None, ge=0, le=100_000, description='Traffic in GB (0 = unlimited)')
servers: list[str] | None = Field(default_factory=list, description='Server UUIDs')
devices: int | None = Field(None, description='Device limit')
devices: int | None = Field(None, ge=1, le=100, description='Device limit')
class PurchasePreviewRequest(BaseModel):
@@ -155,5 +156,7 @@ class TariffPurchaseRequest(BaseModel):
"""Request to purchase a tariff."""
tariff_id: int = Field(..., description='Tariff ID to purchase')
period_days: int = Field(..., description='Period in days')
traffic_gb: int | None = Field(None, ge=0, description='Custom traffic in GB (for custom_traffic_enabled tariffs)')
period_days: int = Field(..., ge=1, le=3650, description='Period in days')
traffic_gb: int | None = Field(
None, ge=0, le=100_000, description='Custom traffic in GB (for custom_traffic_enabled tariffs)'
)
+17
View File
@@ -112,6 +112,8 @@ class TariffDetailResponse(BaseModel):
daily_price_kopeks: int = 0
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = None
created_at: datetime
updated_at: datetime | None = None
@@ -119,6 +121,17 @@ class TariffDetailResponse(BaseModel):
from_attributes = True
class ExternalSquadInfoResponse(BaseModel):
"""External squad info from RemnaWave."""
uuid: str
name: str
members_count: int
UUID_PATTERN = 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}$'
class TariffCreateRequest(BaseModel):
"""Request to create a tariff."""
@@ -155,6 +168,8 @@ class TariffCreateRequest(BaseModel):
daily_price_kopeks: int = Field(0, ge=0)
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
class TariffUpdateRequest(BaseModel):
@@ -192,6 +207,8 @@ class TariffUpdateRequest(BaseModel):
daily_price_kopeks: int | None = Field(None, ge=0)
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
# Внешний сквад RemnaWave
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
class TariffSortOrderRequest(BaseModel):
+3 -1
View File
@@ -261,7 +261,9 @@ class UserNodeUsageResponse(BaseModel):
class UpdateBalanceRequest(BaseModel):
"""Request to update user balance."""
amount_kopeks: int = Field(..., description='Amount in kopeks (positive to add, negative to subtract)')
amount_kopeks: int = Field(
..., ge=-2_000_000_000, le=2_000_000_000, description='Amount in kopeks (positive to add, negative to subtract)'
)
description: str = Field(default='Admin balance adjustment', max_length=500)
create_transaction: bool = Field(default=True, description='Create transaction record')
+1
View File
@@ -60,6 +60,7 @@ class WheelConfigResponse(BaseModel):
can_pay_days: bool = False
user_balance_kopeks: int = 0
required_balance_kopeks: int = 0
has_subscription: bool = False
class SpinAvailabilityResponse(BaseModel):
+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)
+36 -17
View File
@@ -1,8 +1,10 @@
"""Email service for sending verification and password reset emails."""
import html
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid
import structlog
@@ -30,7 +32,7 @@ class EmailService:
def _get_smtp_connection(self) -> smtplib.SMTP:
"""Create and return SMTP connection."""
smtp = smtplib.SMTP(self.host, self.port)
smtp = smtplib.SMTP(self.host, self.port, timeout=30)
smtp.ehlo()
if self.use_tls:
@@ -69,11 +71,19 @@ class EmailService:
logger.warning('SMTP is not configured, cannot send email')
return False
# Defensive: strip newlines to prevent header injection
to_email = to_email.strip().replace('\n', '').replace('\r', '')
subject = subject.replace('\n', '').replace('\r', '')
try:
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f'{self.from_name} <{self.from_email}>'
safe_from_name = self.from_name.replace('\n', '').replace('\r', '') if self.from_name else ''
safe_from_email = self.from_email.replace('\n', '').replace('\r', '') if self.from_email else ''
msg['From'] = f'{safe_from_name} <{safe_from_email}>'
msg['To'] = to_email
msg['Date'] = formatdate(localtime=False)
msg['Message-ID'] = make_msgid(domain=self.from_email.split('@')[-1])
# Plain text version
if body_text is None:
@@ -133,10 +143,13 @@ class EmailService:
full_url = f'{verification_url}?token={verification_token}'
expire_hours = settings.get_cabinet_email_verification_expire_hours()
# Escape user-provided values for HTML context
safe_username = html.escape(username) if username else None
# Localized content
texts = {
'ru': {
'greeting': f'Здравствуйте{", " + username if username else ""}!',
'greeting': f'Здравствуйте{", " + safe_username if safe_username else ""}!',
'subject': 'Подтверждение email адреса',
'intro': 'Спасибо за регистрацию! Пожалуйста, подтвердите ваш email адрес, нажав на кнопку ниже:',
'button': 'Подтвердить email',
@@ -146,7 +159,7 @@ class EmailService:
'regards': 'С уважением,',
},
'en': {
'greeting': f'Hello{", " + username if username else ""}!',
'greeting': f'Hello{", " + safe_username if safe_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',
@@ -156,7 +169,7 @@ class EmailService:
'regards': 'Best regards,',
},
'zh': {
'greeting': f'您好{", " + username if username else ""}!',
'greeting': f'您好{", " + safe_username if safe_username else ""}!',
'subject': '验证您的邮箱地址',
'intro': '感谢您的注册!请点击下方按钮验证您的邮箱地址:',
'button': '验证邮箱',
@@ -166,7 +179,7 @@ class EmailService:
'regards': '此致,',
},
'ua': {
'greeting': f'Вітаємо{", " + username if username else ""}!',
'greeting': f'Вітаємо{", " + safe_username if safe_username else ""}!',
'subject': 'Підтвердження email адреси',
'intro': 'Дякуємо за реєстрацію! Будь ласка, підтвердіть вашу email адресу, натиснувши на кнопку нижче:',
'button': 'Підтвердити email',
@@ -176,7 +189,7 @@ class EmailService:
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'greeting': f'سلام{", " + safe_username if safe_username else ""}!',
'subject': 'تایید آدرس ایمیل',
'intro': 'از ثبت‌نام شما سپاسگزاریم! لطفاً با کلیک روی دکمه زیر ایمیل خود را تایید کنید:',
'button': 'تایید ایمیل',
@@ -260,10 +273,13 @@ class EmailService:
full_url = f'{reset_url}?token={reset_token}'
expire_hours = settings.get_cabinet_password_reset_expire_hours()
# Escape user-provided values for HTML context
safe_username = html.escape(username) if username else None
# Localized content
texts = {
'ru': {
'greeting': f'Здравствуйте{", " + username if username else ""}!',
'greeting': f'Здравствуйте{", " + safe_username if safe_username else ""}!',
'subject': 'Сброс пароля',
'intro': 'Мы получили запрос на сброс вашего пароля. Нажмите на кнопку ниже, чтобы установить новый пароль:',
'button': 'Сбросить пароль',
@@ -273,7 +289,7 @@ class EmailService:
'regards': 'С уважением,',
},
'en': {
'greeting': f'Hello{", " + username if username else ""}!',
'greeting': f'Hello{", " + safe_username if safe_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',
@@ -283,7 +299,7 @@ class EmailService:
'regards': 'Best regards,',
},
'zh': {
'greeting': f'您好{", " + username if username else ""}!',
'greeting': f'您好{", " + safe_username if safe_username else ""}!',
'subject': '重置您的密码',
'intro': '我们收到了重置您密码的请求。点击下方按钮设置新密码:',
'button': '重置密码',
@@ -293,7 +309,7 @@ class EmailService:
'regards': '此致,',
},
'ua': {
'greeting': f'Вітаємо{", " + username if username else ""}!',
'greeting': f'Вітаємо{", " + safe_username if safe_username else ""}!',
'subject': 'Скидання пароля',
'intro': 'Ми отримали запит на скидання вашого пароля. Натисніть на кнопку нижче, щоб встановити новий пароль:',
'button': 'Скинути пароль',
@@ -303,7 +319,7 @@ class EmailService:
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'greeting': f'سلام{", " + safe_username if safe_username else ""}!',
'subject': 'بازنشانی رمز عبور',
'intro': 'درخواستی برای بازنشانی رمز عبور شما دریافت شد. برای تعیین رمز جدید روی دکمه زیر بزنید:',
'button': 'بازنشانی رمز عبور',
@@ -385,9 +401,12 @@ class EmailService:
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
# Escape user-provided values for HTML context
safe_username = html.escape(username) if username else None
texts = {
'ru': {
'greeting': f'Здравствуйте{", " + username if username else ""}!',
'greeting': f'Здравствуйте{", " + safe_username if safe_username else ""}!',
'subject': 'Код подтверждения для смены email',
'intro': 'Вы запросили смену email адреса. Используйте код ниже для подтверждения:',
'code_label': 'Ваш код подтверждения:',
@@ -396,7 +415,7 @@ class EmailService:
'regards': 'С уважением,',
},
'en': {
'greeting': f'Hello{", " + username if username else ""}!',
'greeting': f'Hello{", " + safe_username if safe_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:',
@@ -405,7 +424,7 @@ class EmailService:
'regards': 'Best regards,',
},
'zh': {
'greeting': f'您好{", " + username if username else ""}!',
'greeting': f'您好{", " + safe_username if safe_username else ""}!',
'subject': '邮箱更换验证码',
'intro': '您请求更换邮箱地址。请使用以下验证码确认:',
'code_label': '您的验证码:',
@@ -414,7 +433,7 @@ class EmailService:
'regards': '此致,',
},
'ua': {
'greeting': f'Вітаємо{", " + username if username else ""}!',
'greeting': f'Вітаємо{", " + safe_username if safe_username else ""}!',
'subject': 'Код підтвердження для зміни email',
'intro': 'Ви запросили зміну email адреси. Використовуйте код нижче для підтвердження:',
'code_label': 'Ваш код підтвердження:',
@@ -423,7 +442,7 @@ class EmailService:
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'greeting': f'سلام{", " + safe_username if safe_username else ""}!',
'subject': 'کد تایید تغییر ایمیل',
'intro': 'شما درخواست تغییر ایمیل داده‌اید. برای تایید از کد زیر استفاده کنید:',
'code_label': 'کد تایید شما:',
@@ -4,7 +4,8 @@ Service for managing email template overrides stored in the database.
Custom templates override the hardcoded defaults from email_templates.py.
"""
from datetime import datetime
import html
from datetime import UTC, datetime
from typing import Any
import structlog
@@ -124,7 +125,7 @@ async def save_template_override(
)
row = existing.fetchone()
now = datetime.utcnow()
now = datetime.now(UTC)
if row:
# Update
@@ -195,7 +196,7 @@ async def get_rendered_override(
# 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))
body_html = body_html.replace(f'{{{key}}}', html.escape(str(value)))
rendered = templates._get_base_template(body_html, language)
subject = override['subject']
+648 -9
View File
@@ -4,6 +4,7 @@ Email notification templates for different notification types.
Supports multiple languages: ru, en, zh, ua, fa
"""
import html
from typing import Any
from app.config import settings
@@ -53,10 +54,18 @@ class EmailNotificationTemplates:
NotificationType.WARNING_NOTIFICATION: self._warning_template,
NotificationType.REFERRAL_BONUS: self._referral_bonus_template,
NotificationType.REFERRAL_REGISTERED: self._referral_registered_template,
NotificationType.PARTNER_APPLICATION_APPROVED: self._partner_approved_template,
NotificationType.PARTNER_APPLICATION_REJECTED: self._partner_rejected_template,
NotificationType.WITHDRAWAL_APPROVED: self._withdrawal_approved_template,
NotificationType.WITHDRAWAL_REJECTED: self._withdrawal_rejected_template,
NotificationType.TRAFFIC_RESET: self._traffic_reset_template,
NotificationType.PAYMENT_RECEIVED: self._payment_received_template,
NotificationType.EMAIL_VERIFICATION: self._email_verification_template,
NotificationType.PASSWORD_RESET: self._password_reset_template,
NotificationType.GUEST_SUBSCRIPTION_DELIVERED: self._guest_subscription_delivered_template,
NotificationType.GUEST_ACTIVATION_REQUIRED: self._guest_activation_required_template,
NotificationType.GUEST_GIFT_RECEIVED: self._guest_gift_received_template,
NotificationType.GUEST_CABINET_CREDENTIALS: self._guest_cabinet_credentials_template,
}
template_func = template_map.get(notification_type)
@@ -528,7 +537,7 @@ class EmailNotificationTemplates:
def _autopay_failed_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for failed autopay notification."""
reason = context.get('reason', '')
reason = html.escape(context.get('reason', ''))
subjects = {
'ru': 'Ошибка автопродления',
@@ -715,7 +724,7 @@ class EmailNotificationTemplates:
def _ban_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for ban notification."""
reason = context.get('reason', '')
reason = html.escape(context.get('reason', ''))
subjects = {
'ru': 'Аккаунт заблокирован',
@@ -783,7 +792,7 @@ class EmailNotificationTemplates:
def _warning_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for warning notification."""
message = context.get('message', '')
message = html.escape(context.get('message', ''))
subjects = {
'ru': 'Предупреждение',
@@ -819,7 +828,7 @@ class EmailNotificationTemplates:
def _referral_bonus_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for referral bonus notification."""
bonus = context.get('formatted_bonus', f'{context.get("bonus_rubles", 0):.2f}')
referral_name = context.get('referral_name', '')
referral_name = html.escape(context.get('referral_name', ''))
subjects = {
'ru': f'Реферальный бонус: +{bonus}',
@@ -856,7 +865,7 @@ class EmailNotificationTemplates:
def _referral_registered_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for new referral registered notification."""
referral_name = context.get('referral_name', '')
referral_name = html.escape(context.get('referral_name', ''))
subjects = {
'ru': 'Новый реферал зарегистрирован',
@@ -889,6 +898,249 @@ class EmailNotificationTemplates:
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
# ============================================================================
# Partner Templates
# ============================================================================
def _partner_approved_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for partner application approved notification."""
commission = context.get('commission_percent', 0)
comment = html.escape(context.get('comment', ''))
subjects = {
'ru': 'Заявка на партнёрство одобрена',
'en': 'Partner Application Approved',
'zh': '合作伙伴申请已批准',
'ua': 'Заявка на партнерство схвалена',
}
bodies = {
'ru': f"""
<h2>Заявка на партнёрство одобрена!</h2>
<div class="highlight success">
<p>Ваша заявка на партнёрство была одобрена.</p>
<p>Ваша комиссия: <strong>{commission}%</strong></p>
{f'<p>Комментарий: {comment}</p>' if comment else ''}
</div>
<p>Теперь вы можете приглашать пользователей и получать вознаграждение!</p>
{self._get_cabinet_button(language)}
""",
'en': f"""
<h2>Partner Application Approved!</h2>
<div class="highlight success">
<p>Your partner application has been approved.</p>
<p>Your commission rate: <strong>{commission}%</strong></p>
{f'<p>Comment: {comment}</p>' if comment else ''}
</div>
<p>You can now invite users and earn rewards!</p>
{self._get_cabinet_button(language)}
""",
'zh': f"""
<h2>合作伙伴申请已批准</h2>
<div class="highlight success">
<p>您的合作伙伴申请已获批准</p>
<p>您的佣金比例: <strong>{commission}%</strong></p>
{f'<p>备注: {comment}</p>' if comment else ''}
</div>
<p>您现在可以邀请用户并获得奖励</p>
{self._get_cabinet_button(language)}
""",
'ua': f"""
<h2>Заявка на партнерство схвалена!</h2>
<div class="highlight success">
<p>Вашу заявку на партнерство було схвалено.</p>
<p>Ваша комісія: <strong>{commission}%</strong></p>
{f'<p>Коментар: {comment}</p>' if comment else ''}
</div>
<p>Тепер ви можете запрошувати користувачів та отримувати винагороду!</p>
{self._get_cabinet_button(language)}
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
def _partner_rejected_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for partner application rejected notification."""
comment = html.escape(context.get('comment', ''))
subjects = {
'ru': 'Заявка на партнёрство отклонена',
'en': 'Partner Application Rejected',
'zh': '合作伙伴申请被拒绝',
'ua': 'Заявка на партнерство відхилена',
}
bodies = {
'ru': f"""
<h2>Заявка на партнёрство отклонена</h2>
<div class="highlight danger">
<p>К сожалению, ваша заявка на партнёрство была отклонена.</p>
{f'<p>Причина: {comment}</p>' if comment else ''}
</div>
<p>Вы можете подать новую заявку позже.</p>
{self._get_cabinet_button(language)}
""",
'en': f"""
<h2>Partner Application Rejected</h2>
<div class="highlight danger">
<p>Unfortunately, your partner application has been rejected.</p>
{f'<p>Reason: {comment}</p>' if comment else ''}
</div>
<p>You can submit a new application later.</p>
{self._get_cabinet_button(language)}
""",
'zh': f"""
<h2>合作伙伴申请被拒绝</h2>
<div class="highlight danger">
<p>很抱歉您的合作伙伴申请已被拒绝</p>
{f'<p>原因: {comment}</p>' if comment else ''}
</div>
<p>您可以稍后提交新的申请</p>
{self._get_cabinet_button(language)}
""",
'ua': f"""
<h2>Заявка на партнерство відхилена</h2>
<div class="highlight danger">
<p>На жаль, вашу заявку на партнерство було відхилено.</p>
{f'<p>Причина: {comment}</p>' if comment else ''}
</div>
<p>Ви можете подати нову заявку пізніше.</p>
{self._get_cabinet_button(language)}
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
# ============================================================================
# Withdrawal Templates
# ============================================================================
def _withdrawal_approved_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for withdrawal approved notification."""
amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f}')
comment = html.escape(context.get('comment', ''))
subjects = {
'ru': f'Запрос на вывод {amount} одобрен',
'en': f'Withdrawal request for {amount} approved',
'zh': f'提现请求 {amount} 已批准',
'ua': f'Запит на виведення {amount} схвалено',
}
bodies = {
'ru': f"""
<h2>Запрос на вывод одобрен!</h2>
<div class="highlight success">
<p>Ваш запрос на вывод средств одобрен.</p>
<p>Сумма: <span class="amount">{amount}</span></p>
{f'<p>Комментарий: {comment}</p>' if comment else ''}
</div>
<p>Средства будут переведены в ближайшее время.</p>
{self._get_cabinet_button(language)}
""",
'en': f"""
<h2>Withdrawal Request Approved!</h2>
<div class="highlight success">
<p>Your withdrawal request has been approved.</p>
<p>Amount: <span class="amount">{amount}</span></p>
{f'<p>Comment: {comment}</p>' if comment else ''}
</div>
<p>Funds will be transferred shortly.</p>
{self._get_cabinet_button(language)}
""",
'zh': f"""
<h2>提现请求已批准</h2>
<div class="highlight success">
<p>您的提现请求已获批准</p>
<p>金额: <span class="amount">{amount}</span></p>
{f'<p>备注: {comment}</p>' if comment else ''}
</div>
<p>资金将很快转入</p>
{self._get_cabinet_button(language)}
""",
'ua': f"""
<h2>Запит на виведення схвалено!</h2>
<div class="highlight success">
<p>Ваш запит на виведення коштів було схвалено.</p>
<p>Сума: <span class="amount">{amount}</span></p>
{f'<p>Коментар: {comment}</p>' if comment else ''}
</div>
<p>Кошти будуть переведені найближчим часом.</p>
{self._get_cabinet_button(language)}
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
def _withdrawal_rejected_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for withdrawal rejected notification."""
amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f}')
comment = html.escape(context.get('comment', ''))
subjects = {
'ru': f'Запрос на вывод {amount} отклонён',
'en': f'Withdrawal request for {amount} rejected',
'zh': f'提现请求 {amount} 被拒绝',
'ua': f'Запит на виведення {amount} відхилено',
}
bodies = {
'ru': f"""
<h2>Запрос на вывод отклонён</h2>
<div class="highlight danger">
<p>Ваш запрос на вывод средств был отклонён.</p>
<p>Сумма: <strong>{amount}</strong></p>
{f'<p>Причина: {comment}</p>' if comment else ''}
</div>
<p>Средства возвращены на ваш баланс.</p>
{self._get_cabinet_button(language)}
""",
'en': f"""
<h2>Withdrawal Request Rejected</h2>
<div class="highlight danger">
<p>Your withdrawal request has been rejected.</p>
<p>Amount: <strong>{amount}</strong></p>
{f'<p>Reason: {comment}</p>' if comment else ''}
</div>
<p>Funds have been returned to your balance.</p>
{self._get_cabinet_button(language)}
""",
'zh': f"""
<h2>提现请求被拒绝</h2>
<div class="highlight danger">
<p>您的提现请求已被拒绝</p>
<p>金额: <strong>{amount}</strong></p>
{f'<p>原因: {comment}</p>' if comment else ''}
</div>
<p>资金已退回您的余额</p>
{self._get_cabinet_button(language)}
""",
'ua': f"""
<h2>Запит на виведення відхилено</h2>
<div class="highlight danger">
<p>Ваш запит на виведення коштів було відхилено.</p>
<p>Сума: <strong>{amount}</strong></p>
{f'<p>Причина: {comment}</p>' if comment else ''}
</div>
<p>Кошти повернуто на ваш баланс.</p>
{self._get_cabinet_button(language)}
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
# ============================================================================
# Payment Templates
# ============================================================================
@@ -937,8 +1189,8 @@ class EmailNotificationTemplates:
def _email_verification_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for email verification."""
username = context.get('username', '')
verification_url = context.get('verification_url', '#')
username = html.escape(context.get('username', ''))
verification_url = html.escape(context.get('verification_url', '#'))
expire_hours = context.get('expire_hours', 24)
subjects = {
@@ -1009,8 +1261,8 @@ class EmailNotificationTemplates:
def _password_reset_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for password reset."""
username = context.get('username', '')
reset_url = context.get('reset_url', '#')
username = html.escape(context.get('username', ''))
reset_url = html.escape(context.get('reset_url', '#'))
expire_hours = context.get('expire_hours', 1)
subjects = {
@@ -1079,6 +1331,393 @@ class EmailNotificationTemplates:
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
# ============================================================================
# Guest Purchase Templates
# ============================================================================
def _guest_subscription_delivered_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for guest subscription delivered notification."""
tariff_name = html.escape(context.get('tariff_name', ''))
period_days = context.get('period_days', 0)
cabinet_url = html.escape(context.get('cabinet_url', ''))
subjects = {
'ru': 'Ваша VPN подписка готова',
'en': 'Your VPN subscription is ready',
'zh': '您的VPN订阅已准备就绪',
'ua': 'Ваша VPN підписка готова',
'fa': 'اشتراک VPN شما آماده است',
}
bodies = {
'ru': f"""
<h2>Ваша VPN подписка готова!</h2>
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Период: <strong>{period_days} дней</strong></p>
</div>
<p>Подписка активирована в вашем личном кабинете.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
""",
'en': f"""
<h2>Your VPN subscription is ready!</h2>
<div class="highlight success">
<p>Plan: <strong>{tariff_name}</strong></p>
<p>Period: <strong>{period_days} days</strong></p>
</div>
<p>Your subscription has been activated in your cabinet.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
""",
'zh': f"""
<h2>您的VPN订阅已准备就绪</h2>
<div class="highlight success">
<p>套餐: <strong>{tariff_name}</strong></p>
<p>期限: <strong>{period_days} </strong></p>
</div>
<p>订阅已在您的个人中心激活</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
""",
'ua': f"""
<h2>Ваша VPN підписка готова!</h2>
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Період: <strong>{period_days} днів</strong></p>
</div>
<p>Підписка активована у вашому особистому кабінеті.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
""",
'fa': f"""
<h2>اشتراک VPN شما آماده است!</h2>
<div class="highlight success">
<p>طرح: <strong>{tariff_name}</strong></p>
<p>مدت: <strong>{period_days} روز</strong></p>
</div>
<p>اشتراک شما در پنل کاربری فعال شده است.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
def _guest_activation_required_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for guest purchase pending activation (user already has a subscription)."""
tariff_name = html.escape(context.get('tariff_name', ''))
period_days = context.get('period_days', 0)
success_page_url = html.escape(context.get('success_page_url', ''))
gift_message = context.get('gift_message')
is_gift = context.get('is_gift', False)
gift_block_ru = ''
gift_block_en = ''
gift_block_zh = ''
gift_block_ua = ''
gift_block_fa = ''
if is_gift and gift_message:
escaped_msg = html.escape(gift_message)
gift_block_ru = f'<div class="highlight"><p><em>Сообщение: {escaped_msg}</em></p></div>'
gift_block_en = f'<div class="highlight"><p><em>Message: {escaped_msg}</em></p></div>'
gift_block_zh = f'<div class="highlight"><p><em>留言: {escaped_msg}</em></p></div>'
gift_block_ua = f'<div class="highlight"><p><em>Повідомлення: {escaped_msg}</em></p></div>'
gift_block_fa = f'<div class="highlight"><p><em>پیام: {escaped_msg}</em></p></div>'
subjects = {
'ru': 'Требуется активация подписки',
'en': 'Subscription activation required',
'zh': '需要激活订阅',
'ua': 'Потрібна активація підписки',
'fa': 'فعال‌سازی اشتراک لازم است',
}
bodies = {
'ru': f"""
<h2>Требуется активация подписки</h2>
{gift_block_ru}
<div class="highlight">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Период: <strong>{period_days} дней</strong></p>
</div>
<p class="warning">У вас уже есть активная подписка. Активация новой заменит текущую.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Активировать подписку</a></p>
""",
'en': f"""
<h2>Subscription activation required</h2>
{gift_block_en}
<div class="highlight">
<p>Plan: <strong>{tariff_name}</strong></p>
<p>Period: <strong>{period_days} days</strong></p>
</div>
<p class="warning">You already have an active subscription. Activating will replace your current one.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Activate subscription</a></p>
""",
'zh': f"""
<h2>需要激活订阅</h2>
{gift_block_zh}
<div class="highlight">
<p>套餐: <strong>{tariff_name}</strong></p>
<p>期限: <strong>{period_days} </strong></p>
</div>
<p class="warning">您已有活跃订阅激活新订阅将替换当前订阅</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">激活订阅</a></p>
""",
'ua': f"""
<h2>Потрібна активація підписки</h2>
{gift_block_ua}
<div class="highlight">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Період: <strong>{period_days} днів</strong></p>
</div>
<p class="warning">У вас вже є активна підписка. Активація нової замінить поточну.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">Активувати підписку</a></p>
""",
'fa': f"""
<h2>فعالسازی اشتراک لازم است</h2>
{gift_block_fa}
<div class="highlight">
<p>طرح: <strong>{tariff_name}</strong></p>
<p>مدت: <strong>{period_days} روز</strong></p>
</div>
<p class="warning">شما از قبل اشتراک فعالی دارید. فعالسازی اشتراک جدید جایگزین فعلی خواهد شد.</p>
<p style="text-align: center;"><a href="{success_page_url}" class="button">فعالسازی اشتراک</a></p>
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
def _guest_gift_received_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for gift subscription received notification."""
tariff_name = html.escape(context.get('tariff_name', ''))
period_days = context.get('period_days', 0)
gift_message = context.get('gift_message')
cabinet_password = context.get('cabinet_password')
cabinet_email = html.escape(context.get('cabinet_email', ''))
cabinet_url = html.escape(context.get('cabinet_url', ''))
# Credentials block for gift recipients who got a new cabinet account
cred_block = {'ru': '', 'en': '', 'zh': '', 'ua': '', 'fa': ''}
if cabinet_password and cabinet_email:
escaped_pw = html.escape(cabinet_password)
cred_block = {
'ru': f"""
<div class="highlight">
<p><strong>Данные для входа в личный кабинет:</strong></p>
<p>Email: <code>{cabinet_email}</code></p>
<p>Пароль: <code>{escaped_pw}</code></p>
</div>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
""",
'en': f"""
<div class="highlight">
<p><strong>Your cabinet login credentials:</strong></p>
<p>Email: <code>{cabinet_email}</code></p>
<p>Password: <code>{escaped_pw}</code></p>
</div>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
""",
'zh': f"""
<div class="highlight">
<p><strong>个人中心登录信息</strong></p>
<p>邮箱: <code>{cabinet_email}</code></p>
<p>密码: <code>{escaped_pw}</code></p>
</div>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
""",
'ua': f"""
<div class="highlight">
<p><strong>Дані для входу в особистий кабінет:</strong></p>
<p>Email: <code>{cabinet_email}</code></p>
<p>Пароль: <code>{escaped_pw}</code></p>
</div>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
""",
'fa': f"""
<div class="highlight">
<p><strong>اطلاعات ورود به پنل کاربری:</strong></p>
<p>ایمیل: <code dir="ltr">{cabinet_email}</code></p>
<p>رمز عبور: <code dir="ltr">{escaped_pw}</code></p>
</div>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
""",
}
gift_block_ru = ''
gift_block_en = ''
gift_block_zh = ''
gift_block_ua = ''
gift_block_fa = ''
if gift_message:
escaped_msg = html.escape(gift_message)
gift_block_ru = f'<div class="highlight"><p><em>Сообщение: {escaped_msg}</em></p></div>'
gift_block_en = f'<div class="highlight"><p><em>Message: {escaped_msg}</em></p></div>'
gift_block_zh = f'<div class="highlight"><p><em>留言: {escaped_msg}</em></p></div>'
gift_block_ua = f'<div class="highlight"><p><em>Повідомлення: {escaped_msg}</em></p></div>'
gift_block_fa = f'<div class="highlight"><p><em>پیام: {escaped_msg}</em></p></div>'
subjects = {
'ru': 'Вам подарили VPN подписку!',
'en': "You've been gifted a VPN subscription!",
'zh': '您收到了VPN订阅礼物!',
'ua': 'Вам подарували VPN підписку!',
'fa': 'یک اشتراک VPN به شما هدیه داده شده است!',
}
bodies = {
'ru': f"""
<h2>Вам подарили VPN подписку!</h2>
{gift_block_ru}
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Период: <strong>{period_days} дней</strong></p>
</div>
<p>Подписка активирована в личном кабинете.</p>
{cred_block['ru']}
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
""",
'en': f"""
<h2>You've been gifted a VPN subscription!</h2>
{gift_block_en}
<div class="highlight success">
<p>Plan: <strong>{tariff_name}</strong></p>
<p>Period: <strong>{period_days} days</strong></p>
</div>
<p>Your subscription has been activated in the cabinet.</p>
{cred_block['en']}
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
""",
'zh': f"""
<h2>您收到了VPN订阅礼物</h2>
{gift_block_zh}
<div class="highlight success">
<p>套餐: <strong>{tariff_name}</strong></p>
<p>期限: <strong>{period_days} </strong></p>
</div>
<p>订阅已在个人中心激活</p>
{cred_block['zh']}
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
""",
'ua': f"""
<h2>Вам подарували VPN підписку!</h2>
{gift_block_ua}
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Період: <strong>{period_days} днів</strong></p>
</div>
<p>Підписка активована в особистому кабінеті.</p>
{cred_block['ua']}
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
""",
'fa': f"""
<h2>یک اشتراک VPN به شما هدیه داده شده است!</h2>
{gift_block_fa}
<div class="highlight success">
<p>طرح: <strong>{tariff_name}</strong></p>
<p>مدت: <strong>{period_days} روز</strong></p>
</div>
<p>اشتراک در پنل کاربری فعال شده است.</p>
{cred_block['fa']}
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
def _guest_cabinet_credentials_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for cabinet login credentials email (sent separately from subscription)."""
cabinet_email = html.escape(context.get('cabinet_email', ''))
cabinet_password = html.escape(context.get('cabinet_password', ''))
cabinet_url = html.escape(context.get('cabinet_url', ''))
tariff_name = html.escape(context.get('tariff_name', ''))
period_days = context.get('period_days', 0)
subjects = {
'ru': 'Данные для входа в личный кабинет',
'en': 'Your cabinet login credentials',
'zh': '您的个人中心登录信息',
'ua': 'Дані для входу в особистий кабінет',
'fa': 'اطلاعات ورود به پنل کاربری',
}
bodies = {
'ru': f"""
<h2>Данные для входа в личный кабинет</h2>
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Период: <strong>{period_days} дней</strong></p>
</div>
<div class="highlight">
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
</div>
<p>Сохраните эти данные для входа. Вы можете изменить пароль в настройках кабинета.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
""",
'en': f"""
<h2>Your cabinet login credentials</h2>
<div class="highlight success">
<p>Plan: <strong>{tariff_name}</strong></p>
<p>Period: <strong>{period_days} days</strong></p>
</div>
<div class="highlight">
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Password:</strong> <code>{cabinet_password}</code></p>
</div>
<p>Save these credentials. You can change your password in cabinet settings.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
""",
'zh': f"""
<h2>您的个人中心登录信息</h2>
<div class="highlight success">
<p>套餐: <strong>{tariff_name}</strong></p>
<p>期限: <strong>{period_days} </strong></p>
</div>
<div class="highlight">
<p><strong>邮箱:</strong> <code>{cabinet_email}</code></p>
<p><strong>密码:</strong> <code>{cabinet_password}</code></p>
</div>
<p>请保存这些登录信息您可以在个人中心设置中更改密码</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
""",
'ua': f"""
<h2>Дані для входу в особистий кабінет</h2>
<div class="highlight success">
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Період: <strong>{period_days} днів</strong></p>
</div>
<div class="highlight">
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
</div>
<p>Збережіть ці дані. Ви можете змінити пароль у налаштуваннях кабінету.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
""",
'fa': f"""
<h2>اطلاعات ورود به پنل کاربری</h2>
<div class="highlight success">
<p>طرح: <strong>{tariff_name}</strong></p>
<p>مدت: <strong>{period_days} روز</strong></p>
</div>
<div class="highlight">
<p><strong>ایمیل:</strong> <code dir="ltr">{cabinet_email}</code></p>
<p><strong>رمز عبور:</strong> <code dir="ltr">{cabinet_password}</code></p>
</div>
<p>این اطلاعات را ذخیره کنید. میتوانید رمز عبور خود را در تنظیمات پنل تغییر دهید.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
# Singleton instance
email_notification_templates = EmailNotificationTemplates()
View File
+19
View File
@@ -0,0 +1,19 @@
"""Shared utility for generating campaign deep links and web links."""
from app.config import settings
def get_campaign_deep_link(start_parameter: str) -> str:
"""Generate a Telegram deep link for a campaign."""
bot_username = settings.get_bot_username()
if bot_username:
return f'https://t.me/{bot_username}?start={start_parameter}'
return f'?start={start_parameter}'
def get_campaign_web_link(start_parameter: str) -> str | None:
"""Generate a web app link for a campaign."""
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
if base_url:
return f'{base_url}/?campaign={start_parameter}'
return None
+43
View File
@@ -0,0 +1,43 @@
"""Locale resolution utilities for multi-locale landing page text fields."""
SUPPORTED_LOCALES: tuple[str, ...] = ('ru', 'en', 'zh', 'fa')
DEFAULT_LOCALE: str = 'ru'
def resolve_locale_text(data: dict[str, str] | str | None, lang: str = DEFAULT_LOCALE) -> str:
"""Resolve a localized text dict to a single string for the given language.
Fallback chain: requested lang -> 'ru' -> 'en' -> first available value -> ''.
Accepts plain strings for backward compatibility with pre-migration data.
"""
if data is None:
return ''
if isinstance(data, str):
return data
return data.get(lang) or data.get('ru') or data.get('en') or next(iter(data.values()), '')
def ensure_locale_dict(value: dict[str, str] | str | None) -> dict[str, str]:
"""Coerce a value to a locale dict. Plain strings become ``{'ru': value}``."""
if value is None:
return {}
if isinstance(value, str):
return {'ru': value} if value else {}
return value
def validate_locale_dict(
value: dict[str, str],
*,
max_length: int | None = None,
field_name: str = 'field',
) -> dict[str, str]:
"""Validate that all keys are supported locales and values respect length limits."""
for locale, text in value.items():
if locale not in SUPPORTED_LOCALES:
raise ValueError(f'Unsupported locale "{locale}" in {field_name}. Allowed: {", ".join(SUPPORTED_LOCALES)}')
if not isinstance(text, str):
raise ValueError(f'{field_name}[{locale}] must be a string')
if max_length is not None and len(text) > max_length:
raise ValueError(f'{field_name}[{locale}] exceeds max length {max_length} (got {len(text)})')
return value
+73 -16
View File
@@ -7,6 +7,7 @@ import re
from collections import defaultdict
from datetime import time
from pathlib import Path
from typing import Literal
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
@@ -66,8 +67,6 @@ class Settings(BaseSettings):
ADMIN_REPORTS_TOPIC_ID: int | None = None
ADMIN_REPORTS_SEND_TIME: str | None = None
CHANNEL_SUB_ID: str | None = None
CHANNEL_LINK: str | None = None
CHANNEL_IS_REQUIRED_SUB: bool = False
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
CHANNEL_REQUIRED_FOR_ALL: bool = False
@@ -136,6 +135,7 @@ class Settings(BaseSettings):
DEFAULT_DEVICE_LIMIT: int = 1
DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH'
RESET_TRAFFIC_ON_PAYMENT: bool = False
RESET_TRAFFIC_ON_TARIFF_SWITCH: bool = True
MAX_DEVICES_LIMIT: int = 20
TRIAL_WARNING_HOURS: int = 2
@@ -230,7 +230,9 @@ class Settings(BaseSettings):
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS: int = 100000 # Мин. сумма вывода (1000₽)
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS: int = 30 # Частота запросов на вывод
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE: bool = True # Только реф. баланс (False = реф + свой)
REFERRAL_WITHDRAWAL_REQUISITES_TEXT: str = '' # Текст-подсказка для реквизитов при выводе
REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID: int | None = None # Топик для уведомлений
REFERRAL_PARTNER_SECTION_VISIBLE: bool = True # Показывать раздел партнёрки в кабинете
# Настройки анализа на подозрительность
REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS: int = 50000 # Мин. сумма от 1 реферала (500₽)
@@ -317,6 +319,17 @@ class Settings(BaseSettings):
TELEGRAM_STARS_RATE_RUB: float = 1.3
TELEGRAM_STARS_DISPLAY_NAME: str = 'Telegram Stars'
# Telegram Login Widget (cabinet auth page)
TELEGRAM_WIDGET_SIZE: Literal['large', 'medium', 'small'] = 'large'
TELEGRAM_WIDGET_RADIUS: int = Field(default=8, ge=0, le=20)
TELEGRAM_WIDGET_USERPIC: bool = True
TELEGRAM_WIDGET_REQUEST_ACCESS: bool = True
# Telegram Login OIDC (new system via oauth.telegram.org)
TELEGRAM_OIDC_ENABLED: bool = False
TELEGRAM_OIDC_CLIENT_ID: str = ''
TELEGRAM_OIDC_CLIENT_SECRET: str = ''
TRIBUTE_ENABLED: bool = False
TRIBUTE_API_KEY: str | None = None
TRIBUTE_DONATE_LINK: str | None = None
@@ -500,6 +513,11 @@ class Settings(BaseSettings):
FREEKASSA_USE_API: bool = False
# Публичный IP сервера для Freekassa API (если не задан - определяется автоматически)
SERVER_PUBLIC_IP: str | None = None
# Раздельные методы оплаты Freekassa (отображаются как отдельные кнопки)
FREEKASSA_SBP_ENABLED: bool = False # СБП (QR код) — i=44
FREEKASSA_SBP_DISPLAY_NAME: str = 'СБП (QR код)'
FREEKASSA_CARD_ENABLED: bool = False # Карты РФ — i=36
FREEKASSA_CARD_DISPLAY_NAME: str = 'Карта РФ'
# KassaAI (api.fk.life) - отдельная платёжка
KASSA_AI_ENABLED: bool = False
@@ -549,6 +567,7 @@ class Settings(BaseSettings):
LOG_LEVEL: str = 'INFO'
LOG_FILE: str = 'logs/bot.log'
LOG_COLORS: bool = True # ANSI-цвета в консоли (false для plain-text вывода)
# === Log Rotation Settings ===
LOG_ROTATION_ENABLED: bool = False # По умолчанию старое поведение
@@ -670,9 +689,9 @@ class Settings(BaseSettings):
WEB_API_DEFAULT_TOKEN: str | None = None
WEB_API_DEFAULT_TOKEN_NAME: str = 'Bootstrap Token'
WEB_API_TOKEN_HASH_ALGORITHM: str = 'sha256'
WEB_API_TOKEN_HMAC_SECRET: str | None = None
WEB_API_REQUEST_LOGGING: bool = True
APP_CONFIG_PATH: str = 'app-config.json'
ENABLE_DEEP_LINKS: bool = True
APP_CONFIG_CACHE_TTL: int = 3600
@@ -707,6 +726,9 @@ class Settings(BaseSettings):
CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES: int = 15 # Email change verification code expiration
CABINET_EMAIL_AUTH_ENABLED: bool = True # Enable email registration/login in cabinet
CABINET_URL: str = 'https://example.com/cabinet' # Base URL for cabinet (used in verification emails)
CABINET_TRUSTED_PROXIES: str = (
'' # Comma-separated IPs/CIDRs of trusted reverse proxies (e.g. '127.0.0.1,10.0.0.0/8')
)
# OAuth 2.0 provider settings for cabinet
OAUTH_GOOGLE_CLIENT_ID: str = ''
@@ -1379,13 +1401,6 @@ class Settings(BaseSettings):
return value
return None
def get_app_config_path(self) -> str:
if os.path.isabs(self.APP_CONFIG_PATH):
return self.APP_CONFIG_PATH
project_root = Path(__file__).parent.parent
return str(project_root / self.APP_CONFIG_PATH)
def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS
@@ -1531,8 +1546,8 @@ class Settings(BaseSettings):
logger.warning('Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT', raw_value=raw_value)
return None
if value < 0:
return 0
if value <= 0:
return None
return value
@@ -1756,6 +1771,26 @@ class Settings(BaseSettings):
def get_freekassa_display_name_html(self) -> str:
return html.escape(self.get_freekassa_display_name())
def is_freekassa_sbp_enabled(self) -> bool:
return self.FREEKASSA_SBP_ENABLED and self.is_freekassa_enabled()
def get_freekassa_sbp_display_name(self) -> str:
name = (self.FREEKASSA_SBP_DISPLAY_NAME or '').strip()
return name if name else 'СБП (QR код)'
def get_freekassa_sbp_display_name_html(self) -> str:
return html.escape(self.get_freekassa_sbp_display_name())
def is_freekassa_card_enabled(self) -> bool:
return self.FREEKASSA_CARD_ENABLED and self.is_freekassa_enabled()
def get_freekassa_card_display_name(self) -> str:
name = (self.FREEKASSA_CARD_DISPLAY_NAME or '').strip()
return name if name else 'Карта РФ'
def get_freekassa_card_display_name_html(self) -> str:
return html.escape(self.get_freekassa_card_display_name())
def is_kassa_ai_enabled(self) -> bool:
return (
self.KASSA_AI_ENABLED
@@ -2445,6 +2480,15 @@ class Settings(BaseSettings):
def get_cabinet_jwt_secret(self) -> str:
if self.CABINET_JWT_SECRET:
return self.CABINET_JWT_SECRET
import warnings
warnings.warn(
'CABINET_JWT_SECRET is not set, falling back to BOT_TOKEN. '
'Set CABINET_JWT_SECRET to a unique secret in production: '
'python -c "import secrets; print(secrets.token_urlsafe(64))"',
UserWarning,
stacklevel=2,
)
return self.BOT_TOKEN
def get_cabinet_access_token_expire_minutes(self) -> int:
@@ -2473,6 +2517,12 @@ class Settings(BaseSettings):
def is_cabinet_email_auth_enabled(self) -> bool:
return bool(self.CABINET_EMAIL_AUTH_ENABLED)
def get_cabinet_trusted_proxies(self) -> set[str]:
"""Parse CABINET_TRUSTED_PROXIES into a set of IP strings/CIDRs."""
if not self.CABINET_TRUSTED_PROXIES:
return set()
return {p.strip() for p in self.CABINET_TRUSTED_PROXIES.split(',') if p.strip()}
def is_smtp_configured(self) -> bool:
# For servers without AUTH, only host and from_email are required
has_from = bool(self.SMTP_FROM_EMAIL or self.SMTP_USER)
@@ -2578,18 +2628,25 @@ def get_db_period_prices() -> dict[int, int] | None:
return _DB_PERIOD_PRICES
def clear_db_period_prices() -> None:
"""Очищает кеш цен из тарифов (при переключении в classic mode)."""
global _DB_PERIOD_PRICES
_DB_PERIOD_PRICES = None
def refresh_period_prices() -> None:
"""
Rebuild cached period price mapping.
Приоритет: БД > .env
В режиме tariffs: приоритет у _DB_PERIOD_PRICES (из таблицы Tariff).
В режиме classic: ВСЕГДА используются settings.PRICE_*_DAYS.
"""
PERIOD_PRICES.clear()
if _DB_PERIOD_PRICES:
# Используем цены из БД
if _DB_PERIOD_PRICES and settings.is_tariffs_mode():
# Используем цены из БД тарифов (только в режиме tariffs)
PERIOD_PRICES.update(_DB_PERIOD_PRICES)
else:
# Fallback на .env
# Classic mode или нет цен в БД — берём из settings
PERIOD_PRICES.update(
{days: getattr(settings, field_name, 0) for days, field_name in _PERIOD_PRICE_FIELDS.items()}
)
+2 -2
View File
@@ -8,7 +8,7 @@ from .database import (
get_db,
get_db_read_only,
get_pool_metrics,
init_db,
sync_postgres_sequences,
)
@@ -20,5 +20,5 @@ __all__ = [
'get_db',
'get_db_read_only',
'get_pool_metrics',
'init_db',
'sync_postgres_sequences',
]
+23 -66
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import and_, delete, func, select, update
@@ -36,6 +36,7 @@ async def create_campaign(
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)
@@ -71,6 +73,7 @@ async def get_campaign_by_id(db: AsyncSession, campaign_id: int) -> AdvertisingC
.options(
selectinload(AdvertisingCampaign.registrations),
selectinload(AdvertisingCampaign.tariff),
selectinload(AdvertisingCampaign.partner),
)
.where(AdvertisingCampaign.id == campaign_id)
)
@@ -101,8 +104,9 @@ async def get_campaigns_list(
stmt = (
select(AdvertisingCampaign)
.options(
selectinload(AdvertisingCampaign.registrations),
selectinload(AdvertisingCampaign.tariff),
selectinload(AdvertisingCampaign.partner),
selectinload(AdvertisingCampaign.registrations),
)
.order_by(AdvertisingCampaign.created_at.desc())
.offset(offset)
@@ -141,17 +145,30 @@ async def update_campaign(
'tariff_id',
'tariff_duration_days',
'is_active',
'partner_user_id',
}
nullable_fields = {
'partner_user_id',
'tariff_id',
'subscription_duration_days',
'subscription_traffic_gb',
'subscription_device_limit',
'tariff_duration_days',
}
update_data = {}
for key, value in kwargs.items():
if key in allowed_fields and value is not None:
update_data[key] = value
if key not in allowed_fields:
continue
if value is None and key not in nullable_fields:
continue
update_data[key] = value
if not update_data:
return campaign
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.commit()
@@ -331,7 +348,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)
@@ -359,66 +376,6 @@ async def get_campaign_statistics(
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)
conversion_rate = 0.0
if count:
conversion_rate = round((paid_users_count / count) * 100, 1)
+15 -5
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import structlog
@@ -18,7 +18,7 @@ logger = structlog.get_logger(__name__)
async def create_cloudpayments_payment(
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
invoice_id: str,
amount_kopeks: int,
description: str | None = None,
@@ -92,6 +92,16 @@ async def get_cloudpayments_payment_by_id(
return result.scalars().first()
async def get_cloudpayments_payment_by_id_for_update(
db: AsyncSession,
payment_id: int,
) -> CloudPaymentsPayment | None:
result = await db.execute(
select(CloudPaymentsPayment).where(CloudPaymentsPayment.id == payment_id).with_for_update()
)
return result.scalar_one_or_none()
async def get_cloudpayments_payment_by_transaction_id(
db: AsyncSession,
transaction_id_cp: int,
@@ -127,7 +137,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)
@@ -171,7 +181,7 @@ async def mark_cloudpayments_payment_as_paid(
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
@@ -190,7 +200,7 @@ 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)
+3 -3
View File
@@ -1,5 +1,5 @@
from collections.abc import Sequence
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import and_, delete, desc, select
@@ -107,7 +107,7 @@ async def create_round(
async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
@@ -124,7 +124,7 @@ async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> ContestRound | None:
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
+11 -8
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import and_, select
@@ -13,7 +13,7 @@ logger = structlog.get_logger(__name__)
async def create_cryptobot_payment(
db: AsyncSession,
user_id: int,
user_id: int | None,
invoice_id: str,
amount: str,
asset: str,
@@ -67,6 +67,11 @@ async def get_cryptobot_payment_by_id(db: AsyncSession, payment_id: int) -> Cryp
return result.scalar_one_or_none()
async def get_cryptobot_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> CryptoBotPayment | None:
result = await db.execute(select(CryptoBotPayment).where(CryptoBotPayment.id == payment_id).with_for_update())
return result.scalar_one_or_none()
async def update_cryptobot_payment_status(
db: AsyncSession, invoice_id: str, status: str, paid_at: datetime | None = None
) -> CryptoBotPayment | None:
@@ -76,7 +81,7 @@ async def update_cryptobot_payment_status(
return None
payment.status = status
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
if status == 'paid' and paid_at:
payment.paid_at = paid_at
@@ -97,9 +102,9 @@ async def link_cryptobot_payment_to_transaction(
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.flush()
await db.refresh(payment)
logger.info('Связан CryptoBot платеж с транзакцией', invoice_id=invoice_id, transaction_id=transaction_id)
@@ -120,9 +125,7 @@ async def get_user_cryptobot_payments(
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)
cutoff_time = datetime.now(UTC) - timedelta(hours=older_than_hours)
result = await db.execute(
select(CryptoBotPayment)
+5 -5
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import func, select
@@ -28,7 +28,7 @@ async def upsert_discount_offer(
) -> 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)
@@ -116,7 +116,7 @@ async def list_active_discount_offers_for_user(
) -> list[DiscountOffer]:
"""Return active (not yet claimed) offers for a user."""
now = datetime.utcnow()
now = datetime.now(UTC)
stmt = (
select(DiscountOffer)
.options(
@@ -161,7 +161,7 @@ async def mark_offer_claimed(
*,
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)
@@ -190,7 +190,7 @@ async def mark_offer_claimed(
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,
+4 -4
View File
@@ -1,5 +1,5 @@
from collections.abc import Iterable
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import delete, func, select, update
@@ -21,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,
@@ -117,7 +117,7 @@ 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)
@@ -139,6 +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()
+12 -8
View File
@@ -1,7 +1,6 @@
"""CRUD операции для платежей Freekassa."""
import json
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
@@ -16,14 +15,14 @@ logger = structlog.get_logger(__name__)
async def create_freekassa_payment(
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
expires_at: datetime | None = None,
metadata_json: str | None = None,
metadata_json: dict | None = None,
) -> FreekassaPayment:
"""Создает запись о платеже Freekassa."""
payment = FreekassaPayment(
@@ -34,7 +33,7 @@ async def create_freekassa_payment(
description=description,
payment_url=payment_url,
expires_at=expires_at,
metadata_json=json.loads(metadata_json) if metadata_json else None,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
@@ -63,6 +62,11 @@ async def get_freekassa_payment_by_id(db: AsyncSession, payment_id: int) -> Free
return result.scalar_one_or_none()
async def get_freekassa_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> FreekassaPayment | None:
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.id == payment_id).with_for_update())
return result.scalar_one_or_none()
async def update_freekassa_payment_status(
db: AsyncSession,
payment: FreekassaPayment,
@@ -77,10 +81,10 @@ async def update_freekassa_payment_status(
"""Обновляет статус платежа."""
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:
@@ -134,7 +138,7 @@ async def get_expired_pending_payments(
db: AsyncSession,
) -> list[FreekassaPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(FreekassaPayment).where(
FreekassaPayment.status == 'pending',
+10 -5
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import structlog
@@ -15,7 +15,7 @@ logger = structlog.get_logger(__name__)
async def create_heleket_payment(
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
uuid: str,
order_id: str,
amount: str,
@@ -91,6 +91,11 @@ async def get_heleket_payment_by_id(
return result.scalar_one_or_none()
async def get_heleket_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> HeleketPayment | None:
result = await db.execute(select(HeleketPayment).where(HeleketPayment.id == payment_id).with_for_update())
return result.scalar_one_or_none()
async def update_heleket_payment(
db: AsyncSession,
uuid: str,
@@ -129,7 +134,7 @@ 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)
@@ -157,9 +162,9 @@ async def link_heleket_payment_to_transaction(
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
payment.updated_at = datetime.now(UTC)
await db.commit()
await db.flush()
await db.refresh(payment)
logger.info('Heleket платеж связан с транзакцией', uuid=uuid, transaction_id=transaction_id)
+12 -8
View File
@@ -1,7 +1,6 @@
"""CRUD операции для платежей KassaAI."""
import json
from datetime import datetime
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
@@ -16,7 +15,7 @@ logger = structlog.get_logger(__name__)
async def create_kassa_ai_payment(
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
@@ -24,7 +23,7 @@ async def create_kassa_ai_payment(
payment_url: str | None = None,
payment_system_id: int | None = None,
expires_at: datetime | None = None,
metadata_json: str | None = None,
metadata_json: dict | None = None,
) -> KassaAiPayment:
"""Создает запись о платеже KassaAI."""
payment = KassaAiPayment(
@@ -36,7 +35,7 @@ async def create_kassa_ai_payment(
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,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
@@ -65,6 +64,11 @@ async def get_kassa_ai_payment_by_id(db: AsyncSession, payment_id: int) -> Kassa
return result.scalar_one_or_none()
async def get_kassa_ai_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> KassaAiPayment | None:
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.id == payment_id).with_for_update())
return result.scalar_one_or_none()
async def update_kassa_ai_payment_status(
db: AsyncSession,
payment: KassaAiPayment,
@@ -79,10 +83,10 @@ async def update_kassa_ai_payment_status(
"""Обновляет статус платежа."""
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 kassa_ai_order_id:
payment.kassa_ai_order_id = kassa_ai_order_id
if payment_system_id is not None:
@@ -136,7 +140,7 @@ async def get_expired_pending_kassa_ai_payments(
db: AsyncSession,
) -> list[KassaAiPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.utcnow()
now = datetime.now(UTC)
result = await db.execute(
select(KassaAiPayment).where(
KassaAiPayment.status == 'pending',
+255
View File
@@ -0,0 +1,255 @@
import secrets
import structlog
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage
logger = structlog.get_logger(__name__)
async def get_landing_by_slug(db: AsyncSession, slug: str) -> LandingPage | None:
"""Get a landing page by its slug."""
result = await db.execute(select(LandingPage).where(LandingPage.slug == slug))
return result.scalars().first()
async def get_landing_by_id(db: AsyncSession, landing_id: int) -> LandingPage | None:
"""Get a landing page by its ID."""
result = await db.execute(select(LandingPage).where(LandingPage.id == landing_id))
return result.scalars().first()
async def get_active_landing_by_slug(db: AsyncSession, slug: str) -> LandingPage | None:
"""Get an active landing page by its slug."""
result = await db.execute(
select(LandingPage).where(
LandingPage.slug == slug,
LandingPage.is_active.is_(True),
)
)
return result.scalars().first()
async def get_all_landings(db: AsyncSession) -> list[LandingPage]:
"""Get all landing pages ordered by display_order."""
result = await db.execute(select(LandingPage).order_by(LandingPage.display_order, LandingPage.id))
return list(result.scalars().all())
async def create_landing(db: AsyncSession, **kwargs) -> LandingPage:
"""Create a new landing page."""
landing = LandingPage(**kwargs)
db.add(landing)
await db.flush()
await db.commit()
await db.refresh(landing)
logger.info(
'Created landing page',
slug=landing.slug,
landing_id=landing.id,
)
return landing
_LANDING_UPDATABLE_FIELDS = frozenset(
{
'slug',
'title',
'subtitle',
'is_active',
'features',
'footer_text',
'allowed_tariff_ids',
'allowed_periods',
'payment_methods',
'gift_enabled',
'custom_css',
'meta_title',
'meta_description',
'display_order',
'discount_percent',
'discount_overrides',
'discount_starts_at',
'discount_ends_at',
'discount_badge_text',
}
)
async def update_landing(db: AsyncSession, landing_id: int, data: dict) -> LandingPage | None:
"""Update a landing page by ID. Returns None if not found."""
landing = await get_landing_by_id(db, landing_id)
if landing is None:
return None
for key, value in data.items():
if key in _LANDING_UPDATABLE_FIELDS:
setattr(landing, key, value)
await db.commit()
await db.refresh(landing)
logger.info(
'Updated landing page',
landing_id=landing.id,
slug=landing.slug,
updated_fields=list(data.keys()),
)
return landing
async def delete_landing(db: AsyncSession, landing_id: int) -> bool:
"""Delete a landing page by ID. Returns True if deleted."""
landing = await get_landing_by_id(db, landing_id)
if landing is None:
return False
await db.delete(landing)
await db.commit()
logger.info(
'Deleted landing page',
landing_id=landing_id,
slug=landing.slug,
)
return True
async def update_landing_order(db: AsyncSession, landing_ids: list[int]) -> None:
"""Set display_order for landing pages based on position in list."""
for order, landing_id in enumerate(landing_ids):
await db.execute(update(LandingPage).where(LandingPage.id == landing_id).values(display_order=order))
await db.commit()
logger.info('Updated landing page order', landing_ids=landing_ids)
def generate_purchase_token() -> str:
"""Generate a cryptographically secure purchase token."""
return secrets.token_urlsafe(48)
async def create_guest_purchase(db: AsyncSession, *, commit: bool = True, **kwargs) -> GuestPurchase:
"""Create a new guest purchase with an auto-generated token."""
if 'token' not in kwargs:
kwargs['token'] = generate_purchase_token()
purchase = GuestPurchase(**kwargs)
db.add(purchase)
await db.flush()
if commit:
await db.commit()
await db.refresh(purchase)
logger.info(
'Created guest purchase',
purchase_id=purchase.id,
token_prefix=purchase.token[:5],
status=purchase.status,
landing_id=purchase.landing_id,
)
return purchase
async def get_purchase_by_token(db: AsyncSession, token: str) -> GuestPurchase | None:
"""Get a guest purchase by its token."""
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == token))
return result.scalars().first()
_PURCHASE_UPDATABLE_FIELDS = frozenset(
{
'payment_id',
'paid_at',
'delivered_at',
'subscription_url',
'subscription_crypto_link',
'user_id',
}
)
async def update_purchase_status(
db: AsyncSession,
token: str,
status: GuestPurchaseStatus | str,
*,
commit: bool = True,
**extra_fields,
) -> GuestPurchase | None:
"""Update the status of a guest purchase and optional extra fields."""
purchase = await get_purchase_by_token(db, token)
if purchase is None:
return None
old_status = purchase.status
purchase.status = status.value if isinstance(status, GuestPurchaseStatus) else status
for key, value in extra_fields.items():
if key not in _PURCHASE_UPDATABLE_FIELDS:
logger.warning('Ignoring disallowed field in purchase update', field=key)
continue
setattr(purchase, key, value)
if commit:
await db.commit()
await db.refresh(purchase)
else:
await db.flush()
logger.info(
'Updated guest purchase status',
purchase_id=purchase.id,
token_prefix=token[:5],
old_status=old_status,
new_status=purchase.status,
)
return purchase
async def get_landing_purchase_stats(db: AsyncSession, landing_id: int) -> dict:
"""Get purchase counts grouped by status for a landing page."""
result = await db.execute(
select(
GuestPurchase.status,
func.count(GuestPurchase.id),
)
.where(GuestPurchase.landing_id == landing_id)
.group_by(GuestPurchase.status)
)
rows = result.all()
stats = {s.value: 0 for s in GuestPurchaseStatus}
stats['total'] = 0
for status_value, count in rows:
stats[status_value] = count
stats['total'] += count
return stats
async def get_all_landing_purchase_stats(db: AsyncSession) -> dict[int, dict]:
"""Get purchase counts grouped by landing_id and status in a single query.
Returns a dict mapping landing_id -> {status: count, 'total': count}.
"""
result = await db.execute(
select(
GuestPurchase.landing_id,
GuestPurchase.status,
func.count(GuestPurchase.id),
)
.where(GuestPurchase.landing_id.is_not(None))
.group_by(GuestPurchase.landing_id, GuestPurchase.status)
)
rows = result.all()
all_stats: dict[int, dict] = {}
for landing_id, status_value, count in rows:
if landing_id not in all_stats:
stats = {s.value: 0 for s in GuestPurchaseStatus}
stats['total'] = 0
all_stats[landing_id] = stats
all_stats[landing_id][status_value] = count
all_stats[landing_id]['total'] += count
return all_stats

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