Compare commits

...

146 Commits

Author SHA1 Message Date
Egor 28c953c5ae Update Dockerfile 2026-01-08 03:45:21 +03:00
Egor 685305bff2 Update docker-registry.yml 2026-01-08 03:45:09 +03:00
Egor 927eb1d240 Update docker-hub.yml 2026-01-08 03:44:57 +03:00
Egor c767473184 Merge pull request #2246 from BEDOLAGA-DEV/dev5
Freekassa fix / campaign fix
2026-01-08 03:30:28 +03:00
Egor 744e5c1453 Update campaign_service.py 2026-01-08 03:28:49 +03:00
Egor 5eb581c68e Update .env.example 2026-01-08 03:19:32 +03:00
Egor cf53801408 Update freekassa.py 2026-01-08 03:18:56 +03:00
Egor 4e59d0a071 Update freekassa_service.py 2026-01-08 03:18:31 +03:00
Egor c31de445b9 Update config.py 2026-01-08 03:18:03 +03:00
Egor cc3f78c2f8 Update subscription.py 2026-01-08 03:07:31 +03:00
Egor 5eae547cb2 Update tariff_purchase.py 2026-01-08 02:57:50 +03:00
Egor 7e8e0c1617 Update tariff_purchase.py 2026-01-08 02:54:31 +03:00
Egor 250b8d95fb Update tariff_purchase.py 2026-01-08 02:45:26 +03:00
Egor 28e868fb09 Merge pull request #2245 from BEDOLAGA-DEV/main
ц
2026-01-08 02:44:47 +03:00
Egor 45e9a29b4e Update index.html 2026-01-08 02:36:25 +03:00
Egor 8a52b34760 Update index.html 2026-01-08 02:26:36 +03:00
Egor 464df9689e Update index.html 2026-01-08 01:33:36 +03:00
Egor 6191a7a395 Update index.html 2026-01-08 01:25:27 +03:00
Egor 233f6c3490 Update index.html 2026-01-08 01:02:18 +03:00
Egor 0e5b115d64 Update index.html 2026-01-08 00:52:25 +03:00
Egor 69bf99f3a6 Merge pull request #2244 from BEDOLAGA-DEV/dev5
Update index.html
2026-01-07 18:36:05 +03:00
Egor dae5c07318 Update index.html 2026-01-07 18:35:43 +03:00
Egor d53bf4ae29 Merge pull request #2243 from BEDOLAGA-DEV/dev5
Update index.html
2026-01-07 17:53:10 +03:00
Egor 50dd5a5fb3 Update index.html 2026-01-07 17:52:50 +03:00
Egor 9595026d3d Merge pull request #2242 from BEDOLAGA-DEV/dev5
Api update / Miniapp fix
2026-01-07 17:39:27 +03:00
Egor 571018982f Update index.html 2026-01-07 17:35:30 +03:00
Egor cfdfe1ccd1 Update miniapp.py 2026-01-07 17:34:39 +03:00
Egor d9f0d6496d Update miniapp.py 2026-01-07 17:33:56 +03:00
Egor 0eba4cfc8f Update remnawave_api.py 2026-01-07 17:25:06 +03:00
Egor 3eb84338a4 Merge pull request #2241 from BEDOLAGA-DEV/dev5
Tariffs
2026-01-07 05:14:30 +03:00
Egor 127a609d6b Update .env.example 2026-01-07 05:12:18 +03:00
Egor 528944f649 Update tariff_purchase.py 2026-01-07 05:06:25 +03:00
Egor 48fa739ca7 Update tariff_purchase.py 2026-01-07 04:48:27 +03:00
Egor 07e50f449f Update tariff_purchase.py 2026-01-07 04:40:09 +03:00
Egor 47433d905e Update admin.py 2026-01-07 04:30:54 +03:00
Egor b096003683 Update tariff_purchase.py 2026-01-07 04:26:31 +03:00
Egor 5814bbc920 Update tariff_purchase.py 2026-01-07 04:18:46 +03:00
Egor fdffe7ae35 Update traffic.py 2026-01-07 04:08:33 +03:00
Egor 917fa84838 Update service.py 2026-01-07 04:07:52 +03:00
Egor 9631f340c1 Update config.py 2026-01-07 03:58:44 +03:00
Egor 61fe7f0be4 Update inline.py 2026-01-07 03:49:31 +03:00
Egor a8c54c4d3b Update miniapp.py 2026-01-07 03:48:49 +03:00
Egor db7e6cf8f8 Add files via upload 2026-01-07 03:48:02 +03:00
Egor 9690436b9f Update tariffs.py 2026-01-07 03:47:28 +03:00
Egor fefc46e5c4 Update miniapp.py 2026-01-07 03:38:52 +03:00
Egor 14fc1f58af Update states.py 2026-01-07 03:38:13 +03:00
Egor f933992883 Update purchase.py 2026-01-07 03:31:56 +03:00
Egor fdc382f309 Update tariffs.py 2026-01-07 03:31:17 +03:00
Egor 0ef3e32c15 Update miniapp.py 2026-01-07 03:20:58 +03:00
Egor 9c870ffc70 Update purchase.py 2026-01-07 03:20:17 +03:00
Egor 6ce87698bc Update tariff.py 2026-01-07 03:19:32 +03:00
Egor 60a38d3ea6 Add files via upload 2026-01-07 03:18:46 +03:00
Egor 5355f41bef Update states.py 2026-01-07 03:06:11 +03:00
Egor 0fac84aa7d Update tariffs.py 2026-01-07 03:05:31 +03:00
Egor b4e472b873 Add files via upload 2026-01-07 03:04:44 +03:00
Egor 6c0c3f1b79 Update inline.py 2026-01-07 03:03:55 +03:00
Egor 203405438f Update tariff.py 2026-01-07 03:03:18 +03:00
Egor a38c12eaa6 Add files via upload 2026-01-07 03:02:47 +03:00
Egor e48ebca91e Update inline.py 2026-01-07 02:55:26 +03:00
Egor e32bf5f2c4 Add files via upload 2026-01-07 02:54:47 +03:00
Egor fbb7d6c4ab Update tariffs.py 2026-01-07 02:45:42 +03:00
Egor 29f967469f Update tariff_purchase.py 2026-01-07 02:42:07 +03:00
Egor 541d3c903d Update subscription_auto_purchase_service.py 2026-01-07 02:37:30 +03:00
Egor f19e2b2a34 Update ru.json 2026-01-07 02:36:43 +03:00
Egor 3d0050139c Refactor personal discount retrieval in tariff_purchase 2026-01-07 02:36:02 +03:00
Egor 0b677e4205 Update tariffs.py 2026-01-07 02:35:27 +03:00
Egor 71dac493d8 Update tariffs.py 2026-01-07 02:29:37 +03:00
Egor fc1528532e Update index.html 2026-01-07 02:25:48 +03:00
Egor 48cb19170b Update miniapp.py 2026-01-07 02:24:30 +03:00
Egor 1af1919a14 Update miniapp.py 2026-01-07 02:23:58 +03:00
Egor 5d864d0286 Introduce tariff creation and editing states
Added states for creating and editing tariffs.
2026-01-07 02:23:21 +03:00
Egor a448a2c450 Add files via upload 2026-01-07 02:22:36 +03:00
Egor e301d49657 Add tariffs button to admin keyboard 2026-01-07 02:21:42 +03:00
Egor 738216cf9f Add tariff change button for tariff mode 2026-01-07 02:21:15 +03:00
Egor 7b6f646d7e Update bot.py 2026-01-07 02:20:49 +03:00
Egor 3150349ffa Update config.py 2026-01-07 02:17:18 +03:00
Egor b50478eda0 Add files via upload 2026-01-07 02:16:33 +03:00
Egor cff00eb515 Add files via upload 2026-01-07 02:16:00 +03:00
Egor a981bf2ae0 Add files via upload 2026-01-07 02:15:16 +03:00
Egor 031c2b683b Add files via upload 2026-01-07 02:14:41 +03:00
Egor 72d2501be5 Merge pull request #2240 from BEDOLAGA-DEV/dev5
Update start.py
2026-01-06 22:59:30 +03:00
Egor 69ff87ab31 Update start.py 2026-01-06 22:57:21 +03:00
Egor e4fce0f430 Merge pull request #2239 from BEDOLAGA-DEV/dev5
Update start.py
2026-01-06 22:51:38 +03:00
Egor 5fb0699dcb Update start.py 2026-01-06 22:51:18 +03:00
Egor 965ae9d1d8 Merge pull request #2238 from BEDOLAGA-DEV/revert-2237-revert-2236-dev5
Revert "Revert "Frekassa""
2026-01-06 22:43:11 +03:00
Egor 23b64ffb2a Revert "Revert "Frekassa"" 2026-01-06 22:43:01 +03:00
Egor 9868c5de0f Merge pull request #2237 from BEDOLAGA-DEV/revert-2236-dev5
Revert "Frekassa"
2026-01-06 22:40:45 +03:00
Egor aeaaa54920 Revert "Frekassa" 2026-01-06 22:40:35 +03:00
Egor fb25032284 Merge pull request #2236 from BEDOLAGA-DEV/dev5
Frekassa
2026-01-06 21:43:35 +03:00
Egor 3e0661da39 Update index.html 2026-01-06 21:02:20 +03:00
Egor 7ab9fe9ad2 Update .env.example 2026-01-06 21:01:56 +03:00
Egor 7c846c2f83 Update miniapp.py 2026-01-06 21:01:20 +03:00
Egor 163f55ec14 Update config.py 2026-01-06 21:00:04 +03:00
Egor 2c8e67ac82 Implement Freekassa webhook handler
Added support for Freekassa webhook handling and logging.
2026-01-06 20:59:38 +03:00
Egor e9ce583eb2 Add files via upload 2026-01-06 20:59:02 +03:00
Egor 416908aea4 Add files via upload 2026-01-06 20:58:36 +03:00
Egor 6223a5d63a Update bot_configuration.py 2026-01-06 20:57:55 +03:00
Egor 53a10c0640 Add files via upload 2026-01-06 20:56:44 +03:00
Egor 7c2408209d Add files via upload 2026-01-06 20:54:46 +03:00
Egor 9fbc3a8312 Add FreekassaPayment model for payment processing 2026-01-06 20:54:18 +03:00
Egor dff08cbad2 Add create_freekassa_payments_table function 2026-01-06 20:53:55 +03:00
Egor a2379b9be1 Delete FreekassaPayment model and related code
Removed FreekassaPayment class and its related fields.
2026-01-06 20:53:32 +03:00
Egor 12dc9ccbaa Update models.py 2026-01-06 20:51:46 +03:00
Egor 4b74ae12ad Merge pull request #2233 from BEDOLAGA-DEV/main
w
2026-01-04 17:24:01 +03:00
PEDZEO 258a4a5cb4 Merge pull request #2230 from BEDOLAGA-DEV/buttons
Buttons
2026-01-03 14:41:17 +03:00
Egor 2cd2147464 Merge pull request #2231 from Gy9vin/main
Фиксы
2026-01-02 19:26:52 +03:00
Mikhail c5efc7ce06 Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-02 19:25:34 +03:00
gy9vin 9cd5d8e0b9 Фикс промокодов 2026-01-02 19:23:52 +03:00
Egor 31d538fbcf Merge pull request #2229 from Gy9vin/main
Фиксы
2026-01-02 19:01:26 +03:00
gy9vin 2156f630dc Добавлена опция "только для первой покупки" в промокоды
- models.py: добавлено поле first_purchase_only в PromoCode
- universal_migration.py: миграция для добавления колонки first_purchase_only
- promocodes.py: добавлен хендлер toggle_promocode_first_purchase, отображение статуса в управлении промокодом
- promocode.py: обработка ошибки "not
2026-01-02 16:40:04 +03:00
gy9vin 917ca69b1d фикс 2026-01-02 16:20:42 +03:00
gy9vin d524088bb8 Обязательная подписка на канал Доработка 2026-01-02 16:14:40 +03:00
PEDZEO 9bd1944ba3 fix platega inv 2026-01-02 12:16:36 +03:00
PEDZEO c41979bda6 fix 2026-01-02 00:48:08 +03:00
PEDZEO 0813d585d2 docs: add cabinet settings to .env.example
Add configuration options for personal cabinet:
- CABINET_ENABLED, JWT settings, CORS origins
- Email verification settings
- SMTP configuration for email sending

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  db.add(subscription)

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

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

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

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

  2. app/handlers/subscription/purchase.py

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

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

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

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

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

  Стало:
  if settings.is_quick_amount_buttons_enabled():
2025-12-30 22:40:43 +03:00
gy9vin bc19ec32bb fix Реализация корзины пользователя! запоминает настройки подписки после пополнения баланса 2025-12-30 22:35:54 +03:00
gy9vin 720f0ecb60 fix Скрытие кнопки партнёрки через env 2025-12-30 22:29:06 +03:00
gy9vin a9fd4c2466 fix Модульная структура платежки 2025-12-30 22:22:56 +03:00
gy9vin 5aa9b6ddb3 fix Исправление уведомления пользователя о поступлении денег на счет 2025-12-30 22:18:44 +03:00
gy9vin 180cba4561 fix Расширение фильтров 2025-12-30 22:14:09 +03:00
gy9vin 25dc7ff624 fix Добавлена фильтрация пользователей по балансу 2025-12-30 22:07:26 +03:00
gy9vin 1233d38fe1 fix Добавлена функция покупки подписки администратором с преобразованием триала в безлимитную подписку 2025-12-30 21:54:02 +03:00
gy9vin d60ebaef41 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-30 21:50:42 +03:00
gy9vin dd86014667 Fix Добавление кнопок пополнения с суммой подписки 2025-12-30 21:50:36 +03:00
Mikhail bb8beff511 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-30 21:47:07 +03:00
gy9vin 8843d86d9b Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-30 11:34:46 +03:00
gy9vin 449f82d538 refactor(auto-purchase): перезагрузка пользователя после списания баланса для восстановления связей
- Добавлен get_user_by_id в импорты
- Перезагрузка user через get_user_by_id после subtract_user_balance
- Восстановление связи user_promo_groups, сбрасываемой после db.refresh() в payment-сервисах
- Добавлен мок get_user_by_id в тесте
2025-12-30 11:34:41 +03:00
122 changed files with 19677 additions and 1174 deletions
+71 -1
View File
@@ -3,11 +3,43 @@
# ===============================================
# ===== TELEGRAM BOT =====
# Токен бота от @BotFather
# ВАЖНО: Также используется для авторизации виджета личного кабинета (Cabinet WebApp)
# через Telegram.WebApp.initData
BOT_TOKEN=
ADMIN_IDS=
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
SUPPORT_USERNAME=@support
# ===== ЛИЧНЫЙ КАБИНЕТ (CABINET) =====
# Включить личный кабинет пользователя (веб-интерфейс для управления подпиской)
CABINET_ENABLED=false
# Секретный ключ для JWT токенов (если не указан, используется BOT_TOKEN)
CABINET_JWT_SECRET=
# Время жизни access token в минутах (по умолчанию 15)
CABINET_ACCESS_TOKEN_EXPIRE_MINUTES=15
# Время жизни refresh token в днях (по умолчанию 7)
CABINET_REFRESH_TOKEN_EXPIRE_DAYS=7
# Разрешенные origins для CORS (через запятую, например: https://cabinet.example.com)
CABINET_ALLOWED_ORIGINS=
# Включить верификацию email (требует настройки SMTP)
CABINET_EMAIL_VERIFICATION_ENABLED=false
# Время жизни токена верификации email в часах
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS=24
# Время жизни токена сброса пароля в часах
CABINET_PASSWORD_RESET_EXPIRE_HOURS=1
# ===== SMTP НАСТРОЙКИ (для email в личном кабинете) =====
# SMTP сервер (например: smtp.gmail.com, smtp.yandex.ru)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
# Email отправителя (если не указан, используется SMTP_USER)
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=VPN Service
# Использовать TLS шифрование
SMTP_USE_TLS=true
# Уведомления администраторов
ADMIN_NOTIFICATIONS_ENABLED=true
@@ -99,10 +131,21 @@ REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
REMNAWAVE_USER_DELETE_MODE=delete
# ========= ПОДПИСКИ =========
# ===== РЕЖИМ ПРОДАЖ =====
# Режим продаж подписок:
# "classic" - классический режим (выбор серверов, трафика, устройств, периода отдельно)
# "tariffs" - режим тарифов (готовые пакеты с фиксированными параметрами)
SALES_MODE=classic
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
# Длительность триала всё равно берётся из TRIAL_DURATION_DAYS
TRIAL_TARIFF_ID=0
# Платный триал: если TRIAL_ACTIVATION_PRICE > 0, триал становится платным
# Цена в копейках (1000 = 10 рублей). Пользователь может оплатить триал любым методом оплаты.
# TRIAL_PAYMENT_ENABLED опционален (для обратной совместимости)
@@ -262,9 +305,15 @@ YOOKASSA_VAT_CODE=1
# 1 - НДС не облагается
# 2 - НДС 0%
# 3 - НДС 10%
# 4 - НДС 20%/22%
# 4 - НДС 20%
# 5 - НДС 10/110
# 6 - НДС 20/120
# 7 - НДС 5%
# 8 - НДС 7%
# 9 - НДС 5/105
# 10 - НДС 7/107
# 11 - НДС 22%
# 12 - НДС 22/122
YOOKASSA_PAYMENT_MODE=full_payment
# Способы расчета:
@@ -413,6 +462,27 @@ PLATEGA_WEBHOOK_PATH=/platega-webhook
PLATEGA_WEBHOOK_HOST=0.0.0.0
PLATEGA_WEBHOOK_PORT=8086
# ===== FREEKASSA =====
FREEKASSA_ENABLED=false
FREEKASSA_SHOP_ID=
FREEKASSA_API_KEY=
# Секретное слово 1 (для формы оплаты)
FREEKASSA_SECRET_WORD_1=
# Секретное слово 2 (для webhook)
FREEKASSA_SECRET_WORD_2=
FREEKASSA_DISPLAY_NAME=Freekassa
FREEKASSA_CURRENCY=RUB
FREEKASSA_MIN_AMOUNT_KOPEKS=10000
FREEKASSA_MAX_AMOUNT_KOPEKS=100000000
FREEKASSA_PAYMENT_TIMEOUT_SECONDS=3600
FREEKASSA_WEBHOOK_PATH=/freekassa-webhook
FREEKASSA_WEBHOOK_HOST=0.0.0.0
FREEKASSA_WEBHOOK_PORT=8088
# Способ оплаты: пусто = форма выбора, 42 = обычный СБП, 44 = NSPK СБП
FREEKASSA_PAYMENT_SYSTEM_ID=
# Использовать API для создания заказов (обязательно для NSPK СБП)
FREEKASSA_USE_API=false
# ===== ИНТЕРФЕЙС И UX =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.9.4-$(git rev-parse --short HEAD)"
VERSION="v2.9.5-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.9.4-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.5-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.9.4-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.5-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.9.4-$(git rev-parse --short HEAD)"
VERSION="v2.9.5-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.9.4-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.5-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.9.4-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.5-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.9.4"
ARG VERSION="v2.9.5"
ARG BUILD_DATE
ARG VCS_REF
+2
View File
@@ -65,6 +65,7 @@ from app.handlers.admin import (
faq as admin_faq,
payments as admin_payments,
trials as admin_trials,
tariffs as admin_tariffs,
)
from app.handlers import contests as user_contests
from app.handlers.stars_payments import register_stars_handlers
@@ -190,6 +191,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_faq.register_handlers(dp)
admin_payments.register_handlers(dp)
admin_trials.register_handlers(dp)
admin_tariffs.register_handlers(dp)
admin_bulk_ban.register_bulk_ban_handlers(dp)
admin_blacklist.register_blacklist_handlers(dp)
common.register_handlers(dp)
+10
View File
@@ -0,0 +1,10 @@
"""
Cabinet module - Personal Account for VPN Bot users.
This module provides:
- JWT-based authentication (Telegram + Email)
- Subscription management
- Balance & payments
- Referral program
- Support tickets
"""
+21
View File
@@ -0,0 +1,21 @@
"""Cabinet authentication module."""
from .password_utils import hash_password, verify_password
from .jwt_handler import (
create_access_token,
create_refresh_token,
decode_token,
get_token_payload,
)
from .telegram_auth import validate_telegram_login_widget, validate_telegram_init_data
__all__ = [
"hash_password",
"verify_password",
"create_access_token",
"create_refresh_token",
"decode_token",
"get_token_payload",
"validate_telegram_login_widget",
"validate_telegram_init_data",
]
+64
View File
@@ -0,0 +1,64 @@
"""Email verification token generation and validation."""
import secrets
from datetime import datetime, timedelta
from typing import Optional
from app.config import settings
def generate_verification_token() -> str:
"""
Generate a secure random verification token.
Returns:
32-character hex token string
"""
return secrets.token_hex(32)
def generate_password_reset_token() -> str:
"""
Generate a secure random password reset token.
Returns:
32-character hex token string
"""
return secrets.token_hex(32)
def get_verification_expires_at() -> datetime:
"""
Get the expiration datetime for a verification token.
Returns:
Datetime when the verification token expires
"""
hours = settings.get_cabinet_email_verification_expire_hours()
return datetime.utcnow() + timedelta(hours=hours)
def get_password_reset_expires_at() -> datetime:
"""
Get the expiration datetime for a password reset token.
Returns:
Datetime when the password reset token expires
"""
hours = settings.get_cabinet_password_reset_expire_hours()
return datetime.utcnow() + timedelta(hours=hours)
def is_token_expired(expires_at: Optional[datetime]) -> bool:
"""
Check if a token has expired.
Args:
expires_at: Token expiration datetime
Returns:
True if expired or no expiration set, False otherwise
"""
if expires_at is None:
return True
return datetime.utcnow() > expires_at
+106
View File
@@ -0,0 +1,106 @@
"""JWT token handling for cabinet authentication."""
import jwt
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from app.config import settings
JWT_ALGORITHM = "HS256"
def create_access_token(user_id: int, telegram_id: int) -> str:
"""
Create a short-lived access token.
Args:
user_id: Database user ID
telegram_id: Telegram user ID
Returns:
Encoded JWT access token
"""
expire_minutes = settings.get_cabinet_access_token_expire_minutes()
expires = datetime.utcnow() + timedelta(minutes=expire_minutes)
payload = {
"sub": str(user_id),
"telegram_id": telegram_id,
"type": "access",
"exp": expires,
"iat": datetime.utcnow(),
}
secret = settings.get_cabinet_jwt_secret()
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
def create_refresh_token(user_id: int) -> str:
"""
Create a long-lived refresh token.
Args:
user_id: Database user ID
Returns:
Encoded JWT refresh token
"""
expire_days = settings.get_cabinet_refresh_token_expire_days()
expires = datetime.utcnow() + timedelta(days=expire_days)
payload = {
"sub": str(user_id),
"type": "refresh",
"exp": expires,
"iat": datetime.utcnow(),
}
secret = settings.get_cabinet_jwt_secret()
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
def decode_token(token: str) -> Optional[Dict[str, Any]]:
"""
Decode and validate a JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict or None if invalid/expired
"""
try:
secret = settings.get_cabinet_jwt_secret()
return jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def get_token_payload(token: str, expected_type: str = "access") -> Optional[Dict[str, Any]]:
"""
Decode token and verify its type.
Args:
token: JWT token string
expected_type: Expected token type ("access" or "refresh")
Returns:
Decoded payload dict or None if invalid/expired/wrong type
"""
payload = decode_token(token)
if not payload:
return None
if payload.get("type") != expected_type:
return None
return payload
def get_refresh_token_expires_at() -> datetime:
"""Get the expiration datetime for a new refresh token."""
expire_days = settings.get_cabinet_refresh_token_expire_days()
return datetime.utcnow() + timedelta(days=expire_days)
+40
View File
@@ -0,0 +1,40 @@
"""Password hashing utilities using bcrypt."""
import bcrypt
BCRYPT_ROUNDS = 12
def hash_password(password: str) -> str:
"""
Hash a password using bcrypt.
Args:
password: Plain text password
Returns:
Hashed password string
"""
password_bytes = password.encode("utf-8")
salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode("utf-8")
def verify_password(password: str, password_hash: str) -> bool:
"""
Verify a password against its hash.
Args:
password: Plain text password to verify
password_hash: Previously hashed password
Returns:
True if password matches, False otherwise
"""
try:
password_bytes = password.encode("utf-8")
hash_bytes = password_hash.encode("utf-8")
return bcrypt.checkpw(password_bytes, hash_bytes)
except (ValueError, TypeError):
return False
+139
View File
@@ -0,0 +1,139 @@
"""Telegram authentication validation for cabinet."""
import hashlib
import hmac
import json
from datetime import datetime
from typing import Dict, Any, Optional
from urllib.parse import parse_qsl, unquote
from app.config import settings
def validate_telegram_login_widget(data: Dict[str, Any], max_age_seconds: int = 86400) -> bool:
"""
Validate Telegram Login Widget data.
https://core.telegram.org/widgets/login#checking-authorization
Args:
data: Dictionary with Telegram login data (id, first_name, auth_date, hash, etc.)
max_age_seconds: Maximum allowed age of auth_date (default 24 hours)
Returns:
True if data is valid, False otherwise
"""
auth_data = data.copy()
check_hash = auth_data.pop("hash", None)
if not check_hash:
return False
# Check auth_date is not too old
auth_date = auth_data.get("auth_date")
if auth_date:
try:
# Use UTC timestamp to avoid timezone issues
auth_time = datetime.utcfromtimestamp(int(auth_date))
age = (datetime.utcnow() - auth_time).total_seconds()
if age > max_age_seconds:
return False
except (ValueError, TypeError, OSError):
return False
# Build data-check-string (sorted key=value pairs, newline-separated)
data_check_arr = [f"{k}={v}" for k, v in sorted(auth_data.items()) if v is not None]
data_check_string = "\n".join(data_check_arr)
# Create secret key from bot token using SHA256
bot_token = settings.BOT_TOKEN
secret_key = hashlib.sha256(bot_token.encode()).digest()
# Calculate expected hash
calculated_hash = hmac.new(
secret_key,
data_check_string.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(calculated_hash, check_hash)
def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) -> Optional[Dict[str, Any]]:
"""
Validate Telegram WebApp initData.
https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
Args:
init_data: Raw initData string from Telegram WebApp
max_age_seconds: Maximum allowed age of auth_date (default 24 hours)
Returns:
Parsed user data dict if valid, None otherwise
"""
try:
# Parse the init_data string
parsed = dict(parse_qsl(init_data, keep_blank_values=True))
received_hash = parsed.pop("hash", None)
if not received_hash:
return None
# Check auth_date is not too old
auth_date = parsed.get("auth_date")
if auth_date:
try:
# Use UTC timestamp to avoid timezone issues
auth_time = datetime.utcfromtimestamp(int(auth_date))
age = (datetime.utcnow() - auth_time).total_seconds()
if age > max_age_seconds:
return None
except (ValueError, TypeError, OSError):
return None
# Build data-check-string
data_check_arr = [f"{k}={v}" for k, v in sorted(parsed.items())]
data_check_string = "\n".join(data_check_arr)
# Create secret key: HMAC_SHA256(bot_token, "WebAppData")
bot_token = settings.BOT_TOKEN
secret_key = hmac.new(
b"WebAppData",
bot_token.encode(),
hashlib.sha256
).digest()
# Calculate expected hash
calculated_hash = hmac.new(
secret_key,
data_check_string.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(calculated_hash, received_hash):
return None
# Parse user data from the validated data
user_data_str = parsed.get("user")
if user_data_str:
user_data = json.loads(unquote(user_data_str))
return user_data
return parsed
except (ValueError, TypeError, json.JSONDecodeError):
return None
def extract_telegram_user_from_init_data(init_data: str) -> Optional[Dict[str, Any]]:
"""
Extract and validate user info from Telegram WebApp initData.
Args:
init_data: Raw initData string from Telegram WebApp
Returns:
User data dict with id, first_name, last_name, username, etc. or None if invalid
"""
return validate_telegram_init_data(init_data)
+140
View File
@@ -0,0 +1,140 @@
"""FastAPI dependencies for cabinet module."""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from app.database.database import AsyncSessionLocal
from app.database.models import User
from app.database.crud.user import get_user_by_id
from app.config import settings
from .auth.jwt_handler import get_token_payload
security = HTTPBearer(auto_error=False)
async def get_cabinet_db() -> AsyncSession:
"""Get database session for cabinet operations."""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def get_current_cabinet_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
"""
Get current authenticated cabinet user from JWT token.
Args:
credentials: HTTP Bearer credentials
db: Database session
Returns:
Authenticated User object
Raises:
HTTPException: If token is invalid, expired, or user not found
"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
payload = get_token_payload(token, expected_type="access")
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
user_id = int(payload.get("sub"))
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
headers={"WWW-Authenticate": "Bearer"},
)
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if user.status != "active":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is not active",
)
return user
async def get_optional_cabinet_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> Optional[User]:
"""
Optionally get current authenticated cabinet user.
Returns None if no valid token is provided instead of raising an exception.
"""
if not credentials:
return None
token = credentials.credentials
payload = get_token_payload(token, expected_type="access")
if not payload:
return None
try:
user_id = int(payload.get("sub"))
except (TypeError, ValueError):
return None
user = await get_user_by_id(db, user_id)
if not user or user.status != "active":
return None
return user
async def get_current_admin_user(
user: User = Depends(get_current_cabinet_user),
) -> User:
"""
Get current authenticated admin user.
Checks if the user's telegram_id is in ADMIN_IDS from settings.
Args:
user: Authenticated User object
Returns:
Authenticated admin User object
Raises:
HTTPException: If user is not an admin
"""
if not settings.is_admin(user.telegram_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user
+43
View File
@@ -0,0 +1,43 @@
"""Cabinet API routes."""
from fastapi import APIRouter
from .auth import router as auth_router
from .subscription import router as subscription_router
from .balance import router as balance_router
from .referral import router as referral_router
from .tickets import router as tickets_router
from .admin_tickets import router as admin_tickets_router
from .admin_settings import router as admin_settings_router
from .admin_apps import router as admin_apps_router
from .promocode import router as promocode_router
from .contests import router as contests_router
from .polls import router as polls_router
from .promo import router as promo_router
from .notifications import router as notifications_router
from .info import router as info_router
from .branding import router as branding_router
# Main cabinet router
router = APIRouter(prefix="/cabinet", tags=["Cabinet"])
# Include all sub-routers
router.include_router(auth_router)
router.include_router(subscription_router)
router.include_router(balance_router)
router.include_router(referral_router)
router.include_router(tickets_router)
router.include_router(promocode_router)
router.include_router(contests_router)
router.include_router(polls_router)
router.include_router(promo_router)
router.include_router(notifications_router)
router.include_router(info_router)
router.include_router(branding_router)
# Admin routes
router.include_router(admin_tickets_router)
router.include_router(admin_settings_router)
router.include_router(admin_apps_router)
__all__ = ["router"]
+421
View File
@@ -0,0 +1,421 @@
"""Admin routes for managing VPN applications in app-config.json."""
import json
import logging
from typing import List, Optional, Dict, Any
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.config import settings
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/apps", tags=["Cabinet Admin Apps"])
# ============ Schemas ============
class LocalizedText(BaseModel):
"""Localized text for multiple languages."""
en: str = ""
ru: str = ""
zh: Optional[str] = ""
fa: Optional[str] = ""
class AppButton(BaseModel):
"""Button with link and localized text."""
buttonLink: str
buttonText: LocalizedText
class AppStep(BaseModel):
"""Step with description and optional buttons/title."""
description: LocalizedText
buttons: Optional[List[AppButton]] = None
title: Optional[LocalizedText] = None
class AppDefinition(BaseModel):
"""VPN application definition."""
id: str
name: str
isFeatured: bool = False
urlScheme: str
isNeedBase64Encoding: Optional[bool] = None
installationStep: AppStep
addSubscriptionStep: AppStep
connectAndUseStep: AppStep
additionalBeforeAddSubscriptionStep: Optional[AppStep] = None
additionalAfterAddSubscriptionStep: Optional[AppStep] = 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, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to parse app config: {e}",
)
def _save_config(config: dict) -> None:
"""Save app config to file."""
config_path = _get_config_path()
try:
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to save app config: {e}",
)
VALID_PLATFORMS = ["ios", "android", "macos", "windows", "linux", "androidTV", "appleTV"]
# ============ Routes ============
@router.get("", response_model=AppConfigResponse)
async def get_app_config(
admin: User = Depends(get_current_admin_user),
):
"""Get full app configuration."""
config = _load_config()
return config
@router.get("/platforms", response_model=List[str])
async def get_platforms(
admin: User = Depends(get_current_admin_user),
):
"""Get list of available platforms."""
return VALID_PLATFORMS
@router.get("/platforms/{platform}", response_model=List[AppDefinition])
async def get_platform_apps(
platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Get apps for a specific platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}",
)
config = _load_config()
platforms = config.get("platforms", {})
return platforms.get(platform, [])
@router.post("/platforms/{platform}", response_model=AppDefinition)
async def create_app(
platform: str,
request: CreateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Create a new app for a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid platform: {platform}",
)
config = _load_config()
platforms = config.get("platforms", {})
if platform not in platforms:
platforms[platform] = []
# Check if app with same ID already exists
existing_ids = [app.get("id") for app in platforms[platform]]
if request.app.id in existing_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App with ID '{request.app.id}' already exists in {platform}",
)
# Add new app
app_dict = request.app.model_dump(exclude_none=True)
platforms[platform].append(app_dict)
config["platforms"] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'")
return request.app
@router.put("/platforms/{platform}/{app_id}", response_model=AppDefinition)
async def update_app(
platform: str,
app_id: str,
request: UpdateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update an existing app."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid platform: {platform}",
)
config = _load_config()
platforms = config.get("platforms", {})
apps = platforms.get(platform, [])
# Find and update app
app_index = None
for i, app in enumerate(apps):
if app.get("id") == app_id:
app_index = i
break
if app_index is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Update app
app_dict = request.app.model_dump(exclude_none=True)
apps[app_index] = app_dict
platforms[platform] = apps
config["platforms"] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'")
return request.app
@router.delete("/platforms/{platform}/{app_id}")
async def delete_app(
platform: str,
app_id: str,
admin: User = Depends(get_current_admin_user),
):
"""Delete an app from a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid platform: {platform}",
)
config = _load_config()
platforms = config.get("platforms", {})
apps = platforms.get(platform, [])
# Find and remove app
original_length = len(apps)
apps = [app for app in apps if app.get("id") != app_id]
if len(apps) == original_length:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
platforms[platform] = apps
config["platforms"] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'")
return {"status": "deleted", "app_id": app_id}
@router.post("/platforms/{platform}/reorder")
async def reorder_apps(
platform: str,
request: ReorderAppsRequest,
admin: User = Depends(get_current_admin_user),
):
"""Reorder apps in a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid platform: {platform}",
)
config = _load_config()
platforms = config.get("platforms", {})
apps = platforms.get(platform, [])
# Create a map of apps by ID
apps_map = {app.get("id"): app for app in apps}
# Verify all IDs exist
for app_id in request.app_ids:
if app_id not in apps_map:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Reorder apps
reordered_apps = [apps_map[app_id] for app_id in request.app_ids]
# Add any apps that weren't in the reorder list (shouldn't happen but just in case)
for app in apps:
if app.get("id") not in request.app_ids:
reordered_apps.append(app)
platforms[platform] = reordered_apps
config["platforms"] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'")
return {"status": "reordered", "order": request.app_ids}
@router.put("/branding", response_model=AppConfigBranding)
async def update_branding(
request: UpdateBrandingRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update branding configuration."""
config = _load_config()
if "config" not in config:
config["config"] = {}
config["config"]["branding"] = request.branding.model_dump()
_save_config(config)
logger.info(f"Admin {admin.id} updated branding")
return request.branding
@router.get("/branding", response_model=AppConfigBranding)
async def get_branding(
admin: User = Depends(get_current_admin_user),
):
"""Get branding configuration."""
config = _load_config()
branding = config.get("config", {}).get("branding", {})
return branding
@router.post("/platforms/{platform}/copy/{app_id}")
async def copy_app_to_platform(
platform: str,
app_id: str,
target_platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Copy an app from one platform to another."""
if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid platform(s)",
)
config = _load_config()
platforms = config.get("platforms", {})
source_apps = platforms.get(platform, [])
# Find source app
source_app = None
for app in source_apps:
if app.get("id") == app_id:
source_app = app.copy()
break
if not source_app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Generate new ID for copied app
import time
new_id = f"{app_id}-copy-{int(time.time())}"
source_app["id"] = new_id
# Add to target platform
if target_platform not in platforms:
platforms[target_platform] = []
platforms[target_platform].append(source_app)
config["platforms"] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'")
return {"status": "copied", "new_id": new_id, "target_platform": target_platform}
+264
View File
@@ -0,0 +1,264 @@
"""Admin settings routes for cabinet - system configuration management."""
import logging
from typing import Any, Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.system_settings_service import (
ReadOnlySettingError,
bot_configuration_service,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/settings", tags=["Admin Settings"])
# ============ Schemas ============
class SettingCategoryRef(BaseModel):
"""Reference to category."""
key: str
label: str
class SettingCategorySummary(BaseModel):
"""Category summary."""
key: str
label: str
description: str = ""
items: int
class SettingChoice(BaseModel):
"""Choice option for setting."""
value: Any
label: str
description: Optional[str] = None
class SettingHint(BaseModel):
"""Setting hints and guidance."""
description: str = ""
format: str = ""
example: str = ""
warning: str = ""
class SettingDefinition(BaseModel):
"""Full setting definition with current state."""
key: str
name: str
category: SettingCategoryRef
type: str
is_optional: bool
current: Any = Field(default=None)
original: Any = Field(default=None)
has_override: bool
read_only: bool = Field(default=False)
choices: List[SettingChoice] = Field(default_factory=list)
hint: Optional[SettingHint] = None
class SettingUpdateRequest(BaseModel):
"""Request to update setting value."""
value: Any
# ============ Helper Functions ============
def _coerce_value(key: str, value: Any) -> Any:
"""Convert and validate value for a setting."""
definition = bot_configuration_service.get_definition(key)
if value is None:
if definition.is_optional:
return None
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Value is required")
python_type = definition.python_type
try:
if python_type is bool:
if isinstance(value, bool):
normalized = value
elif isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"true", "1", "yes", "on", "да"}:
normalized = True
elif lowered in {"false", "0", "no", "off", "нет"}:
normalized = False
else:
raise ValueError("invalid bool")
else:
raise ValueError("invalid bool")
elif python_type is int:
normalized = int(value)
elif python_type is float:
normalized = float(value)
else:
normalized = str(value)
except ValueError:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid value type") from None
choices = bot_configuration_service.get_choice_options(key)
if choices:
allowed_values = {option.value for option in choices}
if normalized not in allowed_values:
readable = ", ".join(bot_configuration_service.format_value(opt.value) for opt in choices)
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Value must be one of: {readable}",
)
return normalized
def _serialize_definition(definition, include_choices: bool = True) -> SettingDefinition:
"""Serialize setting definition to response model."""
current = bot_configuration_service.get_current_value(definition.key)
original = bot_configuration_service.get_original_value(definition.key)
has_override = bot_configuration_service.has_override(definition.key)
choices: List[SettingChoice] = []
if include_choices:
choices = [
SettingChoice(
value=option.value,
label=option.label,
description=option.description,
)
for option in bot_configuration_service.get_choice_options(definition.key)
]
# Get setting hints
guidance = bot_configuration_service.get_setting_guidance(definition.key)
hint = SettingHint(
description=guidance.get("description", ""),
format=guidance.get("format", ""),
example=guidance.get("example", ""),
warning=guidance.get("warning", ""),
)
return SettingDefinition(
key=definition.key,
name=definition.display_name,
category=SettingCategoryRef(
key=definition.category_key,
label=definition.category_label,
),
type=definition.type_label,
is_optional=definition.is_optional,
current=current,
original=original,
has_override=has_override,
read_only=bot_configuration_service.is_read_only(definition.key),
choices=choices,
hint=hint,
)
# ============ Routes ============
@router.get("/categories", response_model=List[SettingCategorySummary])
async def list_categories(
admin: User = Depends(get_current_admin_user),
):
"""Get list of setting categories."""
categories = bot_configuration_service.get_categories()
return [
SettingCategorySummary(
key=key,
label=label,
description=bot_configuration_service.get_category_description(key),
items=count,
)
for key, label, count in categories
]
@router.get("", response_model=List[SettingDefinition])
async def list_settings(
admin: User = Depends(get_current_admin_user),
category: Optional[str] = Query(default=None, alias="category_key"),
):
"""Get list of all settings or settings for a specific category."""
items: List[SettingDefinition] = []
if category:
definitions = bot_configuration_service.get_settings_for_category(category)
items.extend(_serialize_definition(defn) for defn in definitions)
return items
for category_key, _, _ in bot_configuration_service.get_categories():
definitions = bot_configuration_service.get_settings_for_category(category_key)
items.extend(_serialize_definition(defn) for defn in definitions)
return items
@router.get("/{key}", response_model=SettingDefinition)
async def get_setting(
key: str,
admin: User = Depends(get_current_admin_user),
):
"""Get a specific setting by key."""
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
return _serialize_definition(definition)
@router.put("/{key}", response_model=SettingDefinition)
async def update_setting(
key: str,
payload: SettingUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update a setting value."""
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
value = _coerce_value(key, payload.value)
try:
await bot_configuration_service.set_value(db, key, value)
except ReadOnlySettingError as error:
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
await db.commit()
logger.info(f"Admin {admin.telegram_id} updated setting {key} to {value}")
return _serialize_definition(definition)
@router.delete("/{key}", response_model=SettingDefinition)
async def reset_setting(
key: str,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset a setting to its default value."""
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
try:
await bot_configuration_service.reset_value(db, key)
except ReadOnlySettingError as error:
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
await db.commit()
logger.info(f"Admin {admin.telegram_id} reset setting {key}")
return _serialize_definition(definition)
+450
View File
@@ -0,0 +1,450 @@
"""Admin tickets routes for cabinet."""
import logging
import math
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, Field
from app.database.models import User, Ticket, TicketMessage
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
from app.config import settings
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.tickets import TicketMessageResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/tickets", tags=["Cabinet Admin Tickets"])
# Admin-specific schemas
class AdminTicketUserInfo(BaseModel):
"""User info for admin view."""
id: int
telegram_id: int
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
class Config:
from_attributes = True
class AdminTicketResponse(BaseModel):
"""Ticket data for admin."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: Optional[datetime] = None
messages_count: int = 0
user: Optional[AdminTicketUserInfo] = None
last_message: Optional[TicketMessageResponse] = None
class Config:
from_attributes = True
class AdminTicketDetailResponse(BaseModel):
"""Ticket with all messages for admin."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: Optional[datetime] = None
is_reply_blocked: bool = False
user: Optional[AdminTicketUserInfo] = None
messages: List[TicketMessageResponse] = []
class Config:
from_attributes = True
class AdminTicketListResponse(BaseModel):
"""Paginated ticket list for admin."""
items: List[AdminTicketResponse]
total: int
page: int
per_page: int
pages: int
class AdminReplyRequest(BaseModel):
"""Admin reply to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description="Reply message")
class AdminStatusUpdateRequest(BaseModel):
"""Update ticket status."""
status: str = Field(..., description="New status: open, answered, pending, closed")
class AdminPriorityUpdateRequest(BaseModel):
"""Update ticket priority."""
priority: str = Field(..., description="New priority: low, normal, high, urgent")
class AdminStatsResponse(BaseModel):
"""Ticket statistics for admin."""
total: int
open: int
pending: int
answered: int
closed: int
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
"""Convert TicketMessage to response."""
return TicketMessageResponse(
id=message.id,
message_text=message.message_text or "",
is_from_admin=message.is_from_admin,
has_media=bool(message.media_file_id),
media_type=message.media_type,
media_caption=message.media_caption,
created_at=message.created_at,
)
def _user_to_info(user: User) -> AdminTicketUserInfo:
"""Convert User to admin info."""
return AdminTicketUserInfo(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
)
def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) -> AdminTicketResponse:
"""Convert Ticket to admin response."""
last_message = None
messages_count = len(ticket.messages) if ticket.messages else 0
if ticket.messages:
last_msg = max(ticket.messages, key=lambda m: m.created_at)
last_message = _message_to_response(last_msg)
user_info = None
if hasattr(ticket, 'user') and ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
status=ticket.status,
priority=ticket.priority or "normal",
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
messages_count=messages_count,
user=user_info,
last_message=last_message,
)
@router.get("/stats", response_model=AdminStatsResponse)
async def get_ticket_stats(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket statistics."""
# Total count
total_result = await db.execute(select(func.count()).select_from(Ticket))
total = total_result.scalar() or 0
# Count by status
statuses = {}
for status_name in ["open", "pending", "answered", "closed"]:
result = await db.execute(
select(func.count()).select_from(Ticket).where(Ticket.status == status_name)
)
statuses[status_name] = result.scalar() or 0
return AdminStatsResponse(
total=total,
open=statuses.get("open", 0),
pending=statuses.get("pending", 0),
answered=statuses.get("answered", 0),
closed=statuses.get("closed", 0),
)
@router.get("", response_model=AdminTicketListResponse)
async def get_all_tickets(
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"),
priority_filter: Optional[str] = Query(None, alias="priority", description="Filter by priority"),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all tickets for admin."""
# Base query with user relationship
query = (
select(Ticket)
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
# Build count query
count_query = select(func.count()).select_from(Ticket)
# Apply filters
if status_filter:
query = query.where(Ticket.status == status_filter)
count_query = count_query.where(Ticket.status == status_filter)
if priority_filter:
query = query.where(Ticket.priority == priority_filter)
count_query = count_query.where(Ticket.priority == priority_filter)
# Get total count
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate - order by updated_at desc (newest first)
offset = (page - 1) * per_page
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(per_page)
result = await db.execute(query)
tickets = result.scalars().all()
items = [_ticket_to_admin_response(t) for t in tickets]
pages = math.ceil(total / per_page) if total > 0 else 1
return AdminTicketListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get("/{ticket_id}", response_model=AdminTicketDetailResponse)
async def get_ticket_detail(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket with all messages for admin."""
query = (
select(Ticket)
.where(Ticket.id == ticket_id)
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
user_info = None
if ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
status=ticket.status,
priority=ticket.priority or "normal",
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False,
user=user_info,
messages=messages_response,
)
@router.post("/{ticket_id}/reply", response_model=TicketMessageResponse)
async def reply_to_ticket(
ticket_id: int,
request: AdminReplyRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reply to a ticket as admin."""
# Get ticket
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
)
# Create admin message
message = TicketMessage(
ticket_id=ticket.id,
user_id=ticket.user_id,
message_text=request.message,
is_from_admin=True,
created_at=datetime.utcnow(),
)
db.add(message)
# Update ticket status to answered
ticket.status = "answered"
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
# Try to notify user via Telegram
try:
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
from app.handlers.admin.tickets import notify_user_about_ticket_reply
await notify_user_about_ticket_reply(bot, ticket, request.message, db)
except Exception as e:
logger.warning(f"Failed to notify user about ticket reply: {e}")
finally:
await bot.session.close()
except Exception as e:
logger.warning(f"Failed to send Telegram notification: {e}")
return _message_to_response(message)
@router.post("/{ticket_id}/status", response_model=AdminTicketDetailResponse)
async def update_ticket_status(
ticket_id: int,
request: AdminStatusUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket status."""
allowed_statuses = {"open", "pending", "answered", "closed"}
if request.status not in allowed_statuses:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status. Allowed: {', '.join(allowed_statuses)}",
)
query = (
select(Ticket)
.where(Ticket.id == ticket_id)
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
)
ticket.status = request.status
ticket.updated_at = datetime.utcnow()
if request.status == "closed":
ticket.closed_at = datetime.utcnow()
else:
ticket.closed_at = None
await db.commit()
await db.refresh(ticket)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
user_info = None
if ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
status=ticket.status,
priority=ticket.priority or "normal",
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False,
user=user_info,
messages=messages_response,
)
@router.post("/{ticket_id}/priority", response_model=AdminTicketDetailResponse)
async def update_ticket_priority(
ticket_id: int,
request: AdminPriorityUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket priority."""
allowed_priorities = {"low", "normal", "high", "urgent"}
if request.priority not in allowed_priorities:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid priority. Allowed: {', '.join(allowed_priorities)}",
)
query = (
select(Ticket)
.where(Ticket.id == ticket_id)
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
)
ticket.priority = request.priority
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(ticket)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
user_info = None
if ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
status=ticket.status,
priority=ticket.priority or "normal",
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False,
user=user_info,
messages=messages_response,
)
+597
View File
@@ -0,0 +1,597 @@
"""Authentication routes for cabinet."""
import hashlib
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database.models import User, CabinetRefreshToken
from app.database.crud.user import get_user_by_telegram_id, get_user_by_id, create_user
from app.config import settings
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.auth import (
TelegramAuthRequest,
TelegramWidgetAuthRequest,
EmailRegisterRequest,
EmailVerifyRequest,
EmailLoginRequest,
RefreshTokenRequest,
PasswordForgotRequest,
PasswordResetRequest,
TokenResponse,
UserResponse,
AuthResponse,
)
from ..auth import (
validate_telegram_login_widget,
validate_telegram_init_data,
create_access_token,
create_refresh_token,
get_token_payload,
hash_password,
verify_password,
)
from ..auth.jwt_handler import get_refresh_token_expires_at
from ..auth.email_verification import (
generate_verification_token,
generate_password_reset_token,
get_verification_expires_at,
get_password_reset_expires_at,
is_token_expired,
)
from ..services.email_service import email_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["Cabinet Auth"])
def _user_to_response(user: User) -> UserResponse:
"""Convert User model to UserResponse."""
return UserResponse(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
email=user.email,
email_verified=user.email_verified,
balance_kopeks=user.balance_kopeks,
balance_rubles=user.balance_rubles,
referral_code=user.referral_code,
language=user.language,
created_at=user.created_at,
)
def _create_auth_response(user: User) -> AuthResponse:
"""Create full auth response with tokens."""
access_token = create_access_token(user.id, user.telegram_id)
refresh_token = create_refresh_token(user.id)
expires_in = settings.get_cabinet_access_token_expire_minutes() * 60
return AuthResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in=expires_in,
user=_user_to_response(user),
)
async def _store_refresh_token(
db: AsyncSession,
user_id: int,
refresh_token: str,
device_info: Optional[str] = None,
) -> None:
"""Store refresh token hash in database."""
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
expires_at = get_refresh_token_expires_at()
token_record = CabinetRefreshToken(
user_id=user_id,
token_hash=token_hash,
device_info=device_info,
expires_at=expires_at,
)
db.add(token_record)
await db.commit()
@router.post("/telegram", response_model=AuthResponse)
async def auth_telegram(
request: TelegramAuthRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Authenticate using Telegram WebApp initData.
This endpoint validates the initData from Telegram WebApp and returns
JWT tokens for authenticated access.
"""
user_data = validate_telegram_init_data(request.init_data)
if not user_data:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired Telegram authentication data",
)
telegram_id = user_data.get("id")
if not telegram_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing Telegram user ID",
)
user = await get_user_by_telegram_id(db, telegram_id)
# Get user data from initData
tg_username = user_data.get("username")
tg_first_name = user_data.get("first_name")
tg_last_name = user_data.get("last_name")
tg_language = user_data.get("language_code", "ru")
if not user:
# Create new user from Telegram initData
logger.info(f"Creating new user from cabinet (initData): telegram_id={telegram_id}")
user = await create_user(
db=db,
telegram_id=telegram_id,
username=tg_username,
first_name=tg_first_name,
last_name=tg_last_name,
language=tg_language,
)
logger.info(f"User created successfully: id={user.id}, telegram_id={user.telegram_id}")
else:
# Update user info from initData (like bot middleware does)
updated = False
if tg_username and tg_username != user.username:
user.username = tg_username
updated = True
if tg_first_name and tg_first_name != user.first_name:
user.first_name = tg_first_name
updated = True
if tg_last_name and tg_last_name != user.last_name:
user.last_name = tg_last_name
updated = True
if updated:
logger.info(f"User {user.id} profile updated from initData")
if user.status != "active":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is not active",
)
# Update last login
user.cabinet_last_login = datetime.utcnow()
await db.commit()
response = _create_auth_response(user)
# Store refresh token
await _store_refresh_token(db, user.id, response.refresh_token)
return response
@router.post("/telegram/widget", response_model=AuthResponse)
async def auth_telegram_widget(
request: TelegramWidgetAuthRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Authenticate using Telegram Login Widget data.
This endpoint validates data from Telegram Login Widget and returns
JWT tokens for authenticated access.
"""
widget_data = request.model_dump()
if not validate_telegram_login_widget(widget_data):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired Telegram authentication data",
)
user = await get_user_by_telegram_id(db, request.id)
if not user:
# Create new user from Telegram data
logger.info(f"Creating new user from cabinet: telegram_id={request.id}, username={request.username}")
user = await create_user(
db=db,
telegram_id=request.id,
username=request.username,
first_name=request.first_name,
last_name=request.last_name,
language="ru",
)
logger.info(f"User created successfully: 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 widget data
if request.username and request.username != user.username:
user.username = request.username
if request.first_name and request.first_name != user.first_name:
user.first_name = request.first_name
if request.last_name != user.last_name:
user.last_name = request.last_name
user.cabinet_last_login = datetime.utcnow()
await db.commit()
response = _create_auth_response(user)
await _store_refresh_token(db, user.id, response.refresh_token)
return response
@router.post("/email/register")
async def register_email(
request: EmailRegisterRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Register/link email to existing Telegram account.
Requires valid JWT token from Telegram authentication.
Sends verification email to the provided address.
"""
# Check if email already exists
existing_user = await db.execute(
select(User).where(User.email == request.email)
)
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This email is already registered",
)
# Check if user already has email
if user.email and user.email_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
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()
# Send verification email
if email_service.is_configured():
# TODO: Get actual verification URL from settings
verification_url = "https://example.com/cabinet/verify-email"
email_service.send_verification_email(
to_email=request.email,
verification_token=verification_token,
verification_url=verification_url,
username=user.first_name,
)
return {
"message": "Verification email sent",
"email": request.email,
}
@router.post("/email/verify")
async def verify_email(
request: EmailVerifyRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Verify email with token."""
# Find user with this token
result = await db.execute(
select(User).where(User.email_verification_token == request.token)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid verification token",
)
if is_token_expired(user.email_verification_expires):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Verification token has expired",
)
# Mark email as verified
user.email_verified = True
user.email_verified_at = datetime.utcnow()
user.email_verification_token = None
user.email_verification_expires = None
await db.commit()
return {"message": "Email verified successfully"}
@router.post("/email/resend")
async def resend_verification(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Resend verification email."""
if not user.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No email address to verify",
)
if user.email_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is already verified",
)
# Generate new token
verification_token = generate_verification_token()
verification_expires = get_verification_expires_at()
user.email_verification_token = verification_token
user.email_verification_expires = verification_expires
await db.commit()
# Send verification email
if email_service.is_configured():
verification_url = "https://example.com/cabinet/verify-email"
email_service.send_verification_email(
to_email=user.email,
verification_token=verification_token,
verification_url=verification_url,
username=user.first_name,
)
return {"message": "Verification email sent"}
@router.post("/email/login", response_model=AuthResponse)
async def login_email(
request: EmailLoginRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Login with email and password."""
# Find user by email
result = await db.execute(
select(User).where(User.email == request.email)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
if not user.password_hash:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Password login not configured for this account",
)
if not verify_password(request.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
if not user.email_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Please verify your email first",
)
if user.status != "active":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is not active",
)
user.cabinet_last_login = datetime.utcnow()
await db.commit()
response = _create_auth_response(user)
await _store_refresh_token(db, user.id, response.refresh_token)
return response
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
request: RefreshTokenRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Refresh access token using refresh token."""
payload = get_token_payload(request.refresh_token, expected_type="refresh")
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
)
try:
user_id = int(payload.get("sub"))
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
# Verify token exists in database and is not revoked
token_hash = hashlib.sha256(request.refresh_token.encode()).hexdigest()
result = await db.execute(
select(CabinetRefreshToken).where(
CabinetRefreshToken.token_hash == token_hash,
CabinetRefreshToken.revoked_at.is_(None),
)
)
token_record = result.scalar_one_or_none()
if not token_record:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token not found or revoked",
)
if not token_record.is_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token is no longer valid",
)
user = await get_user_by_id(db, user_id)
if not user or user.status != "active":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
access_token = create_access_token(user.id, user.telegram_id)
expires_in = settings.get_cabinet_access_token_expire_minutes() * 60
return TokenResponse(
access_token=access_token,
refresh_token=request.refresh_token,
token_type="bearer",
expires_in=expires_in,
)
@router.post("/logout")
async def logout(
request: RefreshTokenRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Logout and revoke refresh token."""
token_hash = hashlib.sha256(request.refresh_token.encode()).hexdigest()
result = await db.execute(
select(CabinetRefreshToken).where(
CabinetRefreshToken.token_hash == token_hash,
)
)
token_record = result.scalar_one_or_none()
if token_record:
token_record.revoked_at = datetime.utcnow()
await db.commit()
return {"message": "Logged out successfully"}
@router.post("/password/forgot")
async def forgot_password(
request: PasswordForgotRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Request password reset."""
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:
return {"message": "If the email exists, a password reset link has been sent"}
# Generate reset token
reset_token = generate_password_reset_token()
reset_expires = get_password_reset_expires_at()
user.password_reset_token = reset_token
user.password_reset_expires = reset_expires
await db.commit()
# Send reset email
if email_service.is_configured():
reset_url = "https://example.com/cabinet/reset-password"
email_service.send_password_reset_email(
to_email=user.email,
reset_token=reset_token,
reset_url=reset_url,
username=user.first_name,
)
return {"message": "If the email exists, a password reset link has been sent"}
@router.post("/password/reset")
async def reset_password(
request: PasswordResetRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset password with token."""
result = await db.execute(
select(User).where(User.password_reset_token == request.token)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid reset token",
)
if is_token_expired(user.password_reset_expires):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Reset token has expired",
)
# Update password
user.password_hash = hash_password(request.password)
user.password_reset_token = None
user.password_reset_expires = None
await db.commit()
return {"message": "Password reset successfully"}
@router.get("/me", response_model=UserResponse)
async def get_current_user(
user: User = Depends(get_current_cabinet_user),
):
"""Get current authenticated user info."""
return _user_to_response(user)
@router.get("/me/is-admin")
async def check_is_admin(
user: User = Depends(get_current_cabinet_user),
):
"""Check if current user is an admin."""
is_admin = settings.is_admin(user.telegram_id)
return {"is_admin": is_admin}
+366
View File
@@ -0,0 +1,366 @@
"""Balance and payment routes for cabinet."""
import logging
import math
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from app.database.models import User, Transaction
from app.config import settings
from app.services.yookassa_service import YooKassaService
from app.external.cryptobot import CryptoBotService
from app.database.crud.user import get_user_by_id
from app.services.payment_service import PaymentService
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.balance import (
BalanceResponse,
TransactionResponse,
TransactionListResponse,
PaymentMethodResponse,
TopUpRequest,
TopUpResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/balance", tags=["Cabinet Balance"])
@router.get("", response_model=BalanceResponse)
async def get_balance(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get current user's balance."""
# Reload user from current session to get fresh data
# (user object is from different session in get_current_cabinet_user)
fresh_user = await get_user_by_id(db, user.id)
if not fresh_user:
raise HTTPException(status_code=404, detail="User not found")
return BalanceResponse(
balance_kopeks=fresh_user.balance_kopeks,
balance_rubles=fresh_user.balance_kopeks / 100,
)
@router.get("/transactions", response_model=TransactionListResponse)
async def get_transactions(
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
type: Optional[str] = Query(None, description="Filter by transaction type"),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get transaction history."""
# Base query
query = select(Transaction).where(Transaction.user_id == user.id)
# Filter by type
if type:
query = query.where(Transaction.type == type)
# Get total count
count_query = select(func.count()).select_from(Transaction).where(Transaction.user_id == user.id)
if type:
count_query = count_query.where(Transaction.type == type)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate
offset = (page - 1) * per_page
query = query.order_by(desc(Transaction.created_at)).offset(offset).limit(per_page)
result = await db.execute(query)
transactions = result.scalars().all()
items = [
TransactionResponse(
id=t.id,
type=t.type,
amount_kopeks=t.amount_kopeks,
amount_rubles=t.amount_kopeks / 100,
description=t.description,
payment_method=t.payment_method,
is_completed=t.is_completed,
created_at=t.created_at,
completed_at=t.completed_at,
)
for t in transactions
]
pages = math.ceil(total / per_page) if total > 0 else 1
return TransactionListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get("/payment-methods", response_model=List[PaymentMethodResponse])
async def get_payment_methods():
"""Get available payment methods."""
methods = []
# YooKassa
if settings.is_yookassa_enabled():
methods.append(PaymentMethodResponse(
id="yookassa",
name="YooKassa (Bank Card)",
description="Pay with bank card via YooKassa",
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
is_available=True,
))
# CryptoBot
if settings.is_cryptobot_enabled():
methods.append(PaymentMethodResponse(
id="cryptobot",
name="CryptoBot",
description="Pay with cryptocurrency via CryptoBot",
min_amount_kopeks=1000,
max_amount_kopeks=10000000,
is_available=True,
))
# Telegram Stars
if settings.TELEGRAM_STARS_ENABLED:
methods.append(PaymentMethodResponse(
id="telegram_stars",
name="Telegram Stars",
description="Pay with Telegram Stars",
min_amount_kopeks=100,
max_amount_kopeks=1000000,
is_available=True,
))
# Heleket
if settings.is_heleket_enabled():
methods.append(PaymentMethodResponse(
id="heleket",
name="Heleket Crypto",
description="Pay with cryptocurrency via Heleket",
min_amount_kopeks=1000,
max_amount_kopeks=10000000,
is_available=True,
))
# MulenPay
if settings.is_mulenpay_enabled():
methods.append(PaymentMethodResponse(
id="mulenpay",
name=settings.get_mulenpay_display_name(),
description="MulenPay payment",
min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS,
is_available=True,
))
# PAL24
if settings.is_pal24_enabled():
methods.append(PaymentMethodResponse(
id="pal24",
name="PAL24",
description="Pay via PAL24",
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS,
is_available=True,
))
# Platega
if settings.is_platega_enabled():
methods.append(PaymentMethodResponse(
id="platega",
name="Platega",
description="Pay via Platega",
min_amount_kopeks=settings.PLATEGA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.PLATEGA_MAX_AMOUNT_KOPEKS,
is_available=True,
))
# Wata
if settings.is_wata_enabled():
methods.append(PaymentMethodResponse(
id="wata",
name="Wata",
description="Pay via Wata",
min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS,
is_available=True,
))
return methods
@router.post("/topup", response_model=TopUpResponse)
async def create_topup(
request: TopUpRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create payment for balance top-up."""
# Validate payment method
methods = await get_payment_methods()
method = next((m for m in methods if m.id == request.payment_method), None)
if not method or not method.is_available:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or unavailable payment method",
)
# Validate amount
if request.amount_kopeks < method.min_amount_kopeks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Minimum amount is {method.min_amount_kopeks / 100:.2f} RUB",
)
if request.amount_kopeks > method.max_amount_kopeks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Maximum amount is {method.max_amount_kopeks / 100:.2f} RUB",
)
amount_rubles = request.amount_kopeks / 100
payment_url = None
payment_id = None
try:
if request.payment_method == "yookassa":
yookassa_service = YooKassaService()
result = await yookassa_service.create_payment(
amount=amount_rubles,
currency="RUB",
description=f"Пополнение баланса на {amount_rubles:.2f}",
metadata={
"user_id": str(user.id),
"amount_kopeks": str(request.amount_kopeks),
"type": "balance_topup",
"source": "cabinet",
},
)
if result and not result.get("error"):
payment_url = result.get("confirmation_url")
payment_id = result.get("id")
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create YooKassa payment",
)
elif request.payment_method == "cryptobot":
cryptobot_service = CryptoBotService()
# Convert RUB to USDT (approximate)
usdt_amount = amount_rubles / 100 # Approximate rate
result = await cryptobot_service.create_invoice(
amount=usdt_amount,
asset="USDT",
description=f"Balance top-up {amount_rubles:.2f} RUB",
payload=f"cabinet_topup_{user.id}_{request.amount_kopeks}",
)
if result:
payment_url = result.get("pay_url") or result.get("bot_invoice_url")
payment_id = str(result.get("invoice_id"))
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create CryptoBot invoice",
)
elif request.payment_method == "telegram_stars":
# Telegram Stars payments require bot interaction
bot_username = settings.get_bot_username() or "bot"
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Telegram Stars payments are only available through the bot. Please use @{bot_username}",
)
elif request.payment_method == "platega":
if not settings.is_platega_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Platega payment method is unavailable",
)
active_methods = settings.get_platega_active_methods()
if not active_methods:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No Platega payment methods configured",
)
# Use payment_option if provided, otherwise use first active method
method_option = request.payment_option or str(active_methods[0])
try:
method_code = int(str(method_option).strip())
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid Platega payment option",
)
if method_code not in active_methods:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Selected Platega method is unavailable",
)
payment_service = PaymentService()
result = await payment_service.create_platega_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_code=method_code,
)
if result and result.get("redirect_url"):
payment_url = result.get("redirect_url")
payment_id = result.get("transaction_id") or str(result.get("local_payment_id", "pending"))
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create Platega payment",
)
else:
# For other payment methods, redirect to bot
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This payment method is only available through the Telegram bot.",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Payment creation error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create payment. Please try again later.",
)
if not payment_url:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Payment URL not received",
)
return TopUpResponse(
payment_id=payment_id or "pending",
payment_url=payment_url,
amount_kopeks=request.amount_kopeks,
amount_rubles=amount_rubles,
status="pending",
expires_at=None,
)
+281
View File
@@ -0,0 +1,281 @@
"""Branding routes for cabinet - logo and project name management."""
import logging
import os
import base64
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database.models import User, SystemSetting
from app.config import settings
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/branding", tags=["Branding"])
# Directory for storing branding assets
BRANDING_DIR = Path("data/branding")
LOGO_FILENAME = "logo.png"
# Settings keys
BRANDING_NAME_KEY = "CABINET_BRANDING_NAME"
BRANDING_LOGO_KEY = "CABINET_BRANDING_LOGO" # Stores "custom" or "default"
# Allowed image types
ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/svg+xml"}
MAX_FILE_SIZE = 2 * 1024 * 1024 # 2MB
# ============ Schemas ============
class BrandingResponse(BaseModel):
"""Current branding settings."""
name: str
logo_url: Optional[str] = None
logo_letter: str
has_custom_logo: bool
class BrandingNameUpdate(BaseModel):
"""Request to update branding name."""
name: str
# ============ Helper Functions ============
def ensure_branding_dir():
"""Ensure branding directory exists."""
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
async def get_setting_value(db: AsyncSession, key: str) -> Optional[str]:
"""Get a setting value from database."""
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == key)
)
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def set_setting_value(db: AsyncSession, key: str, value: str):
"""Set a setting value in database."""
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == key)
)
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = SystemSetting(key=key, value=value)
db.add(setting)
await db.commit()
def get_logo_path() -> Path:
"""Get the path to the custom logo file."""
return BRANDING_DIR / LOGO_FILENAME
def has_custom_logo() -> bool:
"""Check if a custom logo exists."""
return get_logo_path().exists()
# ============ Routes ============
@router.get("", response_model=BrandingResponse)
async def get_branding(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get current branding settings.
This is a public endpoint - no authentication required.
"""
# Get name from database or use default from env/settings
name = await get_setting_value(db, BRANDING_NAME_KEY)
if name is None: # Only use fallback if not set at all (empty string is valid)
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or \
os.getenv('VITE_APP_NAME', 'Cabinet')
# Check for custom logo
custom_logo = has_custom_logo()
# Get first letter for logo fallback (use "V" if name is empty)
logo_letter = name[0].upper() if name else "V"
return BrandingResponse(
name=name,
logo_url="/cabinet/branding/logo" if custom_logo else None,
logo_letter=logo_letter,
has_custom_logo=custom_logo,
)
@router.get("/logo")
async def get_logo():
"""
Get the custom logo image.
Returns 404 if no custom logo is set.
"""
logo_path = get_logo_path()
if not logo_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No custom logo set"
)
# Determine media type from file extension
suffix = logo_path.suffix.lower()
media_types = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
}
media_type = media_types.get(suffix, "image/png")
return FileResponse(
logo_path,
media_type=media_type,
headers={"Cache-Control": "public, max-age=3600"}
)
@router.put("/name", response_model=BrandingResponse)
async def update_branding_name(
payload: BrandingNameUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update the project name. Admin only. Empty name allowed (logo only mode)."""
name = payload.name.strip() if payload.name else ""
if len(name) > 50:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Name too long (max 50 characters)"
)
await set_setting_value(db, BRANDING_NAME_KEY, name)
logger.info(f"Admin {admin.telegram_id} updated branding name to: {name}")
# Return updated branding
custom_logo = has_custom_logo()
logo_letter = name[0].upper() if name else "C"
return BrandingResponse(
name=name,
logo_url="/cabinet/branding/logo" if custom_logo else None,
logo_letter=logo_letter,
has_custom_logo=custom_logo,
)
@router.post("/logo", response_model=BrandingResponse)
async def upload_logo(
file: UploadFile = File(...),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Upload a custom logo. Admin only."""
# Validate content type
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed: PNG, JPEG, WebP, SVG"
)
# Read file content
content = await file.read()
# Validate file size
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB"
)
# Ensure directory exists
ensure_branding_dir()
# Determine file extension from content type
ext_map = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
extension = ext_map.get(file.content_type, ".png")
# Remove old logo files with any extension
for old_file in BRANDING_DIR.glob("logo.*"):
old_file.unlink()
# Save new logo
logo_path = BRANDING_DIR / f"logo{extension}"
logo_path.write_bytes(content)
# Mark that we have a custom logo
await set_setting_value(db, BRANDING_LOGO_KEY, "custom")
logger.info(f"Admin {admin.telegram_id} uploaded new logo: {logo_path}")
# Get current name for response
name = await get_setting_value(db, BRANDING_NAME_KEY)
if name is None: # Only use fallback if not set at all (empty string is valid)
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or \
os.getenv('VITE_APP_NAME', 'Cabinet')
logo_letter = name[0].upper() if name else "C"
return BrandingResponse(
name=name,
logo_url="/cabinet/branding/logo",
logo_letter=logo_letter,
has_custom_logo=True,
)
@router.delete("/logo", response_model=BrandingResponse)
async def delete_logo(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete custom logo and revert to letter. Admin only."""
# Remove logo files
for old_file in BRANDING_DIR.glob("logo.*"):
old_file.unlink()
# Update setting
await set_setting_value(db, BRANDING_LOGO_KEY, "default")
logger.info(f"Admin {admin.telegram_id} deleted custom logo")
# Get current name for response
name = await get_setting_value(db, BRANDING_NAME_KEY)
if name is None: # Only use fallback if not set at all (empty string is valid)
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or \
os.getenv('VITE_APP_NAME', 'Cabinet')
logo_letter = name[0].upper() if name else "C"
return BrandingResponse(
name=name,
logo_url=None,
logo_letter=logo_letter,
has_custom_logo=False,
)
+387
View File
@@ -0,0 +1,387 @@
"""Contests routes for cabinet - user participation in games/contests."""
import logging
import random
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User, SubscriptionStatus
from app.database.crud.contest import (
get_active_rounds,
get_attempt,
create_attempt,
increment_winner_count,
)
from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription
from app.services.contest_rotation_service import (
GAME_QUEST,
GAME_LOCKS,
GAME_CIPHER,
GAME_SERVER,
GAME_BLITZ,
GAME_EMOJI,
GAME_ANAGRAM,
)
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/contests", tags=["Cabinet Contests"])
# ============ Schemas ============
class ContestInfo(BaseModel):
"""Contest/game info."""
id: int
slug: str
name: str
description: Optional[str] = None
prize_days: int
is_available: bool
already_played: bool = False
class ContestGameData(BaseModel):
"""Data for playing a contest game."""
round_id: int
game_type: str
game_data: Dict[str, Any]
instructions: str
class ContestAnswerRequest(BaseModel):
"""Request to submit contest answer."""
round_id: int
answer: str
class ContestResult(BaseModel):
"""Result of contest attempt."""
is_winner: bool
message: str
prize_days: Optional[int] = None
# ============ Helpers ============
def _user_allowed(subscription) -> bool:
"""Check if user is allowed to participate in contests."""
if not subscription:
return False
return subscription.status in {
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
}
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int) -> str:
"""Award prize to winner."""
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return "Error: subscription not found"
subscription.end_date = subscription.end_date + timedelta(days=prize_days)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f"🎁 Extended subscription for user {user_id} by {prize_days} days (contest prize)")
return f"Subscription extended by {prize_days} days"
# ============ Routes ============
class ContestsCountResponse(BaseModel):
"""Count of available contests."""
count: int
@router.get("/count", response_model=ContestsCountResponse)
async def get_contests_count(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get count of contests available for the user."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
return ContestsCountResponse(count=0)
active_rounds = await get_active_rounds(db)
# Count unique available contests (not yet played)
count = 0
seen_templates = set()
for rnd in active_rounds:
if not rnd.template or not rnd.template.is_enabled:
continue
tpl_slug = rnd.template.slug if rnd.template else ""
if tpl_slug in seen_templates:
continue
seen_templates.add(tpl_slug)
# Check if user already played this round
attempt = await get_attempt(db, rnd.id, user.id)
if not attempt:
count += 1
return ContestsCountResponse(count=count)
@router.get("", response_model=List[ContestInfo])
async def get_contests(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available contests/games."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Contests are only available for users with active or trial subscriptions",
)
active_rounds = await get_active_rounds(db)
# Group by template to avoid duplicates
unique_templates = {}
for rnd in active_rounds:
if not rnd.template or not rnd.template.is_enabled:
continue
tpl_slug = rnd.template.slug if rnd.template else ""
if tpl_slug not in unique_templates:
unique_templates[tpl_slug] = rnd
contests = []
for tpl_slug, rnd in unique_templates.items():
# Check if user already played this round
attempt = await get_attempt(db, rnd.id, user.id)
contests.append(ContestInfo(
id=rnd.id,
slug=tpl_slug,
name=rnd.template.name if rnd.template else tpl_slug,
description=rnd.template.description if rnd.template else None,
prize_days=rnd.template.prize_days if rnd.template else 0,
is_available=True,
already_played=attempt is not None,
))
return contests
@router.get("/{round_id}", response_model=ContestGameData)
async def get_contest_game(
round_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get game data for a specific contest round."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Contests are only available for users with active or trial subscriptions",
)
active_rounds = await get_active_rounds(db)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Contest round not found or already finished",
)
if not round_obj.template or not round_obj.template.is_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This contest is disabled",
)
# Check if already played
attempt = await get_attempt(db, round_id, user.id)
if attempt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You have already played this round",
)
tpl = round_obj.template
game_type = tpl.slug
game_data = {}
instructions = ""
if game_type == GAME_QUEST:
rows = round_obj.payload.get("rows", 3)
cols = round_obj.payload.get("cols", 3)
secret = random.randint(0, rows * cols - 1)
game_data = {
"rows": rows,
"cols": cols,
"secret": secret,
"grid_size": rows * cols,
}
instructions = "Select one of the nodes in the grid. Find the hidden server!"
elif game_type == GAME_LOCKS:
total = round_obj.payload.get("total", 20)
secret = random.randint(0, total - 1)
game_data = {
"total": total,
"secret": secret,
}
instructions = "Find the unlocked button among the locks!"
elif game_type == GAME_SERVER:
flags = round_obj.payload.get("flags") or []
shuffled_flags = flags.copy()
random.shuffle(shuffled_flags)
game_data = {
"flags": shuffled_flags,
}
instructions = "Choose a server by clicking on a flag!"
elif game_type == GAME_CIPHER:
question = round_obj.payload.get("question", "")
game_data = {
"question": question,
"input_type": "text",
}
instructions = "Decrypt the cipher and enter the answer!"
elif game_type == GAME_EMOJI:
question = round_obj.payload.get("question", "🤔")
emoji_list = question.split()
random.shuffle(emoji_list)
game_data = {
"question": " ".join(emoji_list),
"input_type": "text",
}
instructions = "Guess the service by emojis!"
elif game_type == GAME_ANAGRAM:
letters = round_obj.payload.get("letters", "")
game_data = {
"letters": letters,
"input_type": "text",
}
instructions = "Make a word from the given letters!"
elif game_type == GAME_BLITZ:
game_data = {
"button_text": "I'm here!",
}
instructions = "Click the button as fast as you can!"
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unknown contest type",
)
return ContestGameData(
round_id=round_id,
game_type=game_type,
game_data=game_data,
instructions=instructions,
)
@router.post("/{round_id}/answer", response_model=ContestResult)
async def submit_contest_answer(
round_id: int,
request: ContestAnswerRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Submit answer for a contest round."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Contests are only available for users with active or trial subscriptions",
)
active_rounds = await get_active_rounds(db)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Contest round not found or already finished",
)
# Check if already played
attempt = await get_attempt(db, round_id, user.id)
if attempt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You have already played this round",
)
tpl = round_obj.template
answer = request.answer
is_winner = False
# Determine if winner based on game type
if tpl.slug == GAME_SERVER:
flags = round_obj.payload.get("flags") or []
secret_idx = round_obj.payload.get("secret_idx")
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
is_winner = answer == correct_flag
elif tpl.slug in {GAME_QUEST, GAME_LOCKS}:
try:
parts = answer.split("_")
if len(parts) >= 2:
idx = int(parts[0])
secret = int(parts[1])
is_winner = idx == secret
except (ValueError, IndexError):
is_winner = False
elif tpl.slug == GAME_BLITZ:
is_winner = answer.lower() == "blitz"
elif tpl.slug in {GAME_CIPHER, GAME_EMOJI, GAME_ANAGRAM}:
correct = (round_obj.payload.get("answer") or "").upper()
is_winner = correct and answer.upper() == correct
# Record attempt
await create_attempt(
db,
round_id=round_obj.id,
user_id=user.id,
answer=str(answer),
is_winner=is_winner
)
if is_winner:
await increment_winner_count(db, round_obj)
prize_text = await _award_prize(db, user.id, tpl.prize_days)
return ContestResult(
is_winner=True,
message=f"🎉 Congratulations! You won! {prize_text}",
prize_days=tpl.prize_days,
)
else:
lose_messages = {
GAME_QUEST: ["Empty node", "Wrong server", "Try another"],
GAME_LOCKS: ["Locked", "No access", "Try again"],
GAME_SERVER: ["Server overloaded", "No response", "Try tomorrow"],
}
messages = lose_messages.get(tpl.slug, ["Incorrect", "Try again next round"])
return ContestResult(
is_winner=False,
message=random.choice(messages),
)
+237
View File
@@ -0,0 +1,237 @@
"""Info pages routes for cabinet - FAQ, rules, privacy policy, etc."""
import logging
from typing import List, Optional, Dict, Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.config import settings
from app.services.faq_service import FaqService
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
from app.database.crud.rules import get_rules_by_language, get_current_rules_content
from ..dependencies import get_cabinet_db, get_current_cabinet_user, get_optional_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/info", tags=["Cabinet Info"])
# ============ Schemas ============
class FaqPageResponse(BaseModel):
"""FAQ page."""
id: int
title: str
content: str
order: int
class RulesResponse(BaseModel):
"""Service rules."""
content: str
updated_at: Optional[str] = None
class PrivacyPolicyResponse(BaseModel):
"""Privacy policy."""
content: str
updated_at: Optional[str] = None
class PublicOfferResponse(BaseModel):
"""Public offer."""
content: str
updated_at: Optional[str] = None
class ServiceInfoResponse(BaseModel):
"""General service info."""
name: str
description: Optional[str] = None
support_email: Optional[str] = None
support_telegram: Optional[str] = None
website: Optional[str] = None
# ============ Routes ============
@router.get("/faq", response_model=List[FaqPageResponse])
async def get_faq_pages(
language: str = Query("ru", min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of FAQ pages."""
requested_lang = FaqService.normalize_language(language)
pages = await FaqService.get_pages(
db,
requested_lang,
include_inactive=False, # Only active pages for cabinet
fallback=True,
)
return [
FaqPageResponse(
id=page.id,
title=page.title,
content=page.content or "",
order=page.display_order or 0,
)
for page in pages
]
@router.get("/faq/{page_id}", response_model=FaqPageResponse)
async def get_faq_page(
page_id: int,
language: str = Query("ru", min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get a specific FAQ page by ID."""
requested_lang = FaqService.normalize_language(language)
page = await FaqService.get_page(
db,
page_id,
requested_lang,
include_inactive=False,
fallback=True,
)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="FAQ page not found",
)
return FaqPageResponse(
id=page.id,
title=page.title,
content=page.content or "",
order=page.display_order or 0,
)
@router.get("/rules", response_model=RulesResponse)
async def get_rules(
language: str = Query("ru", min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get service rules - uses same function as bot."""
requested_lang = language.split("-")[0].lower()
# Use the same function as bot to ensure consistent content
content = await get_current_rules_content(db, requested_lang)
# Try to get updated_at from DB record
rules = await get_rules_by_language(db, requested_lang)
updated_at = None
if rules and rules.updated_at:
updated_at = rules.updated_at.isoformat()
return RulesResponse(content=content, updated_at=updated_at)
@router.get("/privacy-policy", response_model=PrivacyPolicyResponse)
async def get_privacy_policy(
language: str = Query("ru", min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get privacy policy."""
requested_lang = PrivacyPolicyService.normalize_language(language)
policy = await PrivacyPolicyService.get_policy(db, requested_lang, fallback=True)
if policy and policy.content:
updated_at = policy.updated_at.isoformat() if policy.updated_at else None
return PrivacyPolicyResponse(content=policy.content, updated_at=updated_at)
# Return default policy if none found
return PrivacyPolicyResponse(
content="""# Политика конфиденциальности
Мы уважаем вашу конфиденциальность и защищаем ваши персональные данные.
""",
updated_at=None,
)
@router.get("/public-offer", response_model=PublicOfferResponse)
async def get_public_offer(
language: str = Query("ru", min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get public offer."""
requested_lang = PublicOfferService.normalize_language(language)
offer = await PublicOfferService.get_offer(db, requested_lang, fallback=True)
if offer and offer.content:
updated_at = offer.updated_at.isoformat() if offer.updated_at else None
return PublicOfferResponse(content=offer.content, updated_at=updated_at)
# Return default offer if none found
return PublicOfferResponse(
content="""# Публичная оферта
Условия использования сервиса.
""",
updated_at=None,
)
@router.get("/service", response_model=ServiceInfoResponse)
async def get_service_info():
"""Get general service information."""
return ServiceInfoResponse(
name=getattr(settings, 'SERVICE_NAME', None) or getattr(settings, 'BOT_NAME', 'VPN Service'),
description=getattr(settings, 'SERVICE_DESCRIPTION', None),
support_email=getattr(settings, 'SUPPORT_EMAIL', None),
support_telegram=getattr(settings, 'SUPPORT_USERNAME', None) or getattr(settings, 'SUPPORT_TELEGRAM', None),
website=getattr(settings, 'WEBSITE_URL', None),
)
@router.get("/languages")
async def get_available_languages():
"""Get list of available languages."""
return {
"languages": [
{"code": "ru", "name": "Русский", "flag": "🇷🇺"},
{"code": "en", "name": "English", "flag": "🇬🇧"},
],
"default": getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru',
}
@router.get("/user/language")
async def get_user_language(
user: User = Depends(get_current_cabinet_user),
):
"""Get current user's language."""
return {"language": user.language or "ru"}
@router.patch("/user/language")
async def update_user_language(
request: Dict[str, str],
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user's language preference."""
language = request.get("language", "ru")
valid_languages = ["ru", "en"]
if language not in valid_languages:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid language. Supported: {', '.join(valid_languages)}",
)
user.language = language
await db.commit()
await db.refresh(user)
return {"language": user.language}
+145
View File
@@ -0,0 +1,145 @@
"""Notification settings routes for cabinet."""
import logging
from datetime import datetime
from typing import Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/notifications", tags=["Cabinet Notifications"])
# ============ Schemas ============
class NotificationSettingsResponse(BaseModel):
"""User notification settings."""
subscription_expiry_enabled: bool = True
subscription_expiry_days: int = 3
traffic_warning_enabled: bool = True
traffic_warning_percent: int = 80
balance_low_enabled: bool = True
balance_low_threshold: int = 100 # kopeks
news_enabled: bool = True
promo_offers_enabled: bool = True
class NotificationSettingsUpdate(BaseModel):
"""Update notification settings."""
subscription_expiry_enabled: Optional[bool] = None
subscription_expiry_days: Optional[int] = Field(None, ge=1, le=30)
traffic_warning_enabled: Optional[bool] = None
traffic_warning_percent: Optional[int] = Field(None, ge=50, le=99)
balance_low_enabled: Optional[bool] = None
balance_low_threshold: Optional[int] = Field(None, ge=0)
news_enabled: Optional[bool] = None
promo_offers_enabled: Optional[bool] = None
# ============ Helpers ============
def _get_notification_settings(user: User) -> Dict[str, Any]:
"""Get notification settings from user object."""
# Try to get from user's settings field or use defaults
settings_data = getattr(user, 'notification_settings', None) or {}
return {
"subscription_expiry_enabled": settings_data.get("subscription_expiry_enabled", True),
"subscription_expiry_days": settings_data.get("subscription_expiry_days", 3),
"traffic_warning_enabled": settings_data.get("traffic_warning_enabled", True),
"traffic_warning_percent": settings_data.get("traffic_warning_percent", 80),
"balance_low_enabled": settings_data.get("balance_low_enabled", True),
"balance_low_threshold": settings_data.get("balance_low_threshold", 100),
"news_enabled": settings_data.get("news_enabled", True),
"promo_offers_enabled": settings_data.get("promo_offers_enabled", True),
}
def _update_notification_settings(user: User, updates: Dict[str, Any]) -> Dict[str, Any]:
"""Update notification settings on user object."""
current_settings = _get_notification_settings(user)
for key, value in updates.items():
if value is not None:
current_settings[key] = value
return current_settings
# ============ Routes ============
@router.get("", response_model=NotificationSettingsResponse)
async def get_notification_settings(
user: User = Depends(get_current_cabinet_user),
):
"""Get user's notification settings."""
settings = _get_notification_settings(user)
return NotificationSettingsResponse(**settings)
@router.patch("", response_model=NotificationSettingsResponse)
async def update_notification_settings(
request: NotificationSettingsUpdate,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user's notification settings."""
updates = request.model_dump(exclude_unset=True)
if not updates:
# No updates provided, return current settings
settings = _get_notification_settings(user)
return NotificationSettingsResponse(**settings)
# Update settings
new_settings = _update_notification_settings(user, updates)
# Store in user object
if not hasattr(user, 'notification_settings') or user.notification_settings is None:
user.notification_settings = {}
user.notification_settings = new_settings
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
return NotificationSettingsResponse(**new_settings)
@router.post("/test")
async def send_test_notification(
user: User = Depends(get_current_cabinet_user),
):
"""Send a test notification to the user."""
# This would typically trigger a notification via Telegram bot
# For now, just return success
return {
"success": True,
"message": "Test notification request received. You will receive a test message shortly.",
}
@router.get("/history")
async def get_notification_history(
limit: int = 20,
offset: int = 0,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's notification history."""
# For now, return empty list - notification history can be implemented later
# when there's a notification log table
return {
"notifications": [],
"total": 0,
"limit": limit,
"offset": offset,
}
+353
View File
@@ -0,0 +1,353 @@
"""Polls routes for cabinet - user participation in polls/surveys."""
import logging
from datetime import datetime
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database.models import User, Poll, PollResponse, PollQuestion
from app.database.crud.poll import (
get_poll_response_by_id,
record_poll_answer,
)
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
from app.config import settings
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/polls", tags=["Cabinet Polls"])
# ============ Schemas ============
class PollOptionResponse(BaseModel):
"""Poll option."""
id: int
text: str
order: int
class PollQuestionResponse(BaseModel):
"""Poll question with options."""
id: int
text: str
order: int
options: List[PollOptionResponse]
class PollInfo(BaseModel):
"""Poll info for user."""
id: int
response_id: int
title: str
description: Optional[str] = None
total_questions: int
answered_questions: int
is_completed: bool
reward_amount: Optional[int] = None
class PollStartResponse(BaseModel):
"""Response when starting a poll."""
response_id: int
current_question_index: int
total_questions: int
question: PollQuestionResponse
class AnswerRequest(BaseModel):
"""Request to answer a poll question."""
option_id: int
class AnswerResponse(BaseModel):
"""Response after answering."""
success: bool
is_completed: bool
next_question: Optional[PollQuestionResponse] = None
current_question_index: Optional[int] = None
total_questions: int
reward_granted: Optional[int] = None
message: Optional[str] = None
# ============ Helpers ============
def _question_to_response(question: PollQuestion) -> PollQuestionResponse:
"""Convert question model to response."""
options = [
PollOptionResponse(
id=opt.id,
text=opt.text,
order=opt.order,
)
for opt in sorted(question.options, key=lambda o: o.order)
]
return PollQuestionResponse(
id=question.id,
text=question.text,
order=question.order,
options=options,
)
# ============ Routes ============
class PollsCountResponse(BaseModel):
"""Count of available polls."""
count: int
@router.get("/count", response_model=PollsCountResponse)
async def get_polls_count(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get count of polls available for the user."""
result = await db.execute(
select(PollResponse)
.where(PollResponse.user_id == user.id)
.where(PollResponse.completed_at.is_(None)) # Only incomplete polls
)
responses = result.scalars().all()
return PollsCountResponse(count=len(responses))
@router.get("", response_model=List[PollInfo])
async def get_available_polls(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of polls available for the user."""
# Get user's poll responses with eager loading of relationships
result = await db.execute(
select(PollResponse)
.where(PollResponse.user_id == user.id)
.options(
selectinload(PollResponse.poll).selectinload(Poll.questions),
selectinload(PollResponse.answers),
)
.order_by(PollResponse.created_at.desc())
)
responses = result.scalars().all()
polls = []
for response in responses:
if not response.poll:
continue
answered_count = len(response.answers) if response.answers else 0
total_questions = len(response.poll.questions) if response.poll.questions else 0
# Convert kopeks to rubles for display
reward_amount = None
if response.poll.reward_amount_kopeks:
reward_amount = response.poll.reward_amount_kopeks // 100
polls.append(PollInfo(
id=response.poll.id,
response_id=response.id,
title=response.poll.title,
description=response.poll.description,
total_questions=total_questions,
answered_questions=answered_count,
is_completed=response.completed_at is not None,
reward_amount=reward_amount,
))
return polls
@router.get("/{response_id}", response_model=PollInfo)
async def get_poll_details(
response_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get details of a specific poll response."""
response = await get_poll_response_by_id(db, response_id)
if not response or response.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Poll not found",
)
if not response.poll:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Poll data not available",
)
answered_count = len(response.answers) if response.answers else 0
total_questions = len(response.poll.questions) if response.poll.questions else 0
# Convert kopeks to rubles for display
reward_amount = None
if response.poll.reward_amount_kopeks:
reward_amount = response.poll.reward_amount_kopeks // 100
return PollInfo(
id=response.poll.id,
response_id=response.id,
title=response.poll.title,
description=response.poll.description,
total_questions=total_questions,
answered_questions=answered_count,
is_completed=response.completed_at is not None,
reward_amount=reward_amount,
)
@router.post("/{response_id}/start", response_model=PollStartResponse)
async def start_poll(
response_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Start or continue a poll."""
response = await get_poll_response_by_id(db, response_id)
if not response or response.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Poll not found",
)
if response.completed_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This poll has already been completed",
)
if not response.poll or not response.poll.questions:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Poll is not available",
)
# Mark as started if not already
if not response.started_at:
response.started_at = datetime.utcnow()
await db.commit()
# Get next unanswered question
index, question = await get_next_question(response)
if not question:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No questions available",
)
return PollStartResponse(
response_id=response.id,
current_question_index=index,
total_questions=len(response.poll.questions),
question=_question_to_response(question),
)
@router.post("/{response_id}/questions/{question_id}/answer", response_model=AnswerResponse)
async def answer_question(
response_id: int,
question_id: int,
request: AnswerRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Submit answer for a poll question."""
response = await get_poll_response_by_id(db, response_id)
if not response or response.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Poll not found",
)
if response.completed_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This poll has already been completed",
)
if not response.poll:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Poll is not available",
)
# Find the question
question = next((q for q in response.poll.questions if q.id == question_id), None)
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found",
)
# Validate option
option = await get_question_option(question, request.option_id)
if not option:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid answer option",
)
# Record the answer
await record_poll_answer(
db,
response_id=response.id,
question_id=question.id,
option_id=option.id,
)
# Refresh to get updated answers
try:
await db.refresh(response, attribute_names=["answers"])
except Exception:
response = await get_poll_response_by_id(db, response_id)
if not response:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to process answer",
)
# Get next question
index, next_question = await get_next_question(response)
total_questions = len(response.poll.questions)
if next_question:
# More questions to answer
return AnswerResponse(
success=True,
is_completed=False,
next_question=_question_to_response(next_question),
current_question_index=index,
total_questions=total_questions,
)
# Poll completed
response.completed_at = datetime.utcnow()
await db.commit()
# Award reward if any
reward_amount = await reward_user_for_poll(db, response)
message = "Thank you for completing the poll!"
if reward_amount:
message += f" Reward of {settings.format_price(reward_amount)} has been added to your balance."
return AnswerResponse(
success=True,
is_completed=True,
total_questions=total_questions,
reward_granted=reward_amount,
message=message,
)
+306
View File
@@ -0,0 +1,306 @@
"""Promo offers routes for cabinet - personal discounts and offers."""
import logging
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from app.database.models import User, DiscountOffer
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.services.promo_offer_service import promo_offer_service
from app.config import settings
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/promo", tags=["Cabinet Promo"])
# ============ Schemas ============
class PromoOfferInfo(BaseModel):
"""Promo offer info."""
id: int
notification_type: str
discount_percent: Optional[int] = None
effect_type: str
expires_at: datetime
is_active: bool
is_claimed: bool
claimed_at: Optional[datetime] = None
extra_data: Optional[Dict[str, Any]] = None
class ActiveDiscountInfo(BaseModel):
"""User's active discount info."""
discount_percent: int
source: Optional[str] = None
expires_at: Optional[datetime] = None
is_active: bool
class ClaimOfferRequest(BaseModel):
"""Request to claim an offer."""
offer_id: int
class ClaimOfferResponse(BaseModel):
"""Response after claiming offer."""
success: bool
message: str
discount_percent: Optional[int] = None
expires_at: Optional[datetime] = None
class PromoGroupDiscounts(BaseModel):
"""User's promo group discounts."""
group_name: Optional[str] = None
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: Dict[str, int] = {}
# ============ Routes ============
@router.get("/offers", response_model=List[PromoOfferInfo])
async def get_promo_offers(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available promo offers for the user."""
now = datetime.utcnow()
result = await db.execute(
select(DiscountOffer)
.where(
and_(
DiscountOffer.user_id == user.id,
DiscountOffer.expires_at > now,
)
)
.order_by(DiscountOffer.created_at.desc())
)
offers = result.scalars().all()
return [
PromoOfferInfo(
id=offer.id,
notification_type=offer.notification_type or "",
discount_percent=offer.discount_percent,
effect_type=offer.effect_type or "percent_discount",
expires_at=offer.expires_at,
is_active=offer.is_active and offer.claimed_at is None,
is_claimed=offer.claimed_at is not None,
claimed_at=offer.claimed_at,
extra_data=offer.extra_data,
)
for offer in offers
]
@router.get("/active-discount", response_model=ActiveDiscountInfo)
async def get_active_discount(
user: User = Depends(get_current_cabinet_user),
):
"""Get user's currently active discount."""
discount_percent = user.promo_offer_discount_percent or 0
expires_at = user.promo_offer_discount_expires_at
source = user.promo_offer_discount_source
now = datetime.utcnow()
is_active = discount_percent > 0 and (expires_at is None or expires_at > now)
return ActiveDiscountInfo(
discount_percent=discount_percent if is_active else 0,
source=source,
expires_at=expires_at,
is_active=is_active,
)
@router.get("/group-discounts", response_model=PromoGroupDiscounts)
async def get_promo_group_discounts(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's promo group discounts."""
await db.refresh(user, ["promo_groups"])
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if not promo_group:
return PromoGroupDiscounts()
# Get period discounts
period_discounts = {}
raw_period_discounts = getattr(promo_group, "period_discounts", None)
if isinstance(raw_period_discounts, dict):
for key, value in raw_period_discounts.items():
try:
period_discounts[str(key)] = int(value)
except (TypeError, ValueError):
continue
return PromoGroupDiscounts(
group_name=promo_group.name,
server_discount_percent=promo_group.server_discount_percent or 0,
traffic_discount_percent=promo_group.traffic_discount_percent or 0,
device_discount_percent=promo_group.device_discount_percent or 0,
period_discounts=period_discounts,
)
@router.post("/claim", response_model=ClaimOfferResponse)
async def claim_promo_offer(
request: ClaimOfferRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Claim a promo offer."""
offer = await get_offer_by_id(db, request.offer_id)
if not offer or offer.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Offer not found",
)
now = datetime.utcnow()
if offer.claimed_at is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This offer has already been claimed",
)
if not offer.is_active or offer.expires_at <= now:
offer.is_active = False
await db.commit()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This offer has expired",
)
effect_type = (offer.effect_type or "percent_discount").lower()
# Handle test access offers
if effect_type == "test_access":
await db.refresh(user, ["subscription"])
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
db,
user,
offer,
)
if not success:
error_messages = {
"subscription_missing": "Active subscription required for this offer",
"squads_missing": "Could not determine servers for test access",
"already_connected": "These servers are already connected",
"remnawave_sync_failed": "Failed to connect servers. Please try again later",
}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_messages.get(error_code, "Failed to activate offer"),
)
await mark_offer_claimed(
db,
offer,
details={
"context": "test_access_claim",
"new_squads": newly_added,
"expires_at": expires_at.isoformat() if expires_at else None,
},
)
return ClaimOfferResponse(
success=True,
message=f"Test access activated until {expires_at.strftime('%Y-%m-%d %H:%M') if expires_at else 'unlimited'}",
expires_at=expires_at,
)
# Handle discount offers
discount_percent = int(offer.discount_percent or 0)
if discount_percent <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid offer",
)
user.promo_offer_discount_percent = discount_percent
user.promo_offer_discount_source = offer.notification_type
user.updated_at = now
# Calculate expiration
extra_data = offer.extra_data or {}
raw_duration = extra_data.get("active_discount_hours")
template_id = extra_data.get("template_id")
if raw_duration in (None, "") and template_id:
try:
template = await get_promo_offer_template_by_id(db, int(template_id))
except (ValueError, TypeError):
template = None
if template and template.active_discount_hours:
raw_duration = template.active_discount_hours
try:
duration_hours = int(raw_duration) if raw_duration is not None else None
except (TypeError, ValueError):
duration_hours = None
if duration_hours and duration_hours > 0:
discount_expires_at = now + timedelta(hours=duration_hours)
else:
discount_expires_at = None
user.promo_offer_discount_expires_at = discount_expires_at
await mark_offer_claimed(
db,
offer,
details={
"context": "discount_claim",
"discount_percent": discount_percent,
"discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None,
},
)
await db.refresh(user)
expires_text = ""
if discount_expires_at:
expires_text = f" Valid until {discount_expires_at.strftime('%Y-%m-%d %H:%M')}"
return ClaimOfferResponse(
success=True,
message=f"Discount of {discount_percent}% activated!{expires_text}",
discount_percent=discount_percent,
expires_at=discount_expires_at,
)
@router.delete("/active-discount")
async def clear_active_discount(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Clear user's active discount."""
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
await db.commit()
return {"message": "Active discount cleared"}
+77
View File
@@ -0,0 +1,77 @@
"""Promo code routes for cabinet."""
import logging
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.promocode_service import PromoCodeService
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/promocode", tags=["Cabinet Promocode"])
class PromocodeActivateRequest(BaseModel):
"""Request to activate a promo code."""
code: str = Field(..., min_length=1, max_length=50, description="Promo code to activate")
class PromocodeActivateResponse(BaseModel):
"""Response after activating a promo code."""
success: bool
message: str
balance_before: float = 0
balance_after: float = 0
bonus_description: str | None = None
@router.post("/activate", response_model=PromocodeActivateResponse)
async def activate_promocode(
request: PromocodeActivateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Activate a promo code for the current user."""
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(
db=db,
user_id=user.id,
code=request.code.strip()
)
if result["success"]:
balance_before_rubles = result.get("balance_before_kopeks", 0) / 100
balance_after_rubles = result.get("balance_after_kopeks", 0) / 100
return PromocodeActivateResponse(
success=True,
message="Promo code activated successfully",
balance_before=balance_before_rubles,
balance_after=balance_after_rubles,
bonus_description=result.get("description"),
)
# Map error codes to messages
error_messages = {
"not_found": "Promo code not found",
"expired": "Promo code has expired",
"used": "Promo code has been fully used",
"already_used_by_user": "You have already used this promo code",
"user_not_found": "User not found",
"server_error": "Server error occurred",
}
error_code = result.get("error", "server_error")
error_message = error_messages.get(error_code, "Failed to activate promo code")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
)
+196
View File
@@ -0,0 +1,196 @@
"""Referral program routes for cabinet."""
import logging
import math
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from app.database.models import User, ReferralEarning
from app.config import settings
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.referral import (
ReferralInfoResponse,
ReferralItemResponse,
ReferralListResponse,
ReferralEarningResponse,
ReferralEarningsListResponse,
ReferralTermsResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/referral", tags=["Cabinet Referral"])
@router.get("", response_model=ReferralInfoResponse)
async def get_referral_info(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get referral program info for current user."""
# Get total referrals count
total_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id)
total_result = await db.execute(total_query)
total_referrals = total_result.scalar() or 0
# Get active referrals (with subscription)
active_query = (
select(func.count())
.select_from(User)
.where(User.referred_by_id == user.id)
.where(User.has_had_paid_subscription == True)
)
active_result = await db.execute(active_query)
active_referrals = active_result.scalar() or 0
# Get total earnings
earnings_query = (
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.user_id == user.id)
)
earnings_result = await db.execute(earnings_query)
total_earnings = earnings_result.scalar() or 0
# Get user's commission percent
commission_percent = user.referral_commission_percent
if commission_percent is None:
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
# Build referral link
bot_username = settings.get_bot_username() or "bot"
referral_link = f"https://t.me/{bot_username}?start={user.referral_code}"
return ReferralInfoResponse(
referral_code=user.referral_code or "",
referral_link=referral_link,
total_referrals=total_referrals,
active_referrals=active_referrals,
total_earnings_kopeks=total_earnings,
total_earnings_rubles=total_earnings / 100,
commission_percent=commission_percent,
)
@router.get("/list", response_model=ReferralListResponse)
async def get_referral_list(
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of invited users."""
# Base query
query = select(User).where(User.referred_by_id == user.id)
# Get total count
count_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate
offset = (page - 1) * per_page
query = query.order_by(desc(User.created_at)).offset(offset).limit(per_page)
result = await db.execute(query)
referrals = result.scalars().all()
items = [
ReferralItemResponse(
id=r.id,
username=r.username,
first_name=r.first_name,
created_at=r.created_at,
has_subscription=r.subscription is not None,
has_paid=r.has_had_paid_subscription,
)
for r in referrals
]
pages = math.ceil(total / per_page) if total > 0 else 1
return ReferralListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get("/earnings", response_model=ReferralEarningsListResponse)
async def get_referral_earnings(
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get referral earnings history."""
# Base query
query = select(ReferralEarning).where(ReferralEarning.user_id == user.id)
# Get total count and sum
count_query = select(func.count()).select_from(ReferralEarning).where(ReferralEarning.user_id == user.id)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
sum_query = (
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.user_id == user.id)
)
sum_result = await db.execute(sum_query)
total_amount = sum_result.scalar() or 0
# Paginate
offset = (page - 1) * per_page
query = query.order_by(desc(ReferralEarning.created_at)).offset(offset).limit(per_page)
result = await db.execute(query)
earnings = result.scalars().all()
items = []
for e in earnings:
# Get referral user info
referral_query = select(User).where(User.id == e.referral_id)
referral_result = await db.execute(referral_query)
referral_user = referral_result.scalar_one_or_none()
items.append(ReferralEarningResponse(
id=e.id,
amount_kopeks=e.amount_kopeks,
amount_rubles=e.amount_kopeks / 100,
reason=e.reason or "Referral commission",
referral_username=referral_user.username if referral_user else None,
referral_first_name=referral_user.first_name if referral_user else None,
created_at=e.created_at,
))
pages = math.ceil(total / per_page) if total > 0 else 1
return ReferralEarningsListResponse(
items=items,
total=total,
total_amount_kopeks=total_amount,
total_amount_rubles=total_amount / 100,
page=page,
per_page=per_page,
pages=pages,
)
@router.get("/terms", response_model=ReferralTermsResponse)
async def get_referral_terms():
"""Get referral program terms."""
return ReferralTermsResponse(
is_enabled=settings.is_referral_program_enabled(),
commission_percent=settings.REFERRAL_COMMISSION_PERCENT,
minimum_topup_kopeks=settings.REFERRAL_MINIMUM_TOPUP_KOPEKS,
minimum_topup_rubles=settings.REFERRAL_MINIMUM_TOPUP_KOPEKS / 100,
first_topup_bonus_kopeks=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
)
File diff suppressed because it is too large Load Diff
+264
View File
@@ -0,0 +1,264 @@
"""Support tickets routes for cabinet."""
import logging
import math
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from sqlalchemy.orm import selectinload
from app.database.models import User, Ticket, TicketMessage
from app.config import settings
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.tickets import (
TicketResponse,
TicketDetailResponse,
TicketListResponse,
TicketMessageResponse,
TicketCreateRequest,
TicketMessageCreateRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tickets", tags=["Cabinet Tickets"])
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
"""Convert TicketMessage to response."""
return TicketMessageResponse(
id=message.id,
message_text=message.message_text or "",
is_from_admin=message.is_from_admin,
has_media=bool(message.media_file_id),
media_type=message.media_type,
media_caption=message.media_caption,
created_at=message.created_at,
)
def _ticket_to_response(ticket: Ticket, include_last_message: bool = True) -> TicketResponse:
"""Convert Ticket to response."""
last_message = None
messages_count = len(ticket.messages) if ticket.messages else 0
if include_last_message and ticket.messages:
last_msg = max(ticket.messages, key=lambda m: m.created_at)
last_message = _message_to_response(last_msg)
return TicketResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
status=ticket.status,
priority=ticket.priority or "normal",
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
messages_count=messages_count,
last_message=last_message,
)
@router.get("", response_model=TicketListResponse)
async def get_tickets(
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's support tickets."""
# Check if tickets are enabled
if not settings.is_support_tickets_enabled():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Support tickets are disabled",
)
# Base query
query = (
select(Ticket)
.where(Ticket.user_id == user.id)
.options(selectinload(Ticket.messages))
)
# Filter by status
if status_filter:
query = query.where(Ticket.status == status_filter)
# Get total count
count_query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user.id)
if status_filter:
count_query = count_query.where(Ticket.status == status_filter)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate
offset = (page - 1) * per_page
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(per_page)
result = await db.execute(query)
tickets = result.scalars().all()
items = [_ticket_to_response(t) for t in tickets]
pages = math.ceil(total / per_page) if total > 0 else 1
return TicketListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.post("", response_model=TicketDetailResponse)
async def create_ticket(
request: TicketCreateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new support ticket."""
# Check if tickets are enabled
if not settings.is_support_tickets_enabled():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Support tickets are disabled",
)
# Create ticket
ticket = Ticket(
user_id=user.id,
title=request.title,
status="open",
priority="normal",
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(ticket)
await db.flush()
# Create initial message
message = TicketMessage(
ticket_id=ticket.id,
user_id=user.id,
message_text=request.message,
is_from_admin=False,
created_at=datetime.utcnow(),
)
db.add(message)
await db.commit()
# Refresh to get relationships
await db.refresh(ticket, ["messages"])
messages = [_message_to_response(m) for m in ticket.messages]
return TicketDetailResponse(
id=ticket.id,
title=ticket.title,
status=ticket.status,
priority=ticket.priority or "normal",
created_at=ticket.created_at,
updated_at=ticket.updated_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False,
messages=messages,
)
@router.get("/{ticket_id}", response_model=TicketDetailResponse)
async def get_ticket(
ticket_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket with all messages."""
query = (
select(Ticket)
.where(Ticket.id == ticket_id, Ticket.user_id == user.id)
.options(selectinload(Ticket.messages))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
return TicketDetailResponse(
id=ticket.id,
title=ticket.title or f"Ticket #{ticket.id}",
status=ticket.status,
priority=ticket.priority or "normal",
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False,
messages=messages_response,
)
@router.post("/{ticket_id}/messages", response_model=TicketMessageResponse)
async def add_ticket_message(
ticket_id: int,
request: TicketMessageCreateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Add message to existing ticket."""
# Get ticket
query = select(Ticket).where(Ticket.id == ticket_id, Ticket.user_id == user.id)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ticket not found",
)
# Check if ticket is closed
if ticket.status == "closed":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot add message to closed ticket",
)
# Check if replies are blocked
if hasattr(ticket, "is_reply_blocked") and ticket.is_reply_blocked:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Replies to this ticket are blocked",
)
# Create message
message = TicketMessage(
ticket_id=ticket.id,
user_id=user.id,
message_text=request.message,
is_from_admin=False,
created_at=datetime.utcnow(),
)
db.add(message)
# Update ticket status and timestamp
if ticket.status == "answered":
ticket.status = "pending"
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
return _message_to_response(message)
+86
View File
@@ -0,0 +1,86 @@
"""Cabinet Pydantic schemas."""
from .auth import (
TelegramAuthRequest,
TelegramWidgetAuthRequest,
EmailRegisterRequest,
EmailVerifyRequest,
EmailLoginRequest,
RefreshTokenRequest,
PasswordForgotRequest,
PasswordResetRequest,
TokenResponse,
UserResponse,
AuthResponse,
)
from .subscription import (
SubscriptionResponse,
RenewalOptionResponse,
RenewalRequest,
TrafficPackageResponse,
TrafficPurchaseRequest,
DevicePurchaseRequest,
AutopayUpdateRequest,
)
from .balance import (
BalanceResponse,
TransactionResponse,
TransactionListResponse,
PaymentMethodResponse,
TopUpRequest,
TopUpResponse,
)
from .referral import (
ReferralInfoResponse,
ReferralListResponse,
ReferralEarningResponse,
ReferralTermsResponse,
)
from .tickets import (
TicketResponse,
TicketListResponse,
TicketMessageResponse,
TicketCreateRequest,
TicketMessageCreateRequest,
)
__all__ = [
# Auth
"TelegramAuthRequest",
"TelegramWidgetAuthRequest",
"EmailRegisterRequest",
"EmailVerifyRequest",
"EmailLoginRequest",
"RefreshTokenRequest",
"PasswordForgotRequest",
"PasswordResetRequest",
"TokenResponse",
"UserResponse",
"AuthResponse",
# Subscription
"SubscriptionResponse",
"RenewalOptionResponse",
"RenewalRequest",
"TrafficPackageResponse",
"TrafficPurchaseRequest",
"DevicePurchaseRequest",
"AutopayUpdateRequest",
# Balance
"BalanceResponse",
"TransactionResponse",
"TransactionListResponse",
"PaymentMethodResponse",
"TopUpRequest",
"TopUpResponse",
# Referral
"ReferralInfoResponse",
"ReferralListResponse",
"ReferralEarningResponse",
"ReferralTermsResponse",
# Tickets
"TicketResponse",
"TicketListResponse",
"TicketMessageResponse",
"TicketCreateRequest",
"TicketMessageCreateRequest",
]
+90
View File
@@ -0,0 +1,90 @@
"""Authentication schemas for cabinet."""
from datetime import datetime
from typing import Optional, Dict, Any
from pydantic import BaseModel, EmailStr, Field
class TelegramAuthRequest(BaseModel):
"""Request for Telegram WebApp initData authentication."""
init_data: str = Field(..., description="Telegram WebApp initData string")
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: Optional[str] = Field(None, description="User's last name")
username: Optional[str] = Field(None, description="User's username")
photo_url: Optional[str] = Field(None, description="User's photo URL")
auth_date: int = Field(..., description="Unix timestamp of authentication")
hash: str = Field(..., description="Authentication hash")
class EmailRegisterRequest(BaseModel):
"""Request to register/link email to existing Telegram account."""
email: EmailStr = Field(..., description="Email address")
password: str = Field(..., min_length=8, max_length=128, description="Password (min 8 chars)")
class EmailVerifyRequest(BaseModel):
"""Request to verify email with token."""
token: str = Field(..., description="Email verification token")
class EmailLoginRequest(BaseModel):
"""Request to login with email and password."""
email: EmailStr = Field(..., description="Email address")
password: str = Field(..., description="Password")
class RefreshTokenRequest(BaseModel):
"""Request to refresh access token."""
refresh_token: str = Field(..., description="Refresh token")
class PasswordForgotRequest(BaseModel):
"""Request to initiate password reset."""
email: EmailStr = Field(..., description="Email address")
class PasswordResetRequest(BaseModel):
"""Request to reset password with token."""
token: str = Field(..., description="Password reset token")
password: str = Field(..., min_length=8, max_length=128, description="New password (min 8 chars)")
class TokenResponse(BaseModel):
"""Token pair response."""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int = Field(..., description="Access token expiration in seconds")
class UserResponse(BaseModel):
"""User data response."""
id: int
telegram_id: int
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
email: Optional[str] = None
email_verified: bool = False
balance_kopeks: int = 0
balance_rubles: float = 0.0
referral_code: Optional[str] = None
language: str = "ru"
created_at: datetime
class Config:
from_attributes = True
class AuthResponse(BaseModel):
"""Full authentication response with tokens and user."""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
user: UserResponse
+63
View File
@@ -0,0 +1,63 @@
"""Balance and payment schemas for cabinet."""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field
class BalanceResponse(BaseModel):
"""User balance data."""
balance_kopeks: int
balance_rubles: float
class TransactionResponse(BaseModel):
"""Transaction history item."""
id: int
type: str
amount_kopeks: int
amount_rubles: float
description: Optional[str] = None
payment_method: Optional[str] = None
is_completed: bool
created_at: datetime
completed_at: Optional[datetime] = None
class Config:
from_attributes = True
class TransactionListResponse(BaseModel):
"""Paginated transaction list."""
items: List[TransactionResponse]
total: int
page: int
per_page: int
pages: int
class PaymentMethodResponse(BaseModel):
"""Available payment method."""
id: str
name: str
description: Optional[str] = None
min_amount_kopeks: int
max_amount_kopeks: int
is_available: bool = True
class TopUpRequest(BaseModel):
"""Request to create payment for balance top-up."""
amount_kopeks: int = Field(..., ge=1000, description="Amount in kopeks (min 10 rubles)")
payment_method: str = Field(..., description="Payment method ID")
payment_option: Optional[str] = Field(None, description="Payment option (e.g. Platega method code)")
class TopUpResponse(BaseModel):
"""Response with payment info."""
payment_id: str
payment_url: str
amount_kopeks: int
amount_rubles: float
status: str
expires_at: Optional[datetime] = None
+72
View File
@@ -0,0 +1,72 @@
"""Referral program schemas for cabinet."""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel
class ReferralInfoResponse(BaseModel):
"""Referral program info for current user."""
referral_code: str
referral_link: str
total_referrals: int
active_referrals: int
total_earnings_kopeks: int
total_earnings_rubles: float
commission_percent: int
class ReferralItemResponse(BaseModel):
"""Single referral info."""
id: int
username: Optional[str] = None
first_name: Optional[str] = None
created_at: datetime
has_subscription: bool
has_paid: bool
class ReferralListResponse(BaseModel):
"""Paginated referral list."""
items: List[ReferralItemResponse]
total: int
page: int
per_page: int
pages: int
class ReferralEarningResponse(BaseModel):
"""Referral earning history item."""
id: int
amount_kopeks: int
amount_rubles: float
reason: str
referral_username: Optional[str] = None
referral_first_name: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
class ReferralEarningsListResponse(BaseModel):
"""Paginated referral earnings list."""
items: List[ReferralEarningResponse]
total: int
total_amount_kopeks: int
total_amount_rubles: float
page: int
per_page: int
pages: int
class ReferralTermsResponse(BaseModel):
"""Referral program terms."""
is_enabled: bool
commission_percent: int
minimum_topup_kopeks: int
minimum_topup_rubles: float
first_topup_bonus_kopeks: int
first_topup_bonus_rubles: float
inviter_bonus_kopeks: int
inviter_bonus_rubles: float
+105
View File
@@ -0,0 +1,105 @@
"""Subscription schemas for cabinet."""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field
class ServerInfo(BaseModel):
"""Server info for display."""
uuid: str
name: str
country_code: Optional[str] = None
class SubscriptionResponse(BaseModel):
"""User subscription data."""
id: int
status: str
is_trial: bool
start_date: datetime
end_date: datetime
days_left: int
hours_left: int = 0
minutes_left: int = 0
time_left_display: str = "" # Human readable format like "2д 5ч" or "5ч 30м"
traffic_limit_gb: int
traffic_used_gb: float
traffic_used_percent: float
device_limit: int
connected_squads: List[str] = []
servers: List[ServerInfo] = [] # Server display info
autopay_enabled: bool
autopay_days_before: int
subscription_url: Optional[str] = None
is_active: bool
is_expired: bool
class Config:
from_attributes = True
class RenewalOptionResponse(BaseModel):
"""Available subscription renewal option."""
period_days: int
price_kopeks: int
price_rubles: float
discount_percent: int = 0
original_price_kopeks: Optional[int] = None
class RenewalRequest(BaseModel):
"""Request to renew subscription."""
period_days: int = Field(..., description="Renewal period in days")
class TrafficPackageResponse(BaseModel):
"""Available traffic package."""
gb: int
price_kopeks: int
price_rubles: float
is_unlimited: bool = False
class TrafficPurchaseRequest(BaseModel):
"""Request to purchase additional traffic."""
gb: int = Field(..., ge=0, description="GB to purchase (0 = unlimited)")
class DevicePurchaseRequest(BaseModel):
"""Request to purchase additional device slots."""
devices: int = Field(..., ge=1, description="Number of additional devices")
class AutopayUpdateRequest(BaseModel):
"""Request to update autopay settings."""
enabled: bool
days_before: Optional[int] = Field(None, ge=1, le=30, description="Days before expiration to charge")
class TrialInfoResponse(BaseModel):
"""Trial subscription info."""
is_available: bool
duration_days: int
traffic_limit_gb: int
device_limit: int
requires_payment: bool = False
price_kopeks: int = 0
price_rubles: float = 0.0
reason_unavailable: Optional[str] = None
# ============ Purchase Options Schemas ============
class PurchaseSelectionRequest(BaseModel):
"""User's selection for subscription purchase."""
period_id: Optional[str] = Field(None, description="Period ID like 'days:30'")
period_days: Optional[int] = Field(None, description="Period in days")
traffic_value: Optional[int] = Field(None, description="Traffic in GB (0 = unlimited)")
servers: Optional[List[str]] = Field(default_factory=list, description="Server UUIDs")
devices: Optional[int] = Field(None, description="Device limit")
class PurchasePreviewRequest(BaseModel):
"""Request to preview purchase pricing."""
selection: PurchaseSelectionRequest
+71
View File
@@ -0,0 +1,71 @@
"""Support tickets schemas for cabinet."""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field
class TicketMessageResponse(BaseModel):
"""Ticket message data."""
id: int
message_text: str
is_from_admin: bool
has_media: bool = False
media_type: Optional[str] = None
media_caption: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
class TicketResponse(BaseModel):
"""Ticket data."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: Optional[datetime] = None
messages_count: int = 0
last_message: Optional[TicketMessageResponse] = None
class Config:
from_attributes = True
class TicketDetailResponse(BaseModel):
"""Ticket with all messages."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: Optional[datetime] = None
is_reply_blocked: bool = False
messages: List[TicketMessageResponse] = []
class Config:
from_attributes = True
class TicketListResponse(BaseModel):
"""Paginated ticket list."""
items: List[TicketResponse]
total: int
page: int
per_page: int
pages: int
class TicketCreateRequest(BaseModel):
"""Request to create a new ticket."""
title: str = Field(..., min_length=3, max_length=255, description="Ticket title")
message: str = Field(..., min_length=10, max_length=4000, description="Initial message")
class TicketMessageCreateRequest(BaseModel):
"""Request to add message to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description="Message text")
+5
View File
@@ -0,0 +1,5 @@
"""Cabinet services."""
from .email_service import EmailService, email_service
__all__ = ["EmailService", "email_service"]
+225
View File
@@ -0,0 +1,225 @@
"""Email service for sending verification and password reset emails."""
import logging
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
from app.config import settings
logger = logging.getLogger(__name__)
class EmailService:
"""Service for sending emails via SMTP."""
def __init__(self):
self.host = settings.SMTP_HOST
self.port = settings.SMTP_PORT
self.user = settings.SMTP_USER
self.password = settings.SMTP_PASSWORD
self.from_email = settings.get_smtp_from_email()
self.from_name = settings.SMTP_FROM_NAME
self.use_tls = settings.SMTP_USE_TLS
def is_configured(self) -> bool:
"""Check if SMTP is properly configured."""
return settings.is_smtp_configured()
def _get_smtp_connection(self) -> smtplib.SMTP:
"""Create and return SMTP connection."""
if self.use_tls:
smtp = smtplib.SMTP(self.host, self.port)
smtp.starttls()
else:
smtp = smtplib.SMTP(self.host, self.port)
if self.user and self.password:
smtp.login(self.user, self.password)
return smtp
def send_email(
self,
to_email: str,
subject: str,
body_html: str,
body_text: Optional[str] = None,
) -> bool:
"""
Send an email.
Args:
to_email: Recipient email address
subject: Email subject
body_html: HTML body content
body_text: Plain text body (optional, generated from HTML if not provided)
Returns:
True if email was sent successfully, False otherwise
"""
if not self.is_configured():
logger.warning("SMTP is not configured, cannot send email")
return False
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{self.from_name} <{self.from_email}>"
msg["To"] = to_email
# Plain text version
if body_text is None:
# Simple HTML to text conversion
import re
body_text = re.sub(r"<[^>]+>", "", body_html)
body_text = body_text.replace("&nbsp;", " ")
body_text = body_text.replace("&amp;", "&")
body_text = body_text.replace("&lt;", "<")
body_text = body_text.replace("&gt;", ">")
part1 = MIMEText(body_text, "plain", "utf-8")
part2 = MIMEText(body_html, "html", "utf-8")
msg.attach(part1)
msg.attach(part2)
with self._get_smtp_connection() as smtp:
smtp.sendmail(self.from_email, to_email, msg.as_string())
logger.info(f"Email sent successfully to {to_email}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {e}")
return False
def send_verification_email(
self,
to_email: str,
verification_token: str,
verification_url: str,
username: Optional[str] = None,
) -> bool:
"""
Send email verification email.
Args:
to_email: Recipient email address
verification_token: Verification token
verification_url: Base URL for verification (token will be appended)
username: User's name for personalization
Returns:
True if email was sent successfully, False otherwise
"""
full_url = f"{verification_url}?token={verification_token}"
greeting = f"Hello{', ' + username if username else ''}!"
subject = "Verify your email address"
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.button {{
display: inline-block;
padding: 12px 24px;
background-color: #007bff;
color: white !important;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}}
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
</style>
</head>
<body>
<div class="container">
<h2>{greeting}</h2>
<p>Thank you for registering! Please verify your email address by clicking the button below:</p>
<a href="{full_url}" class="button">Verify Email</a>
<p>Or copy and paste this link in your browser:</p>
<p><a href="{full_url}">{full_url}</a></p>
<p>This link will expire in {settings.get_cabinet_email_verification_expire_hours()} hours.</p>
<p>If you didn't create an account, you can safely ignore this email.</p>
<div class="footer">
<p>Best regards,<br>{self.from_name}</p>
</div>
</div>
</body>
</html>
"""
return self.send_email(to_email, subject, body_html)
def send_password_reset_email(
self,
to_email: str,
reset_token: str,
reset_url: str,
username: Optional[str] = None,
) -> bool:
"""
Send password reset email.
Args:
to_email: Recipient email address
reset_token: Password reset token
reset_url: Base URL for password reset (token will be appended)
username: User's name for personalization
Returns:
True if email was sent successfully, False otherwise
"""
full_url = f"{reset_url}?token={reset_token}"
greeting = f"Hello{', ' + username if username else ''}!"
subject = "Reset your password"
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.button {{
display: inline-block;
padding: 12px 24px;
background-color: #dc3545;
color: white !important;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}}
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
.warning {{ color: #dc3545; font-weight: bold; }}
</style>
</head>
<body>
<div class="container">
<h2>{greeting}</h2>
<p>We received a request to reset your password. Click the button below to set a new password:</p>
<a href="{full_url}" class="button">Reset Password</a>
<p>Or copy and paste this link in your browser:</p>
<p><a href="{full_url}">{full_url}</a></p>
<p>This link will expire in {settings.get_cabinet_password_reset_expire_hours()} hour(s).</p>
<p class="warning">If you didn't request a password reset, please ignore this email or contact support if you're concerned.</p>
<div class="footer">
<p>Best regards,<br>{self.from_name}</p>
</div>
</div>
</body>
</html>
"""
return self.send_email(to_email, subject, body_html)
# Singleton instance
email_service = EmailService()
+134 -8
View File
@@ -79,7 +79,8 @@ class Settings(BaseSettings):
DATABASE_MODE: str = "auto"
REDIS_URL: str = "redis://localhost:6379/0"
CART_TTL_SECONDS: int = 3600 # Время жизни корзины пользователя в Redis (1 час)
REMNAWAVE_API_URL: Optional[str] = None
REMNAWAVE_API_KEY: Optional[str] = None
REMNAWAVE_SECRET_KEY: Optional[str] = None
@@ -165,7 +166,17 @@ class Settings(BaseSettings):
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
BUY_TRAFFIC_BUTTON_VISIBLE: bool = True
# Режим продаж подписок:
# - classic: классический режим (выбор серверов, трафика, устройств, периода отдельно)
# - tariffs: режим тарифов (готовые пакеты с фиксированными параметрами)
SALES_MODE: str = "classic"
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
# Длительность триала всё равно берётся из TRIAL_DURATION_DAYS
TRIAL_TARIFF_ID: int = 0
# Настройки докупки трафика
TRAFFIC_TOPUP_ENABLED: bool = True # Включить/выключить функцию докупки трафика
# Пакеты для докупки трафика (формат: "гб:цена:enabled", пустая строка = использовать TRAFFIC_PACKAGES_CONFIG)
@@ -398,6 +409,25 @@ class Settings(BaseSettings):
CLOUDPAYMENTS_REQUIRE_EMAIL: bool = False
CLOUDPAYMENTS_TEST_MODE: bool = False
# Freekassa
FREEKASSA_ENABLED: bool = False
FREEKASSA_SHOP_ID: Optional[int] = None
FREEKASSA_API_KEY: Optional[str] = None
FREEKASSA_SECRET_WORD_1: Optional[str] = None # Для формы оплаты
FREEKASSA_SECRET_WORD_2: Optional[str] = None # Для webhook
FREEKASSA_DISPLAY_NAME: str = "Freekassa"
FREEKASSA_CURRENCY: str = "RUB"
FREEKASSA_MIN_AMOUNT_KOPEKS: int = 10000 # 100 руб
FREEKASSA_MAX_AMOUNT_KOPEKS: int = 100000000 # 1 000 000 руб
FREEKASSA_PAYMENT_TIMEOUT_SECONDS: int = 3600
FREEKASSA_WEBHOOK_PATH: str = "/freekassa-webhook"
FREEKASSA_WEBHOOK_HOST: str = "0.0.0.0"
FREEKASSA_WEBHOOK_PORT: int = 8088
# Способ оплаты: None = форма выбора, 42 = обычный СБП, 44 = NSPK СБП
FREEKASSA_PAYMENT_SYSTEM_ID: Optional[int] = None
# Использовать API для создания заказов (нужно для NSPK СБП)
FREEKASSA_USE_API: bool = False
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
@@ -493,6 +523,25 @@ class Settings(BaseSettings):
EXTERNAL_ADMIN_TOKEN: Optional[str] = None
EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None
# Cabinet (Personal Account) settings
CABINET_ENABLED: bool = False
CABINET_JWT_SECRET: Optional[str] = None
CABINET_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
CABINET_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
CABINET_ALLOWED_ORIGINS: str = ""
CABINET_EMAIL_VERIFICATION_ENABLED: bool = True
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS: int = 24
CABINET_PASSWORD_RESET_EXPIRE_HOURS: int = 1
# SMTP settings for cabinet email
SMTP_HOST: Optional[str] = None
SMTP_PORT: int = 587
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
SMTP_FROM_EMAIL: Optional[str] = None
SMTP_FROM_NAME: str = "VPN Service"
SMTP_USE_TLS: bool = True
@field_validator('MAIN_MENU_MODE', mode='before')
@classmethod
def normalize_main_menu_mode(cls, value: Optional[str]) -> str:
@@ -822,6 +871,10 @@ class Settings(BaseSettings):
return bool(value)
def is_quick_amount_buttons_enabled(self) -> bool:
"""Показывать ли кнопки быстрого выбора суммы пополнения."""
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
def get_available_languages(self) -> List[str]:
defaults = ["ru", "en", "ua", "zh"]
@@ -1152,6 +1205,22 @@ class Settings(BaseSettings):
def is_modem_enabled(self) -> bool:
return bool(self.MODEM_ENABLED)
def is_tariffs_mode(self) -> bool:
"""Проверяет, включен ли режим продаж 'Тарифы'."""
return self.SALES_MODE == "tariffs"
def is_classic_mode(self) -> bool:
"""Проверяет, включен ли классический режим продаж."""
return self.SALES_MODE != "tariffs"
def get_sales_mode(self) -> str:
"""Возвращает текущий режим продаж."""
return self.SALES_MODE if self.SALES_MODE in ("classic", "tariffs") else "classic"
def get_trial_tariff_id(self) -> int:
"""Возвращает ID тарифа для триала (0 = использовать стандартные настройки)."""
return self.TRIAL_TARIFF_ID if self.TRIAL_TARIFF_ID > 0 else 0
def get_modem_price_per_month(self) -> int:
try:
value = int(self.MODEM_PRICE_PER_MONTH)
@@ -1209,11 +1278,12 @@ class Settings(BaseSettings):
return applicable_discount
def is_trial_paid_activation_enabled(self) -> bool:
# Если цена > 0, триал автоматически платный
# (TRIAL_PAYMENT_ENABLED теперь опционален - для обратной совместимости)
if self.TRIAL_ACTIVATION_PRICE > 0:
return True
return bool(self.TRIAL_PAYMENT_ENABLED)
# TRIAL_PAYMENT_ENABLED - главный переключатель платной активации
# Если выключен - триал бесплатный, независимо от цены
if not self.TRIAL_PAYMENT_ENABLED:
return False
# Если включен - проверяем что цена > 0
return self.TRIAL_ACTIVATION_PRICE > 0
def get_trial_activation_price(self) -> int:
try:
@@ -1390,6 +1460,22 @@ class Settings(BaseSettings):
and self.CLOUDPAYMENTS_API_SECRET is not None
)
def is_freekassa_enabled(self) -> bool:
return (
self.FREEKASSA_ENABLED
and self.FREEKASSA_SHOP_ID is not None
and self.FREEKASSA_API_KEY is not None
and self.FREEKASSA_SECRET_WORD_1 is not None
and self.FREEKASSA_SECRET_WORD_2 is not None
)
def get_freekassa_display_name(self) -> str:
name = (self.FREEKASSA_DISPLAY_NAME or "").strip()
return name if name else "Freekassa"
def get_freekassa_display_name_html(self) -> str:
return html.escape(self.get_freekassa_display_name())
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
@@ -1622,7 +1708,10 @@ class Settings(BaseSettings):
return stars * self.get_stars_rate()
def rubles_to_stars(self, rubles: float) -> int:
return max(1, math.ceil(rubles / self.get_stars_rate()))
rate = self.get_stars_rate()
if rate <= 0:
raise ValueError("Stars rate must be positive")
return max(1, math.ceil(rubles / rate))
def get_admin_notifications_chat_id(self) -> Optional[int]:
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
@@ -2005,6 +2094,43 @@ class Settings(BaseSettings):
raw_path = "miniapp"
return Path(raw_path)
# Cabinet methods
def is_cabinet_enabled(self) -> bool:
return bool(self.CABINET_ENABLED)
def get_cabinet_jwt_secret(self) -> str:
if self.CABINET_JWT_SECRET:
return self.CABINET_JWT_SECRET
return self.BOT_TOKEN
def get_cabinet_access_token_expire_minutes(self) -> int:
return max(1, self.CABINET_ACCESS_TOKEN_EXPIRE_MINUTES)
def get_cabinet_refresh_token_expire_days(self) -> int:
return max(1, self.CABINET_REFRESH_TOKEN_EXPIRE_DAYS)
def get_cabinet_allowed_origins(self) -> List[str]:
if not self.CABINET_ALLOWED_ORIGINS:
return []
return [o.strip() for o in self.CABINET_ALLOWED_ORIGINS.split(",") if o.strip()]
def is_cabinet_email_verification_enabled(self) -> bool:
return bool(self.CABINET_EMAIL_VERIFICATION_ENABLED)
def get_cabinet_email_verification_expire_hours(self) -> int:
return max(1, self.CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS)
def get_cabinet_password_reset_expire_hours(self) -> int:
return max(1, self.CABINET_PASSWORD_RESET_EXPIRE_HOURS)
def is_smtp_configured(self) -> bool:
return bool(self.SMTP_HOST and self.SMTP_USER and self.SMTP_PASSWORD)
def get_smtp_from_email(self) -> Optional[str]:
if self.SMTP_FROM_EMAIL:
return self.SMTP_FROM_EMAIL
return self.SMTP_USER
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
+159
View File
@@ -0,0 +1,159 @@
"""CRUD операции для платежей Freekassa."""
import json
import logging
from datetime import datetime
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FreekassaPayment
logger = logging.getLogger(__name__)
async def create_freekassa_payment(
db: AsyncSession,
*,
user_id: int,
order_id: str,
amount_kopeks: int,
currency: str = "RUB",
description: Optional[str] = None,
payment_url: Optional[str] = None,
expires_at: Optional[datetime] = None,
metadata_json: Optional[str] = None,
) -> FreekassaPayment:
"""Создает запись о платеже Freekassa."""
payment = FreekassaPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
expires_at=expires_at,
metadata_json=json.loads(metadata_json) if metadata_json else None,
status="pending",
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f"Создан платеж Freekassa: order_id={order_id}, user_id={user_id}")
return payment
async def get_freekassa_payment_by_order_id(
db: AsyncSession, order_id: str
) -> Optional[FreekassaPayment]:
"""Получает платеж по order_id."""
result = await db.execute(
select(FreekassaPayment).where(FreekassaPayment.order_id == order_id)
)
return result.scalar_one_or_none()
async def get_freekassa_payment_by_fk_order_id(
db: AsyncSession, freekassa_order_id: str
) -> Optional[FreekassaPayment]:
"""Получает платеж по ID от Freekassa (intid)."""
result = await db.execute(
select(FreekassaPayment).where(
FreekassaPayment.freekassa_order_id == freekassa_order_id
)
)
return result.scalar_one_or_none()
async def get_freekassa_payment_by_id(
db: AsyncSession, payment_id: int
) -> Optional[FreekassaPayment]:
"""Получает платеж по ID."""
result = await db.execute(
select(FreekassaPayment).where(FreekassaPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def update_freekassa_payment_status(
db: AsyncSession,
payment: FreekassaPayment,
*,
status: str,
is_paid: bool = False,
freekassa_order_id: Optional[str] = None,
payment_system_id: Optional[int] = None,
callback_payload: Optional[dict] = None,
transaction_id: Optional[int] = None,
) -> FreekassaPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.is_paid = is_paid
payment.updated_at = datetime.utcnow()
if is_paid:
payment.paid_at = datetime.utcnow()
if freekassa_order_id:
payment.freekassa_order_id = freekassa_order_id
if payment_system_id is not None:
payment.payment_system_id = payment_system_id
if callback_payload:
payment.callback_payload = callback_payload
if transaction_id:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
f"Обновлен статус платежа Freekassa: order_id={payment.order_id}, "
f"status={status}, is_paid={is_paid}"
)
return payment
async def get_pending_freekassa_payments(
db: AsyncSession, user_id: int
) -> List[FreekassaPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(FreekassaPayment).where(
FreekassaPayment.user_id == user_id,
FreekassaPayment.status == "pending",
FreekassaPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_user_freekassa_payments(
db: AsyncSession,
user_id: int,
limit: int = 10,
offset: int = 0,
) -> List[FreekassaPayment]:
"""Получает платежи пользователя с пагинацией."""
result = await db.execute(
select(FreekassaPayment)
.where(FreekassaPayment.user_id == user_id)
.order_by(FreekassaPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
async def get_expired_pending_payments(
db: AsyncSession,
) -> List[FreekassaPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.utcnow()
result = await db.execute(
select(FreekassaPayment).where(
FreekassaPayment.status == "pending",
FreekassaPayment.is_paid == False,
FreekassaPayment.expires_at < now,
)
)
return list(result.scalars().all())
+140 -97
View File
@@ -44,23 +44,38 @@ async def create_trial_subscription(
duration_days: int = None,
traffic_limit_gb: int = None,
device_limit: Optional[int] = None,
squad_uuid: str = None
squad_uuid: str = None,
connected_squads: List[str] = None,
tariff_id: Optional[int] = None,
) -> Subscription:
"""Создает триальную подписку.
Args:
connected_squads: Список UUID сквадов (если указан, squad_uuid игнорируется)
tariff_id: ID тарифа (для режима тарифов)
"""
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
if device_limit is None:
device_limit = settings.TRIAL_DEVICE_LIMIT
if not squad_uuid:
# Если переданы connected_squads, используем их
# Иначе используем squad_uuid или получаем случайный
final_squads = []
if connected_squads:
final_squads = connected_squads
elif squad_uuid:
final_squads = [squad_uuid]
else:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
squad_uuid = await get_random_trial_squad_uuid(db)
if squad_uuid:
random_squad = await get_random_trial_squad_uuid(db)
if random_squad:
final_squads = [random_squad]
logger.debug(
"Выбран сквад %s для триальной подписки пользователя %s",
squad_uuid,
random_squad,
user_id,
)
except Exception as error:
@@ -80,40 +95,42 @@ async def create_trial_subscription(
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=[squad_uuid] if squad_uuid else [],
connected_squads=final_squads,
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
tariff_id=tariff_id,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}")
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}" +
(f" с тарифом {tariff_id}" if tariff_id else ""))
if squad_uuid:
if final_squads:
try:
from app.database.crud.server_squad import (
get_server_ids_by_uuids,
add_user_to_servers,
)
server_ids = await get_server_ids_by_uuids(db, [squad_uuid])
server_ids = await get_server_ids_by_uuids(db, final_squads)
if server_ids:
await add_user_to_servers(db, server_ids)
logger.info(
"📈 Обновлен счетчик пользователей для триального сквада %s",
squad_uuid,
"📈 Обновлен счетчик пользователей для триальных сквадов %s",
final_squads,
)
else:
logger.warning(
"⚠️ Не удалось найти серверы для обновления счетчика (сквад %s)",
squad_uuid,
"⚠️ Не удалось найти серверы для обновления счетчика (сквады %s)",
final_squads,
)
except Exception as error:
logger.error(
"⚠️ Ошибка обновления счетчика пользователей для триального сквада %s: %s",
squad_uuid,
"⚠️ Ошибка обновления счетчика пользователей для триальных сквадов %s: %s",
final_squads,
error,
)
@@ -129,6 +146,7 @@ async def create_paid_subscription(
connected_squads: List[str] = None,
update_server_counters: bool = False,
is_trial: bool = False,
tariff_id: Optional[int] = None,
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
@@ -147,6 +165,7 @@ async def create_paid_subscription(
connected_squads=connected_squads or [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
tariff_id=tariff_id,
)
db.add(subscription)
@@ -276,8 +295,24 @@ async def replace_subscription(
async def extend_subscription(
db: AsyncSession,
subscription: Subscription,
days: int
days: int,
*,
tariff_id: Optional[int] = None,
traffic_limit_gb: Optional[int] = None,
device_limit: Optional[int] = None,
connected_squads: Optional[List[str]] = None,
) -> Subscription:
"""Продлевает подписку на указанное количество дней.
Args:
db: Сессия базы данных
subscription: Подписка для продления
days: Количество дней для продления
tariff_id: ID тарифа (опционально, для режима тарифов)
traffic_limit_gb: Лимит трафика ГБ (опционально, для режима тарифов)
device_limit: Лимит устройств (опционально, для режима тарифов)
connected_squads: Список UUID сквадов (опционально, для режима тарифов)
"""
current_time = datetime.utcnow()
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
@@ -320,7 +355,7 @@ async def extend_subscription(
# Логируем статус подписки перед проверкой
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
if days > 0 and subscription.status in (
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
@@ -339,13 +374,37 @@ async def extend_subscription(
days
)
if settings.RESET_TRAFFIC_ON_PAYMENT:
# Обновляем параметры тарифа, если переданы
if tariff_id is not None:
old_tariff_id = subscription.tariff_id
subscription.tariff_id = tariff_id
logger.info(f"📦 Обновлен тариф подписки: {old_tariff_id}{tariff_id}")
if traffic_limit_gb is not None:
old_traffic = subscription.traffic_limit_gb
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик вместе с использованным
subscription.purchased_traffic_gb = 0
logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ")
elif settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
subscription.purchased_traffic_gb = 0
logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
if device_limit is not None:
old_devices = subscription.device_limit
subscription.device_limit = device_limit
logger.info(f"📱 Обновлен лимит устройств: {old_devices}{device_limit}")
if connected_squads is not None:
old_squads = subscription.connected_squads
subscription.connected_squads = connected_squads
logger.info(f"🌍 Обновлены сквады: {old_squads}{connected_squads}")
# В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита
if settings.is_traffic_fixed() and days > 0:
# Только если не передан traffic_limit_gb И у подписки нет тарифа (классический режим)
# Если у подписки есть tariff_id - трафик определяется тарифом, не сбрасываем
if traffic_limit_gb is None and settings.is_traffic_fixed() and days > 0 and subscription.tariff_id is None:
fixed_limit = settings.get_fixed_traffic_limit()
old_limit = subscription.traffic_limit_gb
if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0:
@@ -527,6 +586,33 @@ async def deactivate_subscription(
return subscription
async def reactivate_subscription(
db: AsyncSession,
subscription: Subscription
) -> Subscription:
"""Реактивация подписки (например, после повторной подписки на канал).
Активирует только если подписка была DISABLED и ещё не истекла.
Не логирует если реактивация не требуется.
"""
now = datetime.utcnow()
# Тихо выходим если реактивация не нужна
if subscription.status != SubscriptionStatus.DISABLED.value:
return subscription
if subscription.end_date and subscription.end_date <= now:
return subscription
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = now
await db.commit()
await db.refresh(subscription)
return subscription
async def get_expiring_subscriptions(
db: AsyncSession,
days_before: int = 3
@@ -1429,8 +1515,11 @@ async def create_subscription_no_commit(
)
db.add(subscription)
# Выполняем flush, чтобы получить присвоенный первичный ключ
await db.flush()
# Не коммитим сразу, оставляем для пакетной обработки
logger.info(f"✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)")
return subscription
@@ -1498,10 +1587,15 @@ async def create_pending_subscription(
device_limit: int = 1,
connected_squads: List[str] = None,
payment_method: str = "pending",
total_price_kopeks: int = 0
total_price_kopeks: int = 0,
is_trial: bool = False,
) -> Subscription:
"""Creates a pending subscription that will be activated after payment."""
"""Creates a pending subscription that will be activated after payment.
Args:
is_trial: If True, marks the subscription as a trial subscription.
"""
trial_label = "триальная " if is_trial else ""
current_time = datetime.utcnow()
end_date = current_time + timedelta(days=duration_days)
@@ -1513,13 +1607,14 @@ async def create_pending_subscription(
and existing_subscription.end_date > current_time
):
logger.warning(
"⚠️ Попытка создать pending подписку для активного пользователя %s. Возвращаем существующую запись.",
"⚠️ Попытка создать pending %sподписку для активного пользователя %s. Возвращаем существующую запись.",
trial_label,
user_id,
)
return existing_subscription
existing_subscription.status = SubscriptionStatus.PENDING.value
existing_subscription.is_trial = False
existing_subscription.is_trial = is_trial
existing_subscription.start_date = current_time
existing_subscription.end_date = end_date
existing_subscription.traffic_limit_gb = traffic_limit_gb
@@ -1532,7 +1627,8 @@ async def create_pending_subscription(
await db.refresh(existing_subscription)
logger.info(
"♻️ Обновлена ожидающая подписка пользователя %s, ID: %s, метод оплаты: %s",
"♻️ Обновлена ожидающая %sподписка пользователя %s, ID: %s, метод оплаты: %s",
trial_label,
user_id,
existing_subscription.id,
payment_method,
@@ -1542,7 +1638,7 @@ async def create_pending_subscription(
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.PENDING.value,
is_trial=False,
is_trial=is_trial,
start_date=current_time,
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
@@ -1551,13 +1647,14 @@ async def create_pending_subscription(
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(
"💳 Создана ожидающая подписка для пользователя %s, ID: %s, метод оплаты: %s",
"💳 Создана ожидающая %sподписка для пользователя %s, ID: %s, метод оплаты: %s",
trial_label,
user_id,
subscription.id,
payment_method,
@@ -1566,6 +1663,7 @@ async def create_pending_subscription(
return subscription
# Обратная совместимость: алиас для триальной подписки
async def create_pending_trial_subscription(
db: AsyncSession,
user_id: int,
@@ -1574,74 +1672,21 @@ async def create_pending_trial_subscription(
device_limit: int = 1,
connected_squads: List[str] = None,
payment_method: str = "pending",
total_price_kopeks: int = 0
total_price_kopeks: int = 0,
) -> Subscription:
"""Creates a pending trial subscription that will be activated after payment."""
current_time = datetime.utcnow()
end_date = current_time + timedelta(days=duration_days)
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
if (
existing_subscription.status == SubscriptionStatus.ACTIVE.value
and existing_subscription.end_date > current_time
):
logger.warning(
"⚠️ Попытка создать pending триал для активного пользователя %s. Возвращаем существующую запись.",
user_id,
)
return existing_subscription
# Обновляем существующую подписку
existing_subscription.status = SubscriptionStatus.PENDING.value
existing_subscription.is_trial = True # Помечаем как триальную
existing_subscription.start_date = current_time
existing_subscription.end_date = end_date
existing_subscription.traffic_limit_gb = traffic_limit_gb
existing_subscription.device_limit = device_limit
existing_subscription.connected_squads = connected_squads or []
existing_subscription.traffic_used_gb = 0.0
existing_subscription.updated_at = current_time
await db.commit()
await db.refresh(existing_subscription)
logger.info(
"♻️ Обновлена ожидающая триальная подписка пользователя %s, ID: %s, метод оплаты: %s",
user_id,
existing_subscription.id,
payment_method,
)
return existing_subscription
subscription = Subscription(
"""Creates a pending trial subscription. Wrapper for create_pending_subscription with is_trial=True."""
return await create_pending_subscription(
db=db,
user_id=user_id,
status=SubscriptionStatus.PENDING.value,
is_trial=True, # Помечаем как триальную
start_date=current_time,
end_date=end_date,
duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads or [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
connected_squads=connected_squads,
payment_method=payment_method,
total_price_kopeks=total_price_kopeks,
is_trial=True,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(
"💳 Создана ожидающая триальная подписка для пользователя %s, ID: %s, метод оплаты: %s",
user_id,
subscription.id,
payment_method,
)
return subscription
async def activate_pending_subscription(
db: AsyncSession,
@@ -1649,8 +1694,6 @@ async def activate_pending_subscription(
period_days: int = None
) -> Optional[Subscription]:
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
from sqlalchemy import and_
logger.info(f"Активация pending подписки: пользователь {user_id}, период {period_days} дней")
# Находим pending подписку пользователя
+401
View File
@@ -0,0 +1,401 @@
import logging
from typing import Dict, List, Optional
from sqlalchemy import func, select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import Tariff, Subscription, PromoGroup, tariff_promo_groups
logger = logging.getLogger(__name__)
def _normalize_period_prices(period_prices: Optional[Dict[int, int]]) -> Dict[str, int]:
"""Нормализует цены периодов в формат {str: int}."""
if not period_prices:
return {}
normalized: Dict[str, int] = {}
for key, value in period_prices.items():
try:
period = int(key)
price = int(value)
except (TypeError, ValueError):
continue
if period > 0 and price >= 0:
normalized[str(period)] = price
return normalized
async def get_all_tariffs(
db: AsyncSession,
*,
include_inactive: bool = False,
offset: int = 0,
limit: Optional[int] = None,
) -> List[Tariff]:
"""Получает все тарифы с опциональной фильтрацией по активности."""
query = select(Tariff).options(selectinload(Tariff.allowed_promo_groups))
if not include_inactive:
query = query.where(Tariff.is_active.is_(True))
query = query.order_by(Tariff.display_order, Tariff.id)
if offset:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
result = await db.execute(query)
return result.scalars().all()
async def get_tariff_by_id(
db: AsyncSession,
tariff_id: int,
*,
with_promo_groups: bool = True,
) -> Optional[Tariff]:
"""Получает тариф по ID."""
query = select(Tariff).where(Tariff.id == tariff_id)
if with_promo_groups:
query = query.options(selectinload(Tariff.allowed_promo_groups))
result = await db.execute(query)
return result.scalars().first()
async def count_tariffs(db: AsyncSession, *, include_inactive: bool = False) -> int:
"""Подсчитывает количество тарифов."""
query = select(func.count(Tariff.id))
if not include_inactive:
query = query.where(Tariff.is_active.is_(True))
result = await db.execute(query)
return int(result.scalar_one())
async def get_trial_tariff(db: AsyncSession) -> Optional[Tariff]:
"""Получает тариф, доступный для триала (is_trial_available=True)."""
query = (
select(Tariff)
.where(Tariff.is_trial_available.is_(True))
.where(Tariff.is_active.is_(True))
.options(selectinload(Tariff.allowed_promo_groups))
.limit(1)
)
result = await db.execute(query)
return result.scalars().first()
async def set_trial_tariff(db: AsyncSession, tariff_id: int) -> Optional[Tariff]:
"""Устанавливает тариф как триальный (снимает флаг с других тарифов)."""
# Снимаем флаг с всех тарифов
await db.execute(
Tariff.__table__.update().values(is_trial_available=False)
)
# Устанавливаем флаг на выбранный тариф
tariff = await get_tariff_by_id(db, tariff_id)
if tariff:
tariff.is_trial_available = True
await db.commit()
await db.refresh(tariff)
return tariff
async def clear_trial_tariff(db: AsyncSession) -> None:
"""Снимает флаг триала со всех тарифов."""
await db.execute(
Tariff.__table__.update().values(is_trial_available=False)
)
await db.commit()
async def get_tariffs_for_user(
db: AsyncSession,
promo_group_id: Optional[int] = None,
) -> List[Tariff]:
"""
Получает тарифы, доступные для пользователя с учетом его промогруппы.
Если у тарифа нет ограничений по промогруппам - он доступен всем.
"""
query = (
select(Tariff)
.options(selectinload(Tariff.allowed_promo_groups))
.where(Tariff.is_active.is_(True))
.order_by(Tariff.display_order, Tariff.id)
)
result = await db.execute(query)
tariffs = result.scalars().all()
# Фильтруем по промогруппе
available_tariffs = []
for tariff in tariffs:
if not tariff.allowed_promo_groups:
# Нет ограничений - доступен всем
available_tariffs.append(tariff)
elif promo_group_id is not None:
# Проверяем, есть ли промогруппа пользователя в списке разрешенных
if any(pg.id == promo_group_id for pg in tariff.allowed_promo_groups):
available_tariffs.append(tariff)
# else: пользователь без промогруппы, а у тарифа есть ограничения - пропускаем
return available_tariffs
async def create_tariff(
db: AsyncSession,
name: str,
*,
description: Optional[str] = None,
display_order: int = 0,
is_active: bool = True,
traffic_limit_gb: int = 100,
device_limit: int = 1,
allowed_squads: Optional[List[str]] = None,
period_prices: Optional[Dict[int, int]] = None,
tier_level: int = 1,
is_trial_available: bool = False,
promo_group_ids: Optional[List[int]] = None,
) -> Tariff:
"""Создает новый тариф."""
normalized_prices = _normalize_period_prices(period_prices)
tariff = Tariff(
name=name.strip(),
description=description.strip() if description else None,
display_order=max(0, display_order),
is_active=is_active,
traffic_limit_gb=max(0, traffic_limit_gb),
device_limit=max(1, device_limit),
allowed_squads=allowed_squads or [],
period_prices=normalized_prices,
tier_level=max(1, tier_level),
is_trial_available=is_trial_available,
)
db.add(tariff)
await db.flush()
# Добавляем промогруппы если указаны
if promo_group_ids:
promo_groups_result = await db.execute(
select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids))
)
promo_groups = promo_groups_result.scalars().all()
tariff.allowed_promo_groups = list(promo_groups)
await db.commit()
await db.refresh(tariff)
logger.info(
"Создан тариф '%s' (id=%s, tier=%s, traffic=%sGB, devices=%s, prices=%s)",
tariff.name,
tariff.id,
tariff.tier_level,
tariff.traffic_limit_gb,
tariff.device_limit,
normalized_prices,
)
return tariff
async def update_tariff(
db: AsyncSession,
tariff: Tariff,
*,
name: Optional[str] = None,
description: Optional[str] = None,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
traffic_limit_gb: Optional[int] = None,
device_limit: Optional[int] = None,
device_price_kopeks: Optional[int] = ..., # ... = не передан, None = сбросить
allowed_squads: Optional[List[str]] = None,
period_prices: Optional[Dict[int, int]] = None,
tier_level: Optional[int] = None,
is_trial_available: Optional[bool] = None,
promo_group_ids: Optional[List[int]] = None,
) -> Tariff:
"""Обновляет существующий тариф."""
if name is not None:
tariff.name = name.strip()
if description is not None:
tariff.description = description.strip() if description else None
if display_order is not None:
tariff.display_order = max(0, display_order)
if is_active is not None:
tariff.is_active = is_active
if traffic_limit_gb is not None:
tariff.traffic_limit_gb = max(0, traffic_limit_gb)
if device_limit is not None:
tariff.device_limit = max(1, device_limit)
if device_price_kopeks is not ...:
# Если передан device_price_kopeks (включая None) - обновляем
tariff.device_price_kopeks = device_price_kopeks
if allowed_squads is not None:
tariff.allowed_squads = allowed_squads
if period_prices is not None:
tariff.period_prices = _normalize_period_prices(period_prices)
if tier_level is not None:
tariff.tier_level = max(1, tier_level)
if is_trial_available is not None:
tariff.is_trial_available = is_trial_available
# Обновляем промогруппы если указаны
if promo_group_ids is not None:
if promo_group_ids:
promo_groups_result = await db.execute(
select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids))
)
promo_groups = promo_groups_result.scalars().all()
tariff.allowed_promo_groups = list(promo_groups)
else:
tariff.allowed_promo_groups = []
await db.commit()
await db.refresh(tariff)
logger.info(
"Обновлен тариф '%s' (id=%s)",
tariff.name,
tariff.id,
)
return tariff
async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool:
"""
Удаляет тариф.
Подписки с этим тарифом получат tariff_id = NULL.
"""
tariff_id = tariff.id
tariff_name = tariff.name
# Подсчитываем подписки с этим тарифом
subscriptions_count = await db.execute(
select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id)
)
affected_subscriptions = subscriptions_count.scalar_one()
# Удаляем тариф (FK с ondelete=SET NULL автоматически обнулит tariff_id в подписках)
await db.delete(tariff)
await db.commit()
logger.info(
"Удален тариф '%s' (id=%s), затронуто подписок: %s",
tariff_name,
tariff_id,
affected_subscriptions,
)
return True
async def get_tariff_subscriptions_count(db: AsyncSession, tariff_id: int) -> int:
"""Подсчитывает количество подписок на тарифе."""
result = await db.execute(
select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id)
)
return int(result.scalar_one())
async def set_tariff_promo_groups(
db: AsyncSession,
tariff: Tariff,
promo_group_ids: List[int],
) -> Tariff:
"""Устанавливает промогруппы для тарифа."""
if promo_group_ids:
promo_groups_result = await db.execute(
select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids))
)
promo_groups = promo_groups_result.scalars().all()
tariff.allowed_promo_groups = list(promo_groups)
else:
tariff.allowed_promo_groups = []
await db.commit()
await db.refresh(tariff)
return tariff
async def add_promo_group_to_tariff(
db: AsyncSession,
tariff: Tariff,
promo_group_id: int,
) -> bool:
"""Добавляет промогруппу к тарифу."""
promo_group = await db.get(PromoGroup, promo_group_id)
if not promo_group:
return False
if promo_group not in tariff.allowed_promo_groups:
tariff.allowed_promo_groups.append(promo_group)
await db.commit()
return True
async def remove_promo_group_from_tariff(
db: AsyncSession,
tariff: Tariff,
promo_group_id: int,
) -> bool:
"""Удаляет промогруппу из тарифа."""
for pg in tariff.allowed_promo_groups:
if pg.id == promo_group_id:
tariff.allowed_promo_groups.remove(pg)
await db.commit()
return True
return False
async def get_tariffs_with_subscriptions_count(
db: AsyncSession,
*,
include_inactive: bool = False,
) -> List[tuple]:
"""Получает тарифы с количеством подписок."""
query = (
select(Tariff, func.count(Subscription.id))
.outerjoin(Subscription, Subscription.tariff_id == Tariff.id)
.group_by(Tariff.id)
.order_by(Tariff.display_order, Tariff.id)
)
if not include_inactive:
query = query.where(Tariff.is_active.is_(True))
result = await db.execute(query)
return result.all()
async def reorder_tariffs(
db: AsyncSession,
tariff_order: List[int],
) -> None:
"""Изменяет порядок отображения тарифов."""
for order, tariff_id in enumerate(tariff_order):
await db.execute(
update(Tariff)
.where(Tariff.id == tariff_id)
.values(display_order=order)
)
await db.commit()
logger.info("Изменен порядок тарифов: %s", tariff_order)
+60
View File
@@ -1,4 +1,5 @@
from typing import List, Optional
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, and_, or_, update, func
from sqlalchemy.orm import selectinload
@@ -6,6 +7,8 @@ from datetime import datetime
from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog
logger = logging.getLogger(__name__)
class TicketCRUD:
"""CRUD операции для работы с тикетами"""
@@ -47,6 +50,25 @@ class TicketCRUD:
await db.commit()
await db.refresh(ticket)
# Отправляем событие о создании тикета
try:
from app.services.event_emitter import event_emitter
await event_emitter.emit(
"ticket.created",
{
"ticket_id": ticket.id,
"user_id": user_id,
"title": title,
"status": ticket.status,
"priority": priority,
"has_media": bool(media_type and media_file_id),
},
db=db,
)
except Exception as error:
logger.warning("Failed to emit ticket.created event: %s", error)
return ticket
@staticmethod
@@ -246,6 +268,24 @@ class TicketCRUD:
ticket.closed_at = closed_at
await db.commit()
# Отправляем событие об изменении статуса тикета
try:
from app.services.event_emitter import event_emitter
await event_emitter.emit(
"ticket.status_changed",
{
"ticket_id": ticket_id,
"user_id": ticket.user_id,
"old_status": ticket.status, # На самом деле это уже новый статус, но для простоты оставим так
"new_status": status,
"closed_at": closed_at.isoformat() if closed_at else None,
},
db=db,
)
except Exception as error:
logger.warning("Failed to emit ticket.status_changed event: %s", error)
return True
@staticmethod
@@ -434,6 +474,26 @@ class TicketMessageCRUD:
await db.commit()
await db.refresh(message)
# Отправляем событие о новом сообщении в тикете
try:
from app.services.event_emitter import event_emitter
await event_emitter.emit(
"ticket.message_added",
{
"ticket_id": ticket_id,
"message_id": message.id,
"user_id": user_id,
"is_from_admin": is_from_admin,
"message_text": message_text[:200], # Ограничиваем длину для события
"has_media": bool(media_type and media_file_id),
"status": ticket.status if ticket else None,
},
db=db,
)
except Exception as error:
logger.warning("Failed to emit ticket.message_added event: %s", error)
return message
@staticmethod
+21
View File
@@ -38,6 +38,27 @@ async def create_transaction(
logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}")
# Отправляем событие о транзакции
try:
from app.services.event_emitter import event_emitter
await event_emitter.emit(
"payment.completed" if type == TransactionType.DEPOSIT else "transaction.created",
{
"transaction_id": transaction.id,
"user_id": user_id,
"type": type.value,
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_kopeks / 100,
"payment_method": payment_method.value if payment_method else None,
"external_id": external_id,
"is_completed": is_completed,
"description": description,
},
db=db,
)
except Exception as error:
logger.warning("Failed to emit transaction event: %s", error)
try:
from app.services.promo_group_assignment import (
maybe_assign_promo_group_by_total_spent,
+101 -54
View File
@@ -28,6 +28,48 @@ from app.utils.validators import sanitize_telegram_name
logger = logging.getLogger(__name__)
def _build_spending_stats_select():
"""
Возвращает базовый SELECT для статистики трат пользователей.
Используется в:
- get_users_list() для сортировки по тратам/покупкам
- get_users_spending_stats() для получения статистики
Returns:
Tuple колонок (user_id, total_spent, purchase_count)
"""
from app.database.models import Transaction
return (
Transaction.user_id.label("user_id"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.amount_kopeks,
),
else_=0,
)
),
0,
).label("total_spent"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
1,
),
else_=0,
)
),
0,
).label("purchase_count"),
)
def generate_referral_code() -> str:
alphabet = string.ascii_letters + string.digits
code_suffix = ''.join(secrets.choice(alphabet) for _ in range(8))
@@ -279,6 +321,26 @@ async def create_user(
logger.info(
f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}"
)
# Отправляем событие о создании пользователя
try:
from app.services.event_emitter import event_emitter
await event_emitter.emit(
"user.created",
{
"user_id": user.id,
"telegram_id": user.telegram_id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"referral_code": user.referral_code,
"referred_by_id": user.referred_by_id,
},
db=db,
)
except Exception as error:
logger.warning("Failed to emit user.created event: %s", error)
return user
except IntegrityError as exc:
@@ -661,33 +723,7 @@ async def get_users_list(
from app.database.models import Transaction
transactions_stats = (
select(
Transaction.user_id.label("user_id"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.amount_kopeks,
),
else_=0,
)
),
0,
).label("total_spent"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
1,
),
else_=0,
)
),
0,
).label("purchase_count"),
)
select(*_build_spending_stats_select())
.where(Transaction.is_completed.is_(True))
.group_by(Transaction.user_id)
.subquery()
@@ -764,39 +800,23 @@ async def get_users_spending_stats(
db: AsyncSession,
user_ids: List[int]
) -> Dict[int, Dict[str, int]]:
"""
Получает статистику трат для списка пользователей.
Args:
db: Сессия базы данных
user_ids: Список ID пользователей
Returns:
Словарь {user_id: {"total_spent": int, "purchase_count": int}}
"""
if not user_ids:
return {}
from app.database.models import Transaction
stats_query = (
select(
Transaction.user_id,
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.amount_kopeks,
),
else_=0,
)
),
0,
).label("total_spent"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
1,
),
else_=0,
)
),
0,
).label("purchase_count"),
)
select(*_build_spending_stats_select())
.where(
Transaction.user_id.in_(user_ids),
Transaction.is_completed.is_(True),
@@ -993,3 +1013,30 @@ async def get_users_statistics(db: AsyncSession) -> dict:
"new_week": new_week,
"new_month": new_month
}
async def get_users_with_active_subscriptions(db: AsyncSession) -> List[User]:
"""
Получает список пользователей с активными подписками.
Используется для мониторинга трафика.
Returns:
Список пользователей с активными подписками и remnawave_uuid
"""
current_time = datetime.utcnow()
result = await db.execute(
select(User)
.join(Subscription, User.id == Subscription.user_id)
.where(
and_(
User.remnawave_uuid.isnot(None),
User.status == UserStatus.ACTIVE.value,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > current_time,
)
)
.options(selectinload(User.subscription))
)
return result.scalars().unique().all()
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Webhook, WebhookDelivery
async def create_webhook(
db: AsyncSession,
name: str,
url: str,
event_type: str,
secret: Optional[str] = None,
description: Optional[str] = None,
) -> Webhook:
"""Создать новый webhook."""
webhook = Webhook(
name=name,
url=url,
event_type=event_type,
secret=secret,
description=description,
is_active=True,
)
db.add(webhook)
await db.commit()
await db.refresh(webhook)
return webhook
async def get_webhook_by_id(db: AsyncSession, webhook_id: int) -> Optional[Webhook]:
"""Получить webhook по ID."""
result = await db.execute(select(Webhook).where(Webhook.id == webhook_id))
return result.scalar_one_or_none()
async def list_webhooks(
db: AsyncSession,
event_type: Optional[str] = None,
is_active: Optional[bool] = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[Webhook], int]:
"""Список webhooks с фильтрами."""
query = select(Webhook)
if event_type:
query = query.where(Webhook.event_type == event_type)
if is_active is not None:
query = query.where(Webhook.is_active == is_active)
# Подсчет общего количества
count_query = select(func.count()).select_from(query.subquery())
total = await db.scalar(count_query) or 0
# Получение данных
query = query.order_by(Webhook.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(query)
webhooks = result.scalars().all()
return list(webhooks), int(total)
async def get_active_webhooks_for_event(
db: AsyncSession,
event_type: str,
) -> list[Webhook]:
"""Получить все активные webhooks для конкретного события."""
result = await db.execute(
select(Webhook)
.where(Webhook.event_type == event_type)
.where(Webhook.is_active == True)
)
return list(result.scalars().all())
async def update_webhook(
db: AsyncSession,
webhook: Webhook,
name: Optional[str] = None,
url: Optional[str] = None,
secret: Optional[str] = None,
description: Optional[str] = None,
is_active: Optional[bool] = None,
) -> Webhook:
"""Обновить webhook."""
if name is not None:
webhook.name = name
if url is not None:
webhook.url = url
if secret is not None:
webhook.secret = secret
if description is not None:
webhook.description = description
if is_active is not None:
webhook.is_active = is_active
webhook.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(webhook)
return webhook
async def delete_webhook(db: AsyncSession, webhook: Webhook) -> None:
"""Удалить webhook."""
await db.delete(webhook)
await db.commit()
async def record_webhook_delivery(
db: AsyncSession,
webhook_id: int,
event_type: str,
payload: dict,
status: str,
response_status: Optional[int] = None,
response_body: Optional[str] = None,
error_message: Optional[str] = None,
attempt_number: int = 1,
) -> WebhookDelivery:
"""Записать попытку доставки webhook."""
delivery = WebhookDelivery(
webhook_id=webhook_id,
event_type=event_type,
payload=payload,
status=status,
response_status=response_status,
response_body=response_body,
error_message=error_message,
attempt_number=attempt_number,
delivered_at=datetime.utcnow() if status == "success" else None,
)
db.add(delivery)
await db.commit()
await db.refresh(delivery)
return delivery
async def update_webhook_stats(
db: AsyncSession,
webhook: Webhook,
success: bool,
) -> Webhook:
"""Обновить статистику webhook."""
if success:
webhook.success_count += 1
else:
webhook.failure_count += 1
webhook.last_triggered_at = datetime.utcnow()
await db.commit()
await db.refresh(webhook)
return webhook
+40 -3
View File
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import (
)
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
from sqlalchemy import event, text, bindparam, inspect
from sqlalchemy.exc import ProgrammingError
from sqlalchemy.engine import Engine
from sqlalchemy.exc import OperationalError, InterfaceError
import time
@@ -417,10 +418,46 @@ batch_ops = BatchOperations()
async def init_db():
"""Инициализация БД с оптимизациями"""
logger.info("Создание таблиц базы данных...")
logger.info("🚀 Создание таблиц базы данных...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
try:
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, checkfirst=True))
except (ProgrammingError, Exception) as e:
# Игнорируем ошибки дублирования индексов/таблиц - они уже существуют
# Это может произойти если таблицы были созданы вручную или через миграции
error_str = str(e).lower()
error_type = type(e).__name__.lower()
# Проверяем оригинальную ошибку для asyncpg
orig_error = getattr(e, "orig", None)
if orig_error:
orig_type = type(orig_error).__name__.lower()
if "duplicatetableerror" in orig_type or "duplicatekeyerror" in orig_type:
logger.warning(
"⚠️ Некоторые индексы/таблицы уже существуют в БД, это нормально. "
"Продолжаем инициализацию..."
)
return
# Проверяем, является ли это ошибкой дублирования
is_duplicate_error = (
"already exists" in error_str
or "duplicate" in error_str
or "duplicatetableerror" in error_type
or "duplicatekeyerror" in error_type
)
if is_duplicate_error:
logger.warning(
"⚠️ Некоторые объекты БД уже существуют (таблицы/индексы), это нормально. "
"Продолжаем инициализацию..."
)
# Продолжаем выполнение, так как основные таблицы могут быть созданы
else:
# Для других ошибок пробрасываем исключение
logger.error(f"❌ Ошибка при создании таблиц: {e}")
raise
if not IS_SQLITE:
logger.info("Создание индексов для оптимизации...")
+271 -2
View File
@@ -46,6 +46,25 @@ server_squad_promo_groups = Table(
)
# M2M таблица для связи тарифов с промогруппами (доступ к тарифу)
tariff_promo_groups = Table(
"tariff_promo_groups",
Base.metadata,
Column(
"tariff_id",
Integer,
ForeignKey("tariffs.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"promo_group_id",
Integer,
ForeignKey("promo_groups.id", ondelete="CASCADE"),
primary_key=True,
),
)
class UserStatus(Enum):
ACTIVE = "active"
BLOCKED = "blocked"
@@ -87,6 +106,7 @@ class PaymentMethod(Enum):
WATA = "wata"
PLATEGA = "platega"
CLOUDPAYMENTS = "cloudpayments"
FREEKASSA = "freekassa"
MANUAL = "manual"
@@ -545,6 +565,73 @@ class CloudPaymentsPayment(Base):
)
class FreekassaPayment(Base):
__tablename__ = "freekassa_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа
freekassa_order_id = Column(String(64), unique=True, nullable=True, index=True) # intid от Freekassa
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default="pending") # pending, success, failed, expired
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_system_id = Column(Integer, nullable=True) # ID платежной системы FK
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(DateTime, nullable=True)
expires_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
# Relationships
user = relationship("User", backref="freekassa_payments")
transaction = relationship("Transaction", backref="freekassa_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == "pending"
@property
def is_success(self) -> bool:
return self.status == "success" and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ["failed", "expired"]
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<FreekassaPayment(id={0}, order_id={1}, amount={2}₽, status={3})>".format(
self.id,
self.order_id,
self.amount_rubles,
self.status,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"
@@ -646,6 +733,82 @@ class UserPromoGroup(Base):
return f"<UserPromoGroup(user_id={self.user_id}, promo_group_id={self.promo_group_id}, assigned_by='{self.assigned_by}')>"
class Tariff(Base):
"""Тарифный план для режима продаж 'Тарифы'."""
__tablename__ = "tariffs"
id = Column(Integer, primary_key=True, index=True)
# Основная информация
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
display_order = Column(Integer, default=0, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
# Параметры тарифа
traffic_limit_gb = Column(Integer, nullable=False, default=100) # 0 = безлимит
device_limit = Column(Integer, nullable=False, default=1)
device_price_kopeks = Column(Integer, nullable=True, default=None) # Цена за доп. устройство (None = нельзя докупить)
# Сквады (серверы) доступные в тарифе
allowed_squads = Column(JSON, default=list) # список UUID сквадов
# Цены на периоды в копейках (JSON: {"14": 30000, "30": 50000, "90": 120000, ...})
period_prices = Column(JSON, nullable=False, default=dict)
# Уровень тарифа (для визуального отображения, 1 = базовый)
tier_level = Column(Integer, default=1, nullable=False)
# Дополнительные настройки
is_trial_available = Column(Boolean, default=False, nullable=False) # Можно ли взять триал на этом тарифе
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
# M2M связь с промогруппами (какие промогруппы имеют доступ к тарифу)
allowed_promo_groups = relationship(
"PromoGroup",
secondary=tariff_promo_groups,
lazy="selectin",
)
# Подписки на этом тарифе
subscriptions = relationship("Subscription", back_populates="tariff")
@property
def is_unlimited_traffic(self) -> bool:
"""Проверяет, безлимитный ли трафик."""
return self.traffic_limit_gb == 0
def get_price_for_period(self, period_days: int) -> Optional[int]:
"""Возвращает цену в копейках для указанного периода."""
prices = self.period_prices or {}
return prices.get(str(period_days))
def get_available_periods(self) -> List[int]:
"""Возвращает список доступных периодов в днях."""
prices = self.period_prices or {}
return sorted([int(p) for p in prices.keys()])
def get_price_rubles(self, period_days: int) -> Optional[float]:
"""Возвращает цену в рублях для указанного периода."""
price_kopeks = self.get_price_for_period(period_days)
if price_kopeks is not None:
return price_kopeks / 100
return None
def is_available_for_promo_group(self, promo_group_id: Optional[int]) -> bool:
"""Проверяет, доступен ли тариф для указанной промогруппы."""
if not self.allowed_promo_groups:
return True # Если нет ограничений - доступен всем
if promo_group_id is None:
return True # Если у пользователя нет группы - доступен
return any(pg.id == promo_group_id for pg in self.allowed_promo_groups)
def __repr__(self):
return f"<Tariff(id={self.id}, name='{self.name}', tier={self.tier_level}, active={self.is_active})>"
class User(Base):
__tablename__ = "users"
@@ -657,7 +820,7 @@ class User(Base):
status = Column(String(20), default=UserStatus.ACTIVE.value)
language = Column(String(5), default="ru")
balance_kopeks = Column(Integer, default=0)
used_promocodes = Column(Integer, default=0)
used_promocodes = Column(Integer, default=0)
has_had_paid_subscription = Column(Boolean, default=False, nullable=False)
referred_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
referral_code = Column(String(20), unique=True, nullable=True)
@@ -665,6 +828,17 @@ class User(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
last_activity = Column(DateTime, default=func.now())
remnawave_uuid = Column(String(255), nullable=True, unique=True)
# Cabinet authentication fields
email = Column(String(255), unique=True, nullable=True, index=True)
email_verified = Column(Boolean, default=False, nullable=False)
email_verified_at = Column(DateTime, nullable=True)
password_hash = Column(String(255), nullable=True)
email_verification_token = Column(String(255), nullable=True)
email_verification_expires = Column(DateTime, nullable=True)
password_reset_token = Column(String(255), nullable=True)
password_reset_expires = Column(DateTime, nullable=True)
cabinet_last_login = Column(DateTime, nullable=True)
broadcasts = relationship("BroadcastHistory", back_populates="admin")
referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id")
subscription = relationship("Subscription", back_populates="user", uselist=False)
@@ -688,6 +862,7 @@ class User(Base):
promo_group = relationship("PromoGroup", back_populates="users")
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
poll_responses = relationship("PollResponse", back_populates="user")
notification_settings = Column(JSON, nullable=True, default=dict)
last_pinned_message_id = Column(Integer, nullable=True)
# Ограничения пользователя
@@ -780,10 +955,14 @@ class Subscription(Base):
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
remnawave_short_uuid = Column(String(255), nullable=True)
# Тариф (для режима продаж "Тарифы")
tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"), nullable=True, index=True)
user = relationship("User", back_populates="subscription")
tariff = relationship("Tariff", back_populates="subscriptions")
discount_offers = relationship("DiscountOffer", back_populates="subscription")
temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription")
@@ -993,6 +1172,7 @@ class PromoCode(Base):
valid_until = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True)
first_purchase_only = Column(Boolean, default=False) # Только для первой покупки
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="SET NULL"), nullable=True, index=True)
@@ -1939,3 +2119,92 @@ class ButtonClickLog(Base):
def __repr__(self) -> str:
return f"<ButtonClickLog id={self.id} button='{self.button_id}' user={self.user_id} at={self.clicked_at}>"
class Webhook(Base):
"""Webhook конфигурация для подписки на события."""
__tablename__ = "webhooks"
__table_args__ = (
Index("ix_webhooks_event_type", "event_type"),
Index("ix_webhooks_is_active", "is_active"),
)
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
url = Column(Text, nullable=False)
secret = Column(String(128), nullable=True) # Секрет для подписи payload
event_type = Column(String(50), nullable=False) # user.created, payment.completed, ticket.created, etc.
is_active = Column(Boolean, default=True, nullable=False)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
last_triggered_at = Column(DateTime, nullable=True)
failure_count = Column(Integer, default=0, nullable=False)
success_count = Column(Integer, default=0, nullable=False)
deliveries = relationship("WebhookDelivery", back_populates="webhook", cascade="all, delete-orphan")
def __repr__(self) -> str:
status = "active" if self.is_active else "inactive"
return f"<Webhook id={self.id} name='{self.name}' event='{self.event_type}' status={status}>"
class WebhookDelivery(Base):
"""История доставки webhooks."""
__tablename__ = "webhook_deliveries"
__table_args__ = (
Index("ix_webhook_deliveries_webhook_created", "webhook_id", "created_at"),
Index("ix_webhook_deliveries_status", "status"),
)
id = Column(Integer, primary_key=True, index=True)
webhook_id = Column(Integer, ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False)
event_type = Column(String(50), nullable=False)
payload = Column(JSON, nullable=False) # Отправленный payload
response_status = Column(Integer, nullable=True) # HTTP статус ответа
response_body = Column(Text, nullable=True) # Тело ответа (может быть обрезано)
status = Column(String(20), nullable=False) # pending, success, failed
error_message = Column(Text, nullable=True)
attempt_number = Column(Integer, default=1, nullable=False)
created_at = Column(DateTime, default=func.now())
delivered_at = Column(DateTime, nullable=True)
next_retry_at = Column(DateTime, nullable=True)
webhook = relationship("Webhook", back_populates="deliveries")
def __repr__(self) -> str:
return f"<WebhookDelivery id={self.id} webhook_id={self.webhook_id} status='{self.status}' event='{self.event_type}'>"
class CabinetRefreshToken(Base):
"""Refresh tokens for cabinet JWT authentication."""
__tablename__ = "cabinet_refresh_tokens"
__table_args__ = (
Index("ix_cabinet_refresh_tokens_user", "user_id"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
token_hash = Column(String(255), unique=True, nullable=False, index=True)
device_info = Column(String(500), nullable=True)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=func.now())
revoked_at = Column(DateTime, nullable=True)
user = relationship("User", backref="cabinet_tokens")
@property
def is_expired(self) -> bool:
return datetime.utcnow() > self.expires_at
@property
def is_revoked(self) -> bool:
return self.revoked_at is not None
@property
def is_valid(self) -> bool:
return not self.is_expired and not self.is_revoked
def __repr__(self) -> str:
status = "valid" if self.is_valid else ("revoked" if self.is_revoked else "expired")
return f"<CabinetRefreshToken id={self.id} user_id={self.user_id} status={status}>"
+476
View File
@@ -1289,6 +1289,118 @@ async def ensure_wata_payment_schema() -> bool:
return False
async def create_freekassa_payments_table():
"""Создаёт таблицу freekassa_payments для платежей через Freekassa."""
table_exists = await check_table_exists('freekassa_payments')
if table_exists:
logger.info("Таблица freekassa_payments уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE freekassa_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
order_id VARCHAR(64) NOT NULL UNIQUE,
freekassa_order_id VARCHAR(64) NULL UNIQUE,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
is_paid BOOLEAN NOT NULL DEFAULT 0,
payment_url TEXT NULL,
payment_system_id INTEGER NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
paid_at DATETIME NULL,
expires_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
transaction_id INTEGER NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_freekassa_user_id ON freekassa_payments(user_id);
CREATE UNIQUE INDEX idx_freekassa_order_id ON freekassa_payments(order_id);
CREATE UNIQUE INDEX idx_freekassa_fk_order_id ON freekassa_payments(freekassa_order_id);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE freekassa_payments (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
order_id VARCHAR(64) NOT NULL UNIQUE,
freekassa_order_id VARCHAR(64) NULL UNIQUE,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
is_paid BOOLEAN NOT NULL DEFAULT FALSE,
payment_url TEXT NULL,
payment_system_id INTEGER NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
paid_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
transaction_id INTEGER NULL REFERENCES transactions(id)
);
CREATE INDEX idx_freekassa_user_id ON freekassa_payments(user_id);
CREATE UNIQUE INDEX idx_freekassa_order_id ON freekassa_payments(order_id);
CREATE UNIQUE INDEX idx_freekassa_fk_order_id ON freekassa_payments(freekassa_order_id);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE freekassa_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
order_id VARCHAR(64) NOT NULL UNIQUE,
freekassa_order_id VARCHAR(64) NULL UNIQUE,
amount_kopeks INT NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
is_paid BOOLEAN NOT NULL DEFAULT 0,
payment_url TEXT NULL,
payment_system_id INT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
paid_at DATETIME NULL,
expires_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
transaction_id INT NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_freekassa_user_id ON freekassa_payments(user_id);
CREATE UNIQUE INDEX idx_freekassa_order_id ON freekassa_payments(order_id);
CREATE UNIQUE INDEX idx_freekassa_fk_order_id ON freekassa_payments(freekassa_order_id);
"""
else:
logger.error(f"Неподдерживаемый тип БД для таблицы freekassa_payments: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("Таблица freekassa_payments успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы freekassa_payments: {e}")
return False
async def create_discount_offers_table():
table_exists = await check_table_exists('discount_offers')
if table_exists:
@@ -1947,6 +2059,37 @@ async def ensure_user_promo_offer_discount_columns():
return False
async def ensure_user_notification_settings_column() -> bool:
"""Ensure notification_settings column exists in users table."""
try:
column_exists = await check_column_exists('users', 'notification_settings')
if column_exists:
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
column_def = 'TEXT NULL'
elif db_type == 'postgresql':
column_def = 'JSONB NULL'
elif db_type == 'mysql':
column_def = 'JSON NULL'
else:
column_def = 'TEXT NULL'
await conn.execute(text(
f"ALTER TABLE users ADD COLUMN notification_settings {column_def}"
))
logger.info("✅ Колонка notification_settings для users добавлена")
return True
except Exception as e:
logger.error(f"Ошибка добавления колонки notification_settings: {e}")
return False
async def ensure_promo_offer_template_active_duration_column() -> bool:
try:
column_exists = await check_column_exists('promo_offer_templates', 'active_discount_hours')
@@ -3351,6 +3494,54 @@ async def add_user_restriction_columns() -> bool:
return False
async def add_user_cabinet_columns() -> bool:
"""Add cabinet (personal account) columns to users table."""
cabinet_columns = [
("email", "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)"),
("email_verified", "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE", "TINYINT(1) DEFAULT 0"),
("email_verified_at", "DATETIME", "TIMESTAMP", "DATETIME"),
("password_hash", "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)"),
("email_verification_token", "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)"),
("email_verification_expires", "DATETIME", "TIMESTAMP", "DATETIME"),
("password_reset_token", "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)"),
("password_reset_expires", "DATETIME", "TIMESTAMP", "DATETIME"),
("cabinet_last_login", "DATETIME", "TIMESTAMP", "DATETIME"),
]
try:
db_type = await get_database_type()
added_count = 0
for col_name, sqlite_type, pg_type, mysql_type in cabinet_columns:
if await check_column_exists('users', col_name):
continue
async with engine.begin() as conn:
if db_type == 'sqlite':
col_type = sqlite_type
elif db_type == 'postgresql':
col_type = pg_type
else:
col_type = mysql_type
await conn.execute(
text(f"ALTER TABLE users ADD COLUMN {col_name} {col_type}")
)
added_count += 1
logger.info(f"✅ Добавлена колонка users.{col_name}")
if added_count == 0:
logger.info("ℹ️ Все колонки cabinet уже существуют в таблице users")
else:
logger.info(f"✅ Добавлено {added_count} колонок cabinet в таблицу users")
return True
except Exception as e:
logger.error(f"Ошибка добавления колонок cabinet: {e}")
return False
async def add_subscription_crypto_link_column() -> bool:
column_exists = await check_column_exists('subscriptions', 'subscription_crypto_link')
if column_exists:
@@ -4630,6 +4821,38 @@ async def add_promocode_promo_group_column() -> bool:
return False
async def add_promocode_first_purchase_only_column() -> bool:
"""Добавляет колонку first_purchase_only в таблицу promocodes."""
column_exists = await check_column_exists('promocodes', 'first_purchase_only')
if column_exists:
logger.info("Колонка first_purchase_only уже существует в promocodes")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(
text("ALTER TABLE promocodes ADD COLUMN first_purchase_only BOOLEAN DEFAULT 0")
)
elif db_type == 'postgresql':
await conn.execute(
text("ALTER TABLE promocodes ADD COLUMN first_purchase_only BOOLEAN DEFAULT FALSE")
)
elif db_type == 'mysql':
await conn.execute(
text("ALTER TABLE promocodes ADD COLUMN first_purchase_only BOOLEAN DEFAULT FALSE")
)
logger.info("✅ Добавлена колонка first_purchase_only в promocodes")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления first_purchase_only в promocodes: {error}")
return False
async def migrate_contest_templates_prize_columns() -> bool:
"""Миграция contest_templates: prize_days -> prize_type + prize_value."""
try:
@@ -4826,6 +5049,203 @@ async def add_transaction_receipt_columns() -> bool:
return False
# =============================================================================
# МИГРАЦИИ ДЛЯ РЕЖИМА ТАРИФОВ
# =============================================================================
async def create_tariffs_table() -> bool:
"""Создаёт таблицу тарифов для режима продаж 'Тарифы'."""
try:
if await check_table_exists('tariffs'):
logger.info("ℹ️ Таблица tariffs уже существует")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text("""
CREATE TABLE tariffs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
description TEXT,
display_order INTEGER DEFAULT 0 NOT NULL,
is_active BOOLEAN DEFAULT 1 NOT NULL,
traffic_limit_gb INTEGER DEFAULT 100 NOT NULL,
device_limit INTEGER DEFAULT 1 NOT NULL,
allowed_squads JSON DEFAULT '[]',
period_prices JSON DEFAULT '{}' NOT NULL,
tier_level INTEGER DEFAULT 1 NOT NULL,
is_trial_available BOOLEAN DEFAULT 0 NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""))
elif db_type == 'postgresql':
await conn.execute(text("""
CREATE TABLE tariffs (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
display_order INTEGER DEFAULT 0 NOT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
traffic_limit_gb INTEGER DEFAULT 100 NOT NULL,
device_limit INTEGER DEFAULT 1 NOT NULL,
allowed_squads JSON DEFAULT '[]',
period_prices JSON DEFAULT '{}' NOT NULL,
tier_level INTEGER DEFAULT 1 NOT NULL,
is_trial_available BOOLEAN DEFAULT FALSE NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
"""))
else: # MySQL
await conn.execute(text("""
CREATE TABLE tariffs (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
display_order INT DEFAULT 0 NOT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
traffic_limit_gb INT DEFAULT 100 NOT NULL,
device_limit INT DEFAULT 1 NOT NULL,
allowed_squads JSON DEFAULT (JSON_ARRAY()),
period_prices JSON NOT NULL,
tier_level INT DEFAULT 1 NOT NULL,
is_trial_available BOOLEAN DEFAULT FALSE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
"""))
logger.info("✅ Таблица tariffs создана")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы tariffs: {error}")
return False
async def create_tariff_promo_groups_table() -> bool:
"""Создаёт связующую таблицу tariff_promo_groups для M2M связи тарифов и промогрупп."""
try:
if await check_table_exists('tariff_promo_groups'):
logger.info("️ Таблица tariff_promo_groups уже существует")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text("""
CREATE TABLE tariff_promo_groups (
tariff_id INTEGER NOT NULL,
promo_group_id INTEGER NOT NULL,
PRIMARY KEY (tariff_id, promo_group_id),
FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE CASCADE,
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
)
"""))
elif db_type == 'postgresql':
await conn.execute(text("""
CREATE TABLE tariff_promo_groups (
tariff_id INTEGER NOT NULL REFERENCES tariffs(id) ON DELETE CASCADE,
promo_group_id INTEGER NOT NULL REFERENCES promo_groups(id) ON DELETE CASCADE,
PRIMARY KEY (tariff_id, promo_group_id)
)
"""))
else: # MySQL
await conn.execute(text("""
CREATE TABLE tariff_promo_groups (
tariff_id INT NOT NULL,
promo_group_id INT NOT NULL,
PRIMARY KEY (tariff_id, promo_group_id),
FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE CASCADE,
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
)
"""))
logger.info("✅ Таблица tariff_promo_groups создана")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы tariff_promo_groups: {error}")
return False
async def add_subscription_tariff_id_column() -> bool:
"""Добавляет колонку tariff_id в таблицу subscriptions."""
try:
if await check_column_exists('subscriptions', 'tariff_id'):
logger.info("️ Колонка tariff_id уже существует в subscriptions")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id)"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id) ON DELETE SET NULL"
))
# Создаём индекс
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_subscriptions_tariff_id ON subscriptions(tariff_id)"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN tariff_id INT NULL"
))
await conn.execute(text(
"ALTER TABLE subscriptions ADD CONSTRAINT fk_subscriptions_tariff "
"FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE SET NULL"
))
await conn.execute(text(
"CREATE INDEX ix_subscriptions_tariff_id ON subscriptions(tariff_id)"
))
logger.info("✅ Колонка tariff_id добавлена в subscriptions")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонки tariff_id: {error}")
return False
async def add_tariff_device_price_column() -> bool:
"""Добавляет колонку device_price_kopeks в таблицу tariffs."""
try:
if await check_column_exists('tariffs', 'device_price_kopeks'):
logger.info("️ Колонка device_price_kopeks уже существует в tariffs")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN device_price_kopeks INTEGER DEFAULT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN device_price_kopeks INTEGER DEFAULT NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN device_price_kopeks INT DEFAULT NULL"
))
logger.info("✅ Колонка device_price_kopeks добавлена в tariffs")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонки device_price_kopeks: {error}")
return False
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -4969,6 +5389,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Не удалось обновить схему Wata payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ FREEKASSA ===")
freekassa_created = await create_freekassa_payments_table()
if freekassa_created:
logger.info("✅ Таблица Freekassa payments готова")
else:
logger.warning("⚠️ Проблемы с таблицей Freekassa payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===")
discount_created = await create_discount_offers_table()
if discount_created:
@@ -5038,6 +5465,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Не удалось обновить пользовательские промо-скидки")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ NOTIFICATION_SETTINGS ===")
notification_settings_ready = await ensure_user_notification_settings_column()
if notification_settings_ready:
logger.info("✅ Колонка notification_settings готова")
else:
logger.warning("⚠️ Не удалось добавить колонку notification_settings")
effect_types_updated = await migrate_discount_offer_effect_types()
if effect_types_updated:
logger.info("✅ Типы эффектов промо-предложений обновлены")
@@ -5085,6 +5519,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с добавлением promo_group_id в promocodes")
logger.info("=== ДОБАВЛЕНИЕ FIRST_PURCHASE_ONLY В PROMOCODES ===")
first_purchase_ready = await add_promocode_first_purchase_only_column()
if first_purchase_ready:
logger.info("✅ Колонка first_purchase_only в promocodes готова")
else:
logger.warning("⚠️ Проблемы с добавлением first_purchase_only в promocodes")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MAIN_MENU_BUTTONS ===")
main_menu_buttons_created = await create_main_menu_buttons_table()
if main_menu_buttons_created:
@@ -5196,6 +5637,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с добавлением колонок ограничений пользователей")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ЛИЧНОГО КАБИНЕТА ===")
cabinet_added = await add_user_cabinet_columns()
if cabinet_added:
logger.info("✅ Колонки личного кабинета готовы")
else:
logger.warning("⚠️ Проблемы с добавлением колонок личного кабинета")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===")
try:
async with engine.begin() as conn:
@@ -5275,6 +5723,31 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с настройкой доступа серверов к промогруппам")
logger.info("=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕЖИМА ТАРИФОВ ===")
tariffs_table_ready = await create_tariffs_table()
if tariffs_table_ready:
logger.info("✅ Таблица tariffs готова")
else:
logger.warning("⚠️ Проблемы с таблицей tariffs")
tariff_promo_groups_ready = await create_tariff_promo_groups_table()
if tariff_promo_groups_ready:
logger.info("✅ Таблица tariff_promo_groups готова")
else:
logger.warning("⚠️ Проблемы с таблицей tariff_promo_groups")
tariff_id_column_ready = await add_subscription_tariff_id_column()
if tariff_id_column_ready:
logger.info("✅ Колонка tariff_id в subscriptions готова")
else:
logger.warning("⚠️ Проблемы с колонкой tariff_id в subscriptions")
device_price_column_ready = await add_tariff_device_price_column()
if device_price_column_ready:
logger.info("✅ Колонка device_price_kopeks в tariffs готова")
else:
logger.warning("⚠️ Проблемы с колонкой device_price_kopeks в tariffs")
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
fk_updated = await fix_foreign_keys_for_user_deletion()
if fk_updated:
@@ -5384,6 +5857,7 @@ async def check_migration_status():
"users_promo_offer_discount_source_column": False,
"users_promo_offer_discount_expires_column": False,
"users_referral_commission_percent_column": False,
"users_notification_settings_column": False,
"subscription_crypto_link_column": False,
"subscription_modem_enabled_column": False,
"subscription_purchased_traffic_column": False,
@@ -5451,6 +5925,7 @@ async def check_migration_status():
status["users_promo_offer_discount_source_column"] = await check_column_exists('users', 'promo_offer_discount_source')
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent')
status["users_notification_settings_column"] = await check_column_exists('users', 'notification_settings')
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
status["subscription_modem_enabled_column"] = await check_column_exists('subscriptions', 'modem_enabled')
status["subscription_purchased_traffic_column"] = await check_column_exists('subscriptions', 'purchased_traffic_gb')
@@ -5534,6 +6009,7 @@ async def check_migration_status():
"users_promo_offer_discount_source_column": "Колонка источника промо-скидки у пользователей",
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
"users_notification_settings_column": "Колонка notification_settings у пользователей",
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
"subscription_modem_enabled_column": "Колонка modem_enabled в subscriptions",
"subscription_purchased_traffic_column": "Колонка purchased_traffic_gb в subscriptions",
+29 -1
View File
@@ -538,10 +538,25 @@ class RemnaWaveAPI:
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def revoke_user_subscription(self, uuid: str, new_short_uuid: Optional[str] = None) -> RemnaWaveUser:
async def revoke_user_subscription(
self,
uuid: str,
new_short_uuid: Optional[str] = None,
revoke_only_passwords: bool = False
) -> RemnaWaveUser:
"""
Отзывает подписку пользователя (меняет ссылку/пароли).
Args:
uuid: UUID пользователя
new_short_uuid: Новый короткий UUID (опционально, рекомендуется генерировать автоматически)
revoke_only_passwords: Если True, меняются только пароли без изменения URL подписки
"""
data = {}
if new_short_uuid:
data['shortUuid'] = new_short_uuid
if revoke_only_passwords:
data['revokeOnlyPasswords'] = True
response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data)
user = self._parse_user(response['response'])
@@ -809,6 +824,19 @@ class RemnaWaveAPI:
async def get_system_stats(self) -> Dict[str, Any]:
response = await self._make_request('GET', '/api/system/stats')
return response['response']
async def get_system_metadata(self) -> Dict[str, Any]:
"""
Получает метаданные системы Remnawave.
Returns:
Dict с полями:
- version: версия Remnawave
- build: {time, number} - информация о сборке
- git: {backend: {commitSha}, node: {commitSha}} - информация о коммитах
"""
response = await self._make_request('GET', '/api/system/metadata')
return response['response']
async def get_bandwidth_stats(self) -> Dict[str, Any]:
response = await self._make_request('GET', '/api/system/stats/bandwidth')
+84 -3
View File
@@ -36,7 +36,10 @@ class WebhookServer:
if settings.is_cryptobot_enabled():
self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler)
if settings.is_freekassa_enabled():
self.app.router.add_post(settings.FREEKASSA_WEBHOOK_PATH, self._freekassa_webhook_handler)
self.app.router.add_get('/health', self._health_check)
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
@@ -44,7 +47,9 @@ class WebhookServer:
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
if settings.is_cryptobot_enabled():
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
if settings.is_freekassa_enabled():
self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler)
logger.info(f"Webhook сервер настроен:")
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
if settings.is_mulenpay_enabled():
@@ -56,6 +61,8 @@ class WebhookServer:
)
if settings.is_cryptobot_enabled():
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
if settings.is_freekassa_enabled():
logger.info(f" - Freekassa webhook: POST {settings.FREEKASSA_WEBHOOK_PATH}")
logger.info(f" - Health check: GET /health")
return self.app
@@ -446,7 +453,81 @@ class WebhookServer:
"service": "payment-webhooks",
"tribute_enabled": settings.TRIBUTE_ENABLED,
"cryptobot_enabled": settings.is_cryptobot_enabled(),
"freekassa_enabled": settings.is_freekassa_enabled(),
"port": settings.TRIBUTE_WEBHOOK_PORT,
"tribute_path": settings.TRIBUTE_WEBHOOK_PATH,
"cryptobot_path": settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None
"cryptobot_path": settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None,
"freekassa_path": settings.FREEKASSA_WEBHOOK_PATH if settings.is_freekassa_enabled() else None,
})
async def _freekassa_webhook_handler(self, request: web.Request) -> web.Response:
"""
Обработчик webhook от Freekassa.
Freekassa отправляет POST запрос с form-data:
- MERCHANT_ID: ID магазина
- AMOUNT: Сумма платежа
- MERCHANT_ORDER_ID: Наш order_id
- SIGN: Подпись MD5(shop_id:amount:secret2:order_id)
- intid: ID транзакции Freekassa
- CUR_ID: ID валюты/платежной системы
"""
try:
logger.info(f"Получен Freekassa webhook: {request.method} {request.path}")
# Получаем IP клиента
client_ip = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
if not client_ip:
client_ip = request.remote or "unknown"
logger.info(f"Freekassa webhook IP: {client_ip}")
# Freekassa отправляет form-data
try:
form_data = await request.post()
except Exception as e:
logger.error(f"Ошибка парсинга Freekassa form-data: {e}")
return web.Response(text="NO", status=400)
logger.info(f"Freekassa webhook data: {dict(form_data)}")
# Извлекаем параметры
merchant_id = int(form_data.get("MERCHANT_ID", 0))
amount = float(form_data.get("AMOUNT", 0))
order_id = form_data.get("MERCHANT_ORDER_ID", "")
sign = form_data.get("SIGN", "")
intid = form_data.get("intid", "")
cur_id = form_data.get("CUR_ID")
if not order_id or not sign:
logger.warning("Freekassa webhook: отсутствуют обязательные параметры")
return web.Response(text="NO", status=400)
# Обрабатываем платеж через PaymentService
from app.services.payment_service import PaymentService
from app.database.database import AsyncSessionLocal
payment_service = PaymentService(self.bot)
async with AsyncSessionLocal() as db:
success = await payment_service.process_freekassa_webhook(
db=db,
merchant_id=merchant_id,
amount=amount,
order_id=order_id,
sign=sign,
intid=intid,
cur_id=int(cur_id) if cur_id else None,
client_ip=client_ip,
)
if success:
logger.info(f"Freekassa webhook обработан успешно: order_id={order_id}")
# Freekassa ожидает YES в ответе
return web.Response(text="YES", status=200)
else:
logger.error(f"Ошибка обработки Freekassa webhook: order_id={order_id}")
return web.Response(text="NO", status=400)
except Exception as e:
logger.error(f"Критическая ошибка обработки Freekassa webhook: {e}", exc_info=True)
return web.Response(text="NO", status=500)
+47 -1
View File
@@ -61,7 +61,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"payments": {
"title": "💳 Платежные системы",
"description": "YooKassa, CryptoBot, Heleket, CloudPayments, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
"description": "YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
"icon": "💳",
"categories": (
"PAYMENT",
@@ -70,6 +70,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"CRYPTOBOT",
"HELEKET",
"CLOUDPAYMENTS",
"FREEKASSA",
"MULENPAY",
"PAL24",
"WATA",
@@ -257,6 +258,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
"CryptoBot": settings.is_cryptobot_enabled(),
"Platega": settings.is_platega_enabled(),
"CloudPayments": settings.is_cloudpayments_enabled(),
"Freekassa": settings.is_freekassa_enabled(),
"MulenPay": settings.is_mulenpay_enabled(),
"PAL24": settings.is_pal24_enabled(),
"Tribute": settings.TRIBUTE_ENABLED,
@@ -1334,6 +1336,9 @@ def _build_settings_keyboard(
elif category_key == "CRYPTOBOT":
label = texts.t("PAYMENT_CRYPTOBOT", "🪙 Криптовалюта (CryptoBot)")
test_payment_buttons.append([_test_button(f"{label} · тест", "cryptobot")])
elif category_key == "FREEKASSA":
label = texts.t("PAYMENT_FREEKASSA", "💳 Freekassa")
test_payment_buttons.append([_test_button(f"{label} · тест", "freekassa")])
if test_payment_buttons:
rows.extend(test_payment_buttons)
@@ -2331,6 +2336,47 @@ async def test_payment_provider(
await _refresh_markup()
return
if method == "freekassa":
if not settings.is_freekassa_enabled():
await callback.answer("❌ Freekassa отключена", show_alert=True)
return
amount_kopeks = settings.FREEKASSA_MIN_AMOUNT_KOPEKS
payment_result = await payment_service.create_freekassa_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description="Тестовый платеж Freekassa (админ)",
email=getattr(db_user, "email", None),
language=db_user.language or settings.DEFAULT_LANGUAGE,
)
if not payment_result or not payment_result.get("payment_url"):
await callback.answer("❌ Не удалось создать тестовый платеж Freekassa", show_alert=True)
await _refresh_markup()
return
payment_url = payment_result["payment_url"]
message_text = (
"🧪 <b>Тестовый платеж Freekassa</b>\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"🆔 Order ID: {payment_result['order_id']}"
)
reply_markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="💳 Перейти к оплате",
url=payment_url,
)
]
]
)
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer("✅ Ссылка на платеж Freekassa отправлена", show_alert=True)
await _refresh_markup()
return
await callback.answer("❌ Неизвестный способ тестирования платежа", show_alert=True)
await _refresh_markup()
+151 -1
View File
@@ -919,6 +919,8 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
queue_len = nalogo_status.get("queue_length", 0)
total_amount = nalogo_status.get("total_amount", 0)
running = nalogo_status.get("running", False)
pending_count = nalogo_status.get("pending_verification_count", 0)
pending_amount = nalogo_status.get("pending_verification_amount", 0)
nalogo_section = f"""
🧾 <b>Чеки NaloGO:</b>
@@ -926,6 +928,8 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
В очереди: {queue_len} чек(ов)"""
if queue_len > 0:
nalogo_section += f"\n• На сумму: {total_amount:,.2f}"
if pending_count > 0:
nalogo_section += f"\n⚠️ <b>Требуют проверки: {pending_count} ({pending_amount:,.2f} ₽)</b>"
text += nalogo_section
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
@@ -940,6 +944,12 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
text=f"🧾 Отправить ({nalogo_status['queue_length']})",
callback_data="admin_mon_nalogo_force_process"
))
pending_count = nalogo_status.get("pending_verification_count", 0)
if pending_count > 0:
nalogo_buttons.append(InlineKeyboardButton(
text=f"⚠️ Проверить ({pending_count})",
callback_data="admin_mon_nalogo_pending"
))
nalogo_buttons.append(InlineKeyboardButton(
text="📊 Сверка чеков",
callback_data="admin_mon_receipts_missing"
@@ -948,7 +958,7 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
@@ -1068,6 +1078,146 @@ async def nalogo_force_process_callback(callback: CallbackQuery):
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_nalogo_pending")
@admin_required
async def nalogo_pending_callback(callback: CallbackQuery):
"""Просмотр чеков ожидающих ручной проверки."""
try:
from app.services.nalogo_service import NaloGoService
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
nalogo_service = NaloGoService()
receipts = await nalogo_service.get_pending_verification_receipts()
if not receipts:
await callback.answer("✅ Нет чеков на проверку", show_alert=True)
return
text = f"⚠️ <b>Чеки требующие проверки: {len(receipts)}</b>\n\n"
text += "Проверьте в lknpd.nalog.ru созданы ли эти чеки.\n\n"
buttons = []
for i, receipt in enumerate(receipts[:10], 1):
payment_id = receipt.get("payment_id", "unknown")
amount = receipt.get("amount", 0)
created_at = receipt.get("created_at", "")[:16].replace("T", " ")
error = receipt.get("error", "")[:50]
text += f"<b>{i}. {amount:,.2f} ₽</b>\n"
text += f" 📅 {created_at}\n"
text += f" 🆔 <code>{payment_id[:20]}...</code>\n"
if error:
text += f"{error}\n"
text += "\n"
# Кнопки для каждого чека
buttons.append([
InlineKeyboardButton(
text=f"✅ Создан ({i})",
callback_data=f"admin_nalogo_verified:{payment_id[:30]}"
),
InlineKeyboardButton(
text=f"🔄 Отправить ({i})",
callback_data=f"admin_nalogo_retry:{payment_id[:30]}"
),
])
if len(receipts) > 10:
text += f"\n... и ещё {len(receipts) - 10} чек(ов)"
buttons.append([
InlineKeyboardButton(
text="🗑 Очистить всё (проверено)",
callback_data="admin_nalogo_clear_pending"
)
])
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")])
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка просмотра очереди проверки: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data.startswith("admin_nalogo_verified:"))
@admin_required
async def nalogo_mark_verified_callback(callback: CallbackQuery):
"""Пометить чек как созданный в налоговой."""
try:
from app.services.nalogo_service import NaloGoService
payment_id = callback.data.split(":", 1)[1]
nalogo_service = NaloGoService()
# Помечаем как проверенный (чек был создан)
removed = await nalogo_service.mark_pending_as_verified(
payment_id, receipt_uuid=None, was_created=True
)
if removed:
await callback.answer(f"✅ Чек помечен как созданный", show_alert=True)
# Обновляем список
await nalogo_pending_callback(callback)
else:
await callback.answer("❌ Чек не найден", show_alert=True)
except Exception as e:
logger.error(f"Ошибка пометки чека: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data.startswith("admin_nalogo_retry:"))
@admin_required
async def nalogo_retry_callback(callback: CallbackQuery):
"""Повторно отправить чек в налоговую."""
try:
from app.services.nalogo_service import NaloGoService
payment_id = callback.data.split(":", 1)[1]
nalogo_service = NaloGoService()
await callback.answer("🔄 Отправляю чек...", show_alert=False)
receipt_uuid = await nalogo_service.retry_pending_receipt(payment_id)
if receipt_uuid:
await callback.answer(f"✅ Чек создан: {receipt_uuid}", show_alert=True)
# Обновляем список
await nalogo_pending_callback(callback)
else:
await callback.answer("❌ Не удалось создать чек", show_alert=True)
except Exception as e:
logger.error(f"Ошибка повторной отправки чека: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_nalogo_clear_pending")
@admin_required
async def nalogo_clear_pending_callback(callback: CallbackQuery):
"""Очистить всю очередь проверки."""
try:
from app.services.nalogo_service import NaloGoService
nalogo_service = NaloGoService()
count = await nalogo_service.clear_pending_verification()
await callback.answer(f"✅ Очищено: {count} чек(ов)", show_alert=True)
# Возвращаемся на статистику
await callback.message.edit_text(
"✅ Очередь проверки очищена",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")]
])
)
except Exception as e:
logger.error(f"Ошибка очистки очереди: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_receipts_missing")
@admin_required
async def receipts_missing_callback(callback: CallbackQuery):
+60 -7
View File
@@ -130,6 +130,21 @@ async def show_promocodes_list(
await callback.answer()
@admin_required
@error_handler
async def show_promocodes_list_page(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Обработчик пагинации списка промокодов."""
try:
page = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
page = 1
await show_promocodes_list(callback, db_user, db, page=page)
@admin_required
@error_handler
async def show_promocode_management(
@@ -172,27 +187,39 @@ async def show_promocode_management(
if promo.valid_until:
text += f"⏰ <b>Действует до:</b> {format_datetime(promo.valid_until)}\n"
first_purchase_only = getattr(promo, 'first_purchase_only', False)
first_purchase_emoji = "" if first_purchase_only else ""
text += f"🆕 <b>Только первая покупка:</b> {first_purchase_emoji}\n"
text += f"📅 <b>Создан:</b> {format_datetime(promo.created_at)}\n"
first_purchase_btn_text = "🆕 Первая покупка: ✅" if first_purchase_only else "🆕 Первая покупка: ❌"
keyboard = [
[
types.InlineKeyboardButton(
text="✏️ Редактировать",
text="✏️ Редактировать",
callback_data=f"promo_edit_{promo.id}"
),
types.InlineKeyboardButton(
text="🔄 Переключить статус",
text="🔄 Переключить статус",
callback_data=f"promo_toggle_{promo.id}"
)
],
[
types.InlineKeyboardButton(
text="📊 Статистика",
text=first_purchase_btn_text,
callback_data=f"promo_toggle_first_{promo.id}"
)
],
[
types.InlineKeyboardButton(
text="📊 Статистика",
callback_data=f"promo_stats_{promo.id}"
),
types.InlineKeyboardButton(
text="🗑️ Удалить",
text="🗑️ Удалить",
callback_data=f"promo_delete_{promo.id}"
)
],
@@ -931,7 +958,31 @@ async def toggle_promocode_status(
status_text = "активирован" if new_status else "деактивирован"
await callback.answer(f"✅ Промокод {status_text}", show_alert=True)
await show_promocode_management(callback, db_user, db)
@admin_required
@error_handler
async def toggle_promocode_first_purchase(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Переключает режим 'только для первой покупки'."""
promo_id = int(callback.data.split('_')[-1])
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
new_status = not getattr(promo, 'first_purchase_only', False)
await update_promocode(db, promo, first_purchase_only=new_status)
status_text = "включён" if new_status else "выключен"
await callback.answer(f"✅ Режим 'первая покупка' {status_text}", show_alert=True)
await show_promocode_management(callback, db_user, db)
@@ -1103,11 +1154,13 @@ async def show_general_promocode_stats(
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promocodes_menu, F.data == "admin_promocodes")
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
dp.callback_query.register(show_promocodes_list_page, F.data.startswith("admin_promo_list_page_"))
dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create")
dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_"))
dp.callback_query.register(process_promo_group_selection, F.data.startswith("promo_select_group_"))
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
dp.callback_query.register(toggle_promocode_first_purchase, F.data.startswith("promo_toggle_first_"))
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
dp.callback_query.register(show_promocode_stats, F.data.startswith("promo_stats_"))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -57,7 +57,7 @@ async def start_cryptobot_payment(
assets_text = ", ".join(available_assets)
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
if settings.is_quick_amount_buttons_enabled():
message_text = (
f"🪙 <b>Пополнение криптовалютой</b>\n\n"
f"Выберите сумму пополнения или введите вручную сумму "
@@ -83,7 +83,7 @@ async def start_cryptobot_payment(
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
+391
View File
@@ -0,0 +1,391 @@
"""Handler for Freekassa balance top-up."""
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
async def _create_freekassa_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating Freekassa payment and sending response.
Args:
message_or_callback: Either a Message or CallbackQuery object
db_user: User object
db: Database session
amount_kopeks: Amount in kopeks
edit_message: Whether to edit existing message or send new one
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description="Пополнение баланса",
)
result = await payment_service.create_freekassa_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, "email", None),
language=db_user.language,
)
if not result:
error_text = texts.t(
"PAYMENT_CREATE_ERROR",
"Не удалось создать платёж. Попробуйте позже.",
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML",
)
else:
await message_or_callback.answer(
error_text,
parse_mode="HTML",
)
return
payment_url = result.get("payment_url")
display_name = settings.get_freekassa_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
"PAY_BUTTON",
"💳 Оплатить {amount}",
).format(amount=f"{amount_rub:.0f}"),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t("BACK_BUTTON", "◀️ Назад"),
callback_data="menu_balance",
)
],
]
)
response_text = texts.t(
"FREEKASSA_PAYMENT_CREATED",
"💳 <b>Оплата через {name}</b>\n\n"
"Сумма: <b>{amount}₽</b>\n\n"
"Нажмите кнопку ниже для оплаты.\n"
"После успешной оплаты баланс будет пополнен автоматически.",
).format(name=display_name, amount=f"{amount_rub:.2f}")
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode="HTML",
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode="HTML",
)
logger.info(
"Freekassa payment created: user=%s, amount=%s",
db_user.telegram_id,
amount_rub,
)
@error_handler
async def process_freekassa_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly (called from quick_amount handlers).
"""
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, "restriction_topup", False):
reason = (
getattr(db_user, "restriction_reason", None)
or "Действие ограничено администратором"
)
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append(
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
)
keyboard.append(
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
)
await message.answer(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await state.clear()
return
# Validate amount
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
"PAYMENT_AMOUNT_TOO_LOW",
"Минимальная сумма пополнения: {min_amount}",
).format(min_amount=min_amount // 100),
parse_mode="HTML",
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
"PAYMENT_AMOUNT_TOO_HIGH",
"Максимальная сумма пополнения: {max_amount}",
).format(max_amount=max_amount // 100),
parse_mode="HTML",
)
return
await state.clear()
await _create_freekassa_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
)
@error_handler
async def start_freekassa_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start Freekassa top-up process - ask for amount.
"""
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, "restriction_topup", False):
reason = (
getattr(db_user, "restriction_reason", None)
or "Действие ограничено администратором"
)
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append(
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
)
keyboard.append(
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
)
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="freekassa")
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_freekassa_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("BACK_BUTTON", "◀️ Назад"),
callback_data="menu_balance",
)
]
]
)
await callback.message.edit_text(
texts.t(
"FREEKASSA_ENTER_AMOUNT",
"💳 <b>Пополнение через {name}</b>\n\n"
"Введите сумму пополнения в рублях.\n\n"
"Минимум: {min_amount}\n"
"Максимум: {max_amount}",
).format(
name=display_name,
min_amount=min_amount,
max_amount=f"{max_amount:,}".replace(",", " "),
),
parse_mode="HTML",
reply_markup=keyboard,
)
@error_handler
async def process_freekassa_custom_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process custom amount input for Freekassa payment.
"""
data = await state.get_data()
if data.get("payment_method") != "freekassa":
return
texts = get_texts(db_user.language)
try:
amount_text = message.text.replace(",", ".").replace(" ", "").strip()
amount_rubles = float(amount_text)
amount_kopeks = int(amount_rubles * 100)
except (ValueError, TypeError):
await message.answer(
texts.t(
"PAYMENT_INVALID_AMOUNT",
"Введите корректную сумму числом.",
),
parse_mode="HTML",
)
return
await process_freekassa_payment_amount(
message=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
state=state,
)
@error_handler
async def process_freekassa_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process quick amount selection for Freekassa payment.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
if not settings.is_freekassa_enabled():
await callback.answer(
texts.t("FREEKASSA_NOT_AVAILABLE", "Freekassa временно недоступен"),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|freekassa|{amount_kopeks}
try:
parts = callback.data.split("|")
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer("Invalid callback data", show_alert=True)
return
except (ValueError, IndexError):
await callback.answer("Invalid amount", show_alert=True)
return
# Проверка ограничения на пополнение
if getattr(db_user, "restriction_topup", False):
reason = (
getattr(db_user, "restriction_reason", None)
or "Действие ограничено администратором"
)
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append(
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
)
keyboard.append(
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
)
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
return
# Validate amount
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(
texts.t("AMOUNT_TOO_LOW_SHORT", "Сумма слишком мала"),
show_alert=True,
)
return
if amount_kopeks > max_amount:
await callback.answer(
texts.t("AMOUNT_TOO_HIGH_SHORT", "Сумма слишком велика"),
show_alert=True,
)
return
await callback.answer()
await state.clear()
await _create_freekassa_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
)
+1 -1
View File
@@ -71,7 +71,7 @@ async def start_heleket_payment(
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_buttons = get_quick_amount_buttons(db_user.language, db_user)
+120 -195
View File
@@ -29,6 +29,97 @@ logger = logging.getLogger(__name__)
TRANSACTIONS_PER_PAGE = 10
async def route_payment_by_method(
message: types.Message,
db_user: User,
amount_kopeks: int,
state: FSMContext,
payment_method: str
) -> bool:
"""
Роутер платежей по методу оплаты.
Args:
message: Сообщение для ответа
db_user: Пользователь БД
amount_kopeks: Сумма в копейках
state: FSM состояние
payment_method: Метод оплаты (yookassa, stars, cryptobot и т.д.)
Returns:
True если платеж обработан, False если метод неизвестен
"""
if payment_method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
return True
# Все остальные методы требуют сессию БД
from app.database.database import AsyncSessionLocal
if payment_method == "yookassa":
from .yookassa import process_yookassa_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == "yookassa_sbp":
from .yookassa import process_yookassa_sbp_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == "mulenpay":
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == "platega":
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == "wata":
from .wata import process_wata_payment_amount
async with AsyncSessionLocal() as db:
await process_wata_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == "pal24":
from .pal24 import process_pal24_payment_amount
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == "cryptobot":
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == "heleket":
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == "cloudpayments":
from .cloudpayments import process_cloudpayments_payment_amount
async with AsyncSessionLocal() as db:
await process_cloudpayments_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == "freekassa":
from .freekassa import process_freekassa_payment_amount
async with AsyncSessionLocal() as db:
await process_freekassa_payment_amount(message, db_user, db, amount_kopeks, state)
return True
return False
def get_quick_amount_buttons(language: str, user: User) -> list:
"""
Generate quick amount buttons with user-specific pricing and discounts.
@@ -40,7 +131,7 @@ def get_quick_amount_buttons(language: str, user: User) -> list:
Returns:
List of button rows for inline keyboard
"""
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED or settings.DISABLE_TOPUP_BUTTONS:
if not settings.is_quick_amount_buttons_enabled():
return []
from app.localization.texts import get_texts
@@ -565,59 +656,7 @@ async def process_topup_amount(
await message.answer(f"❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f}".replace(',', ' '))
return
if payment_method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
elif payment_method == "yookassa":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "yookassa_sbp":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_sbp_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "mulenpay":
from app.database.database import AsyncSessionLocal
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
message, db_user, db, amount_kopeks, state
)
elif payment_method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
async with AsyncSessionLocal() as db:
await process_wata_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "cryptobot":
from app.database.database import AsyncSessionLocal
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "cloudpayments":
from app.database.database import AsyncSessionLocal
from .cloudpayments import process_cloudpayments_amount
async with AsyncSessionLocal() as db:
await process_cloudpayments_amount(message, db_user, db, state)
else:
if not await route_payment_by_method(message, db_user, amount_kopeks, state, payment_method):
await message.answer("Неизвестный способ оплаты")
except ValueError:
@@ -676,91 +715,24 @@ async def handle_quick_amount_selection(
"""
Обработчик выбора суммы через кнопки быстрого выбора
"""
# Проверяем, что пользователь в правильном состоянии FSM
current_state = await state.get_state()
if current_state != BalanceStates.waiting_for_amount:
await callback.answer("❌ Сначала выберите способ оплаты", show_alert=True)
return
# Извлекаем сумму из callback_data
try:
amount_kopeks = int(callback.data.split('_')[-1])
amount_rubles = amount_kopeks / 100
# Получаем метод оплаты из состояния
data = await state.get_data()
payment_method = data.get("payment_method", "yookassa")
# Проверяем, какой метод оплаты был выбран и вызываем соответствующий обработчик
if payment_method == "yookassa":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "yookassa_sbp":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_sbp_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "mulenpay":
from app.database.database import AsyncSessionLocal
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
async with AsyncSessionLocal() as db:
await process_wata_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "cryptobot":
from app.database.database import AsyncSessionLocal
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "cloudpayments":
from app.database.database import AsyncSessionLocal
from .cloudpayments import process_cloudpayments_payment_amount
async with AsyncSessionLocal() as db:
await process_cloudpayments_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(
callback.message, db_user, amount_kopeks, state
)
else:
# Роутим платеж на соответствующий обработчик
if not await route_payment_by_method(
callback.message, db_user, amount_kopeks, state, payment_method
):
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
return
@@ -789,28 +761,8 @@ async def handle_topup_amount_callback(
return
try:
if method == "yookassa":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "yookassa_sbp":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_sbp_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "mulenpay":
from app.database.database import AsyncSessionLocal
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "platega":
# Особые случаи, требующие специальной логики
if method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount, start_platega_payment
@@ -825,51 +777,14 @@ async def handle_topup_amount_callback(
else:
await state.update_data(platega_pending_amount=amount_kopeks)
await start_platega_payment(callback, db_user, state)
elif method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "cryptobot":
from app.database.database import AsyncSessionLocal
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
async with AsyncSessionLocal() as db:
await process_wata_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "cloudpayments":
from app.database.database import AsyncSessionLocal
from .cloudpayments import process_cloudpayments_payment_amount
async with AsyncSessionLocal() as db:
await process_cloudpayments_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(
callback.message, db_user, amount_kopeks, state
)
elif method == "tribute":
from .tribute import start_tribute_payment
await start_tribute_payment(callback, db_user)
return
else:
# Стандартные методы через роутер
elif not await route_payment_by_method(
callback.message, db_user, amount_kopeks, state, method
):
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
return
@@ -1013,6 +928,16 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("topup_amount|cloudpayments|")
)
from .freekassa import start_freekassa_topup, process_freekassa_quick_amount
dp.callback_query.register(
start_freekassa_topup,
F.data == "topup_freekassa"
)
dp.callback_query.register(
process_freekassa_quick_amount,
F.data.startswith("topup_amount|freekassa|")
)
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(
check_mulenpay_payment_status,
+1 -1
View File
@@ -64,7 +64,7 @@ async def start_mulenpay_payment(
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
+1 -1
View File
@@ -307,7 +307,7 @@ async def start_pal24_payment(
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
+1 -1
View File
@@ -80,7 +80,7 @@ async def _prompt_amount(
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
+2 -2
View File
@@ -45,7 +45,7 @@ async def start_stars_payment(
return
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
if settings.is_quick_amount_buttons_enabled():
message_text = (
f"⭐ <b>Пополнение через Telegram Stars</b>\n\n"
f"Выберите сумму пополнения или введите вручную:"
@@ -57,7 +57,7 @@ async def start_stars_payment(
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
+1 -1
View File
@@ -61,7 +61,7 @@ async def start_wata_payment(
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
+4 -4
View File
@@ -51,7 +51,7 @@ async def start_yookassa_payment(
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
if settings.is_quick_amount_buttons_enabled():
message_text = (
f"💳 <b>Оплата банковской картой</b>\n\n"
f"Выберите сумму пополнения или введите вручную сумму "
@@ -67,7 +67,7 @@ async def start_yookassa_payment(
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
@@ -122,7 +122,7 @@ async def start_yookassa_sbp_payment(
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
if settings.is_quick_amount_buttons_enabled():
message_text = (
f"🏦 <b>Оплата через СБП</b>\n\n"
f"Выберите сумму пополнения или введите вручную сумму "
@@ -138,7 +138,7 @@ async def start_yookassa_sbp_payment(
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
if settings.is_quick_amount_buttons_enabled():
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
+1 -1
View File
@@ -1291,7 +1291,7 @@ async def handle_activate_button(
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
balance = db_user.balance_kopeks
available_periods = sorted([int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS], reverse=True)
available_periods = sorted(settings.get_available_subscription_periods(), reverse=True)
subscription_service = SubscriptionService()
+4
View File
@@ -125,6 +125,10 @@ async def process_promocode(
"expired": texts.PROMOCODE_EXPIRED,
"used": texts.PROMOCODE_USED,
"already_used_by_user": texts.PROMOCODE_USED,
"not_first_purchase": texts.t(
"PROMOCODE_NOT_FIRST_PURCHASE",
"❌ Этот промокод доступен только для первой покупки"
),
"server_error": texts.ERROR
}
+10 -1
View File
@@ -27,8 +27,17 @@ async def show_referral_info(
db_user: User,
db: AsyncSession
):
# Проверяем, включена ли реферальная программа
if not settings.is_referral_program_enabled():
texts = get_texts(db_user.language)
await callback.answer(
texts.t("REFERRAL_PROGRAM_DISABLED", "Реферальная программа отключена"),
show_alert=True
)
return
texts = get_texts(db_user.language)
summary = await get_user_referral_summary(db, db_user.id)
bot_username = (await callback.bot.get_me()).username
+9
View File
@@ -62,6 +62,15 @@ async def start_simple_subscription_purchase(
device_limit = resolve_simple_subscription_device_limit()
# При продлении учитываем количество устройств из текущей подписки
if current_subscription and settings.is_devices_selection_enabled():
current_device_limit = current_subscription.device_limit or device_limit
# Модем добавляет +1 к device_limit, но оплачивается отдельно
if getattr(current_subscription, 'modem_enabled', False):
current_device_limit = max(1, current_device_limit - 1)
# Используем максимум из текущего и дефолтного
device_limit = max(device_limit, current_device_limit)
# Подготовим параметры простой подписки
subscription_params = {
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
+53 -3
View File
@@ -1982,11 +1982,61 @@ async def required_sub_channel_check(
language=language,
referral_code=referral_code,
)
await db.refresh(user, ['subscription'])
await bot.send_message(
chat_id=query.from_user.id,
text=texts.t("WELCOME_FALLBACK", "Добро пожаловать, {user_name}!").format(user_name=user.full_name),
# Показываем главное меню после создания пользователя
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
user.subscription
)
menu_text = await get_main_menu_text(user, texts, db)
from app.utils.message_patch import LOGO_PATH
from aiogram.types import FSInputFile
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (
(not is_admin)
and SupportSettingsService.is_moderator(user.telegram_id)
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
keyboard = await get_main_menu_keyboard_async(
db=db,
user=user,
language=user.language,
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
if settings.ENABLE_LOGO_MODE:
await bot.send_photo(
chat_id=query.from_user.id,
photo=FSInputFile(LOGO_PATH),
caption=menu_text,
reply_markup=keyboard,
parse_mode="HTML",
)
else:
await bot.send_message(
chat_id=query.from_user.id,
text=menu_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await _send_pinned_message(bot, db, user)
else:
await bot.send_message(
chat_id=query.from_user.id,
+107 -33
View File
@@ -183,13 +183,6 @@ async def handle_change_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
if not subscription or subscription.is_trial:
await callback.answer(
texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
@@ -197,6 +190,30 @@ async def handle_change_devices(
)
return
# Проверяем тариф подписки
tariff = None
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
# Для тарифов - проверяем разрешено ли изменение устройств
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
if tariff:
if tariff_device_price is None or tariff_device_price <= 0:
await callback.answer(
texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Изменение устройств недоступно для вашего тарифа"),
show_alert=True,
)
return
else:
# Для обычных подписок проверяем глобальную настройку
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
current_devices = subscription.device_limit
period_hint_days = _get_period_hint_from_subscription(subscription)
@@ -206,17 +223,34 @@ async def handle_change_devices(
period_hint_days,
)
prompt_text = texts.t(
"CHANGE_DEVICES_PROMPT",
(
"📱 <b>Изменение количества устройств</b>\n\n"
"Текущий лимит: {current_devices} устройств\n"
"Выберите новое количество устройств:\n\n"
"💡 <b>Важно:</b>\n"
"• При увеличении - доплата пропорционально оставшемуся времени\n"
"• При уменьшении - возврат средств не производится"
),
).format(current_devices=current_devices)
# Для тарифов показываем цену из тарифа
if tariff:
price_per_device = tariff_device_price
price_text = texts.format_price(price_per_device)
prompt_text = texts.t(
"CHANGE_DEVICES_PROMPT_TARIFF",
(
"📱 <b>Изменение количества устройств</b>\n\n"
"Текущий лимит: {current_devices} устройств\n"
"Цена за доп. устройство: {price}/мес\n"
"Выберите новое количество устройств:\n\n"
"💡 <b>Важно:</b>\n"
"• При увеличении - доплата пропорционально оставшемуся времени\n"
"• При уменьшении - возврат средств не производится"
),
).format(current_devices=current_devices, price=price_text)
else:
prompt_text = texts.t(
"CHANGE_DEVICES_PROMPT",
(
"📱 <b>Изменение количества устройств</b>\n\n"
"Текущий лимит: {current_devices} устройств\n"
"Выберите новое количество устройств:\n\n"
"💡 <b>Важно:</b>\n"
"• При увеличении - доплата пропорционально оставшемуся времени\n"
"• При уменьшении - возврат средств не производится"
),
).format(current_devices=current_devices)
await callback.message.edit_text(
prompt_text,
@@ -225,6 +259,7 @@ async def handle_change_devices(
db_user.language,
subscription.end_date,
devices_discount_percent,
tariff=tariff,
),
parse_mode="HTML"
)
@@ -240,12 +275,30 @@ async def confirm_change_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
# Проверяем тариф подписки
tariff = None
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
# Для тарифов - проверяем разрешено ли изменение устройств
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
if tariff:
if tariff_device_price is None or tariff_device_price <= 0:
await callback.answer(
texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Изменение устройств недоступно для вашего тарифа"),
show_alert=True,
)
return
price_per_device = tariff_device_price
else:
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
price_per_device = settings.PRICE_PER_DEVICE
current_devices = subscription.device_limit
@@ -271,13 +324,16 @@ async def confirm_change_devices(
if devices_difference > 0:
additional_devices = devices_difference
if current_devices < settings.DEFAULT_DEVICE_LIMIT:
# Для тарифов - все устройства платные (нет бесплатного лимита)
if tariff:
chargeable_devices = additional_devices
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
chargeable_devices = max(0, additional_devices - free_devices)
else:
chargeable_devices = additional_devices
devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE
devices_price_per_month = chargeable_devices * price_per_device
months_hint = get_remaining_months(subscription.end_date)
period_hint_days = months_hint * 30 if months_hint > 0 else None
devices_discount_percent = _get_addon_discount_percent_for_user(
@@ -937,12 +993,30 @@ async def confirm_add_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
# Проверяем тариф подписки
tariff = None
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
# Для тарифов - проверяем разрешено ли добавление устройств
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
if tariff:
if tariff_device_price is None or tariff_device_price <= 0:
await callback.answer(
texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Добавление устройств недоступно для вашего тарифа"),
show_alert=True,
)
return
price_per_device = tariff_device_price
else:
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
price_per_device = settings.PRICE_PER_DEVICE
resume_callback = None
@@ -956,7 +1030,7 @@ async def confirm_add_devices(
)
return
devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE
devices_price_per_month = devices_count * price_per_device
months_hint = get_remaining_months(subscription.end_date)
period_hint_days = months_hint * 30 if months_hint > 0 else None
devices_discount_percent = _get_addon_discount_percent_for_user(
+158 -98
View File
@@ -129,6 +129,10 @@ from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from app.handlers.simple_subscription import (
_calculate_simple_subscription_price,
_get_simple_subscription_payment_keyboard,
)
from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, logger, update_traffic_prices
from .autopay import (
@@ -329,6 +333,17 @@ async def show_subscription_info(
else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов")
)
# Получаем название тарифа для режима тарифов
tariff_line = ""
if settings.is_tariffs_mode() and subscription.tariff_id:
try:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
tariff_line = f"\n📦 Тариф: {tariff.name}"
except Exception as e:
logger.warning(f"Ошибка получения тарифа: {e}")
message_template = texts.t(
"SUBSCRIPTION_OVERVIEW_TEMPLATE",
"""👤 {full_name}
@@ -336,7 +351,7 @@ async def show_subscription_info(
📱 Подписка: {status_emoji} {status_display}{warning}
📱 Информация о подписке
🎭 Тип: {subscription_type}
🎭 Тип: {subscription_type}{tariff_line}
📅 Действует до: {end_date}
Осталось: {time_left}
📈 Трафик: {traffic}
@@ -366,6 +381,7 @@ async def show_subscription_info(
status_display=status_display,
warning=warning_text,
subscription_type=subscription_type,
tariff_line=tariff_line,
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
time_left=time_left_text,
traffic=traffic_used_display,
@@ -442,34 +458,74 @@ async def show_trial_offer(
await callback.answer()
return
# Получаем параметры триала (из тарифа или из глобальных настроек)
trial_days = settings.TRIAL_DURATION_DAYS
trial_traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
trial_tariff = None
trial_server_name = texts.t("TRIAL_SERVER_DEFAULT_NAME", "🎯 Тестовый сервер")
# Проверяем триальный тариф
if settings.is_tariffs_mode():
try:
from app.database.crud.tariff import get_trial_tariff, get_tariff_by_id as get_tariff
trial_tariff = await get_trial_tariff(db)
if not trial_tariff:
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id > 0:
trial_tariff = await get_tariff(db, trial_tariff_id)
if trial_tariff and not trial_tariff.is_active:
trial_tariff = None
if trial_tariff:
trial_traffic = trial_tariff.traffic_limit_gb
trial_device_limit = trial_tariff.device_limit
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
trial_days = tariff_trial_days
logger.info(f"Показываем триал с тарифом {trial_tariff.name}")
except Exception as e:
logger.error(f"Ошибка получения триального тарифа: {e}")
try:
from app.database.crud.server_squad import get_trial_eligible_server_squads
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
if trial_squads:
if len(trial_squads) == 1:
trial_server_name = trial_squads[0].display_name
else:
trial_server_name = texts.t(
"TRIAL_SERVER_RANDOM_POOL",
"🎲 Случайный из {count} серверов",
).format(count=len(trial_squads))
# Для тарифа используем его сервера
if trial_tariff and trial_tariff.allowed_squads:
from app.database.crud.server_squad import get_server_squads_by_uuids
tariff_squads = await get_server_squads_by_uuids(db, trial_tariff.allowed_squads)
if tariff_squads:
if len(tariff_squads) == 1:
trial_server_name = tariff_squads[0].display_name
else:
trial_server_name = texts.t(
"TRIAL_SERVER_RANDOM_POOL",
"🎲 Случайный из {count} серверов",
).format(count=len(tariff_squads))
else:
logger.warning("Не настроены сквады для выдачи триалов")
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
if trial_squads:
if len(trial_squads) == 1:
trial_server_name = trial_squads[0].display_name
else:
trial_server_name = texts.t(
"TRIAL_SERVER_RANDOM_POOL",
"🎲 Случайный из {count} серверов",
).format(count=len(trial_squads))
else:
logger.warning("Не настроены сквады для выдачи триалов")
except Exception as e:
logger.error(f"Ошибка получения триального сервера: {e}")
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
if not settings.is_devices_selection_enabled():
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is not None:
trial_device_limit = forced_limit
devices_line = ""
if settings.is_devices_selection_enabled():
if settings.is_devices_selection_enabled() or trial_tariff:
devices_line_template = texts.t(
"TRIAL_AVAILABLE_DEVICES_LINE",
"\n📱 <b>Устройства:</b> {devices} шт.",
@@ -488,8 +544,8 @@ async def show_trial_offer(
).format(price=settings.format_price(trial_price))
trial_text = texts.TRIAL_AVAILABLE.format(
days=settings.TRIAL_DURATION_DAYS,
traffic=texts.format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB),
days=trial_days,
traffic=texts.format_traffic(trial_traffic),
devices=trial_device_limit if trial_device_limit is not None else "",
devices_line=devices_line,
server_name=trial_server_name,
@@ -664,10 +720,49 @@ async def activate_trial(
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
# Проверяем, настроен ли триальный тариф для режима тарифов
trial_tariff = None
trial_traffic_limit = None
trial_device_limit = forced_devices
trial_squads = None
tariff_id_for_trial = None
trial_duration = None # None = использовать TRIAL_DURATION_DAYS
if settings.is_tariffs_mode():
try:
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
# Сначала проверяем тариф из БД с флагом is_trial_available
trial_tariff = await get_trial_tariff(db)
# Если не найден в БД, проверяем настройку TRIAL_TARIFF_ID
if not trial_tariff:
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id > 0:
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
if trial_tariff and not trial_tariff.is_active:
trial_tariff = None
if trial_tariff:
trial_traffic_limit = trial_tariff.traffic_limit_gb
trial_device_limit = trial_tariff.device_limit
trial_squads = trial_tariff.allowed_squads or []
tariff_id_for_trial = trial_tariff.id
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
trial_duration = tariff_trial_days
logger.info(f"Используем триальный тариф {trial_tariff.name} (ID: {trial_tariff.id})")
except Exception as e:
logger.error(f"Ошибка получения триального тарифа: {e}")
subscription = await create_trial_subscription(
db,
db_user.id,
device_limit=forced_devices,
duration_days=trial_duration,
device_limit=trial_device_limit,
traffic_limit_gb=trial_traffic_limit,
connected_squads=trial_squads,
tariff_id=tariff_id_for_trial,
)
await db.refresh(db_user)
@@ -1044,6 +1139,12 @@ async def start_subscription_purchase(
):
texts = get_texts(db_user.language)
# Проверяем режим продаж - если tariffs, перенаправляем на выбор тарифов
if settings.is_tariffs_mode():
from .tariff_purchase import show_tariffs_list
await show_tariffs_list(callback, db_user, db, state)
return
keyboard = get_subscription_period_keyboard(db_user.language, db_user)
prompt_text = await _build_subscription_period_prompt(db_user, texts, db)
@@ -1319,6 +1420,35 @@ async def handle_extend_subscription(
await callback.answer("⚠ Продление доступно только для платных подписок", show_alert=True)
return
# В режиме тарифов проверяем наличие tariff_id
if settings.is_tariffs_mode():
if subscription.tariff_id:
# У подписки есть тариф - перенаправляем на продление по тарифу
from .tariff_purchase import show_tariff_extend
await show_tariff_extend(callback, db_user, db)
return
else:
# У подписки нет тарифа - предлагаем выбрать тариф
await callback.message.edit_text(
"📦 <b>Выберите тариф для продления</b>\n\n"
"Ваша текущая подписка была создана до введения тарифов.\n"
"Для продления необходимо выбрать один из доступных тарифов.\n\n"
"⚠️ Ваша текущая подписка продолжит действовать до окончания срока.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="📦 Выбрать тариф",
callback_data="tariff_switch"
)],
[types.InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_subscription"
)]
]),
parse_mode="HTML"
)
await callback.answer()
return
subscription_service = SubscriptionService()
available_periods = settings.get_available_renewal_periods()
@@ -2825,6 +2955,12 @@ async def handle_subscription_settings(
texts = get_texts(db_user.language)
subscription = db_user.subscription
# Получаем тариф подписки если есть
tariff = None
if subscription and subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not subscription or subscription.is_trial:
await callback.answer(
texts.t(
@@ -2880,7 +3016,7 @@ async def handle_subscription_settings(
await callback.message.edit_text(
settings_text,
reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries),
reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries, tariff=tariff),
parse_mode="HTML"
)
await callback.answer()
@@ -3891,6 +4027,10 @@ def register_handlers(dp: Dispatcher):
from .modem import register_modem_handlers
register_modem_handlers(dp)
# Регистрируем обработчики покупки по тарифам
from .tariff_purchase import register_tariff_purchase_handlers
register_tariff_purchase_handlers(dp)
# Регистрируем обработчик для простой покупки
dp.callback_query.register(
handle_simple_subscription_purchase,
@@ -4051,86 +4191,6 @@ async def handle_simple_subscription_purchase(
await callback.answer()
async def _calculate_simple_subscription_price(
db: AsyncSession,
params: dict,
*,
user: Optional[User] = None,
resolved_squad_uuid: Optional[str] = None,
) -> Tuple[int, Dict[str, Any]]:
"""Рассчитывает цену простой подписки."""
resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None
return await compute_simple_subscription_price(
db,
params,
user=user,
resolved_squad_uuids=resolved_uuids,
)
def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup:
"""Создает клавиатуру с методами оплаты для простой подписки."""
texts = get_texts(language)
keyboard = []
# Добавляем доступные методы оплаты
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([types.InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="simple_subscription_stars"
)])
if settings.is_yookassa_enabled():
yookassa_methods = []
if settings.YOOKASSA_SBP_ENABLED:
yookassa_methods.append(types.InlineKeyboardButton(
text="🏦 YooKassa (СБП)",
callback_data="simple_subscription_yookassa_sbp"
))
yookassa_methods.append(types.InlineKeyboardButton(
text="💳 YooKassa (Карта)",
callback_data="simple_subscription_yookassa"
))
if yookassa_methods:
keyboard.append(yookassa_methods)
if settings.is_cryptobot_enabled():
keyboard.append([types.InlineKeyboardButton(
text="🪙 CryptoBot",
callback_data="simple_subscription_cryptobot"
)])
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
keyboard.append([types.InlineKeyboardButton(
text=f"💳 {mulenpay_name}",
callback_data="simple_subscription_mulenpay"
)])
if settings.is_pal24_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 PayPalych",
callback_data="simple_subscription_pal24"
)])
if settings.is_wata_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 WATA",
callback_data="simple_subscription_wata"
)])
# Кнопка назад
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
callback_data="subscription_purchase"
)])
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _extend_existing_subscription(
callback: types.CallbackQuery,
db_user: User,
File diff suppressed because it is too large Load Diff
+11
View File
@@ -107,6 +107,17 @@ async def handle_add_traffic(
)
return
# В режиме тарифов докупка трафика недоступна
if settings.is_tariffs_mode():
await callback.answer(
texts.t(
"TARIFF_TRAFFIC_TOPUP_DISABLED",
"⚠️ В режиме тарифов докупка трафика недоступна",
),
show_alert=True,
)
return
if settings.is_traffic_topup_blocked():
await callback.answer(
texts.t(
+6
View File
@@ -24,10 +24,16 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
),
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_TARIFFS", "📦 Тарифы"),
callback_data="admin_tariffs",
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_PRICING", "💰 Цены"),
callback_data="admin_pricing",
),
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_PROMO_STATS", "💰 Промокоды/Статистика"),
callback_data="admin_submenu_promo",
+68 -24
View File
@@ -807,10 +807,10 @@ def get_happ_download_link_keyboard(language: str, link: str) -> InlineKeyboardM
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_back_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
def get_back_keyboard(language: str = DEFAULT_LANGUAGE, callback_data: str = "back_to_menu") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
[InlineKeyboardButton(text=texts.BACK, callback_data=callback_data)]
])
@@ -993,6 +993,14 @@ def get_subscription_keyboard(
callback_data="subscription_settings",
)
])
# Кнопка смены тарифа для режима тарифов
if settings.is_tariffs_mode() and subscription:
keyboard.append([
InlineKeyboardButton(
text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Сменить тариф"),
callback_data="tariff_switch"
)
])
# Кнопка докупки трафика для платных подписок
if (
settings.is_traffic_topup_enabled()
@@ -1429,6 +1437,16 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
])
has_direct_payment_methods = True
if settings.is_freekassa_enabled():
freekassa_name = settings.get_freekassa_display_name()
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_FREEKASSA", f"💳 {freekassa_name}"),
callback_data=_build_callback("freekassa")
)
])
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append([
InlineKeyboardButton(
@@ -1773,28 +1791,37 @@ def get_change_devices_keyboard(
language: str = DEFAULT_LANGUAGE,
subscription_end_date: datetime = None,
discount_percent: int = 0,
tariff=None, # Тариф для цены за устройство
) -> InlineKeyboardMarkup:
from app.utils.pricing_utils import get_remaining_months
from app.config import settings
texts = get_texts(language)
months_multiplier = 1
period_text = ""
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f" (за {months_multiplier} мес)"
device_price_per_month = settings.PRICE_PER_DEVICE
# Используем цену из тарифа если есть, иначе глобальную настройку
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
if tariff and tariff_device_price:
device_price_per_month = tariff_device_price
# Для тарифов все устройства платные (нет бесплатного лимита)
default_device_limit = 0
else:
device_price_per_month = settings.PRICE_PER_DEVICE
default_device_limit = settings.DEFAULT_DEVICE_LIMIT
buttons = []
min_devices = 1
min_devices = 1
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
start_range = max(1, min(current_devices - 3, max_devices - 6))
end_range = min(max_devices + 1, max(current_devices + 4, 7))
for devices_count in range(start_range, end_range):
if devices_count == current_devices:
emoji = ""
@@ -1803,11 +1830,11 @@ def get_change_devices_keyboard(
elif devices_count > current_devices:
emoji = ""
additional_devices = devices_count - current_devices
current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT)
new_chargeable = max(0, devices_count - settings.DEFAULT_DEVICE_LIMIT)
current_chargeable = max(0, current_devices - default_device_limit)
new_chargeable = max(0, devices_count - default_device_limit)
chargeable_devices = new_chargeable - current_chargeable
if chargeable_devices > 0:
price_per_month = chargeable_devices * device_price_per_month
discounted_per_month, discount_per_month = apply_percentage_discount(
@@ -1829,19 +1856,19 @@ def get_change_devices_keyboard(
emoji = ""
action_text = ""
price_text = " (без возврата)"
button_text = f"{emoji} {devices_count} устр.{action_text}{price_text}"
buttons.append([
InlineKeyboardButton(text=button_text, callback_data=f"change_devices_{devices_count}")
])
if current_devices < start_range or current_devices >= end_range:
current_button = f"{current_devices} устр. (текущее)"
buttons.insert(0, [
InlineKeyboardButton(text=current_button, callback_data=f"change_devices_{current_devices}")
])
buttons.append([
InlineKeyboardButton(
text=texts.BACK,
@@ -2392,18 +2419,25 @@ def get_devices_management_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE, show_countries_management: bool = True) -> InlineKeyboardMarkup:
def get_updated_subscription_settings_keyboard(
language: str = DEFAULT_LANGUAGE,
show_countries_management: bool = True,
tariff=None, # Тариф подписки (если есть - ограничиваем настройки)
) -> InlineKeyboardMarkup:
from app.config import settings
texts = get_texts(language)
keyboard = []
if show_countries_management:
# Если подписка на тарифе - отключаем страны, модем, трафик
has_tariff = tariff is not None
if show_countries_management and not has_tariff:
keyboard.append([
InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries")
])
if settings.is_traffic_selectable():
if settings.is_traffic_selectable() and not has_tariff:
keyboard.append([
InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic")
])
@@ -2411,7 +2445,17 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
])
if settings.is_devices_selection_enabled():
# Устройства: для тарифов - только если указана цена за устройство
if has_tariff:
tariff_device_price = getattr(tariff, 'device_price_kopeks', None)
if tariff_device_price is not None and tariff_device_price > 0:
keyboard.append([
InlineKeyboardButton(
text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"),
callback_data="subscription_change_devices"
)
])
elif settings.is_devices_selection_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"),
@@ -2419,7 +2463,7 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
)
])
if settings.is_modem_enabled():
if settings.is_modem_enabled() and not has_tariff:
keyboard.append([
InlineKeyboardButton(
text=texts.t("MODEM_BUTTON", "📡 Модем"),
+2
View File
@@ -666,6 +666,7 @@
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
"ADMIN_SETTINGS_TARIFFS": "📦 Тарифы",
"ADMIN_SQUAD_ADD_ALL": "👥 Добавить всех пользователей",
"ADMIN_SQUAD_DELETE": "🗑️ Удалить сквад",
"ADMIN_SQUAD_EDIT": "✏️ Редактировать",
@@ -940,6 +941,7 @@
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
"CHANGE_TARIFF_BUTTON": "📦 Сменить тариф",
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
+97 -3
View File
@@ -9,7 +9,7 @@ from aiogram.enums import ChatMemberStatus
from app.config import settings
from app.database.database import get_db
from app.database.crud.campaign import get_campaign_by_start_parameter
from app.database.crud.subscription import deactivate_subscription
from app.database.crud.subscription import deactivate_subscription, reactivate_subscription
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import SubscriptionStatus
from app.keyboards.inline import get_channel_sub_keyboard
@@ -104,12 +104,15 @@ class ChannelCheckerMiddleware(BaseMiddleware):
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
if member.status in self.GOOD_MEMBER_STATUS:
# Реактивируем подписку если была отключена из-за отписки от канала
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
await self._reactivate_subscription_on_subscribe(telegram_id, bot)
return await handler(event, data)
elif member.status in self.BAD_MEMBER_STATUS:
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
await self._deactivate_subscription_on_unsubscribe(telegram_id)
await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, channel_link)
await self._capture_start_payload(state, event, bot)
@@ -253,7 +256,9 @@ class ChannelCheckerMiddleware(BaseMiddleware):
finally:
break
async def _deactivate_subscription_on_unsubscribe(self, telegram_id: int) -> None:
async def _deactivate_subscription_on_unsubscribe(
self, telegram_id: int, bot: Bot, channel_link: Optional[str]
) -> None:
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
logger.debug(
"ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено",
@@ -308,6 +313,24 @@ class ChannelCheckerMiddleware(BaseMiddleware):
user.remnawave_uuid,
api_error,
)
# Уведомляем пользователя о деактивации
try:
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
notification_text = texts.t(
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE",
"🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n"
"Подпишитесь на канал снова, чтобы восстановить доступ к VPN."
)
channel_kb = get_channel_sub_keyboard(channel_link, language=user.language)
await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb)
logger.info(f"📨 Уведомление о деактивации отправлено пользователю {telegram_id}")
except Exception as notify_error:
logger.error(
"❌ Не удалось отправить уведомление о деактивации пользователю %s: %s",
telegram_id,
notify_error,
)
except Exception as db_error:
logger.error(
"❌ Ошибка деактивации подписки пользователя %s после отписки: %s",
@@ -317,6 +340,77 @@ class ChannelCheckerMiddleware(BaseMiddleware):
finally:
break
async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None:
"""Реактивация подписки после повторной подписки на канал.
Вызывается только если подписка в статусе DISABLED.
"""
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
return
async for db in get_db():
try:
user = await get_user_by_telegram_id(db, telegram_id)
if not user or not user.subscription:
break
subscription = user.subscription
# Реактивируем только DISABLED подписки (деактивированные из-за отписки)
# Тихо выходим если подписка не требует реактивации — без логов
if subscription.status != SubscriptionStatus.DISABLED.value:
break
# Проверяем что подписка ещё не истекла
from datetime import datetime
if subscription.end_date and subscription.end_date <= datetime.utcnow():
break
# Реактивируем в БД
await reactivate_subscription(db, subscription)
sub_type = "Триальная" if subscription.is_trial else "Платная"
logger.info(
"%s подписка пользователя %s реактивирована после подписки на канал",
sub_type,
telegram_id,
)
# Включаем в RemnaWave
if user.remnawave_uuid:
service = SubscriptionService()
try:
await service.enable_remnawave_user(user.remnawave_uuid)
except Exception as api_error:
logger.error(
"❌ Не удалось включить пользователя RemnaWave %s: %s",
user.remnawave_uuid,
api_error,
)
# Уведомляем пользователя о реактивации
try:
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
notification_text = texts.t(
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE",
"✅ Ваша подписка восстановлена!\n\n"
"Спасибо, что подписались на канал. VPN снова работает."
)
await bot.send_message(telegram_id, notification_text)
except Exception as notify_error:
logger.warning(
"Не удалось отправить уведомление о реактивации пользователю %s: %s",
telegram_id,
notify_error,
)
except Exception as db_error:
logger.error(
"❌ Ошибка реактивации подписки пользователя %s: %s",
telegram_id,
db_error,
)
finally:
break
@staticmethod
async def _deny_message(
event: TelegramObject,
+4 -1
View File
@@ -1364,7 +1364,8 @@ class AdminNotificationService:
update_types = {
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы")
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы"),
"modem": ("📡 ИЗМЕНЕНИЕ МОДЕМА", "модем")
}
title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры"))
@@ -1449,6 +1450,8 @@ class AdminNotificationService:
if isinstance(value, list):
return f"{len(value)} серверов"
return str(value)
elif update_type == "modem":
return "✅ Включён" if value else "❌ Выключен"
return str(value)
async def send_bulk_ban_notification(
-1
View File
@@ -4,7 +4,6 @@
"""
import asyncio
import logging
import re
from typing import List, Dict, Optional, Tuple
from datetime import datetime, timedelta
import aiohttp
+2 -3
View File
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.campaign import record_campaign_registration
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription,
get_subscription_by_user_id,
)
from app.database.crud.user import add_user_balance
@@ -141,7 +141,7 @@ class AdvertisingCampaignService:
squad_uuid = squads[0] if squads else None
new_subscription = await create_trial_subscription(
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=duration_days,
@@ -150,7 +150,6 @@ class AdvertisingCampaignService:
connected_squads=squads,
update_server_counters=True,
is_trial=True,
squad_uuid=squad_uuid,
)
try:
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime
from typing import Any, Callable, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.webhook_service import webhook_service
logger = logging.getLogger(__name__)
class EventEmitter:
"""Event emitter для отслеживания и распространения событий системы."""
def __init__(self) -> None:
self._listeners: dict[str, list[Callable]] = {}
self._websocket_connections: set[Any] = set()
def on(self, event_type: str, callback: Callable) -> None:
"""Подписаться на событие."""
if event_type not in self._listeners:
self._listeners[event_type] = []
self._listeners[event_type].append(callback)
def off(self, event_type: str, callback: Callable) -> None:
"""Отписаться от события."""
if event_type in self._listeners:
try:
self._listeners[event_type].remove(callback)
except ValueError:
pass
def register_websocket(self, websocket: Any) -> None:
"""Зарегистрировать WebSocket подключение."""
self._websocket_connections.add(websocket)
logger.debug("WebSocket connection registered. Total: %d", len(self._websocket_connections))
def unregister_websocket(self, websocket: Any) -> None:
"""Отменить регистрацию WebSocket подключения."""
self._websocket_connections.discard(websocket)
logger.debug("WebSocket connection unregistered. Total: %d", len(self._websocket_connections))
async def emit(
self,
event_type: str,
payload: dict[str, Any],
db: Optional[AsyncSession] = None,
) -> None:
"""Отправить событие всем подписчикам."""
event_data = {
"type": event_type,
"payload": payload,
"timestamp": str(datetime.utcnow()),
}
# Вызываем локальные слушатели
if event_type in self._listeners:
for callback in self._listeners[event_type]:
try:
if asyncio.iscoroutinefunction(callback):
await callback(event_data)
else:
callback(event_data)
except Exception as error:
logger.exception("Error in event listener for %s: %s", event_type, error)
# Отправляем через WebSocket
await self._broadcast_to_websockets(event_data)
# Отправляем webhooks
if db:
await webhook_service.send_webhook(db, event_type, payload)
async def _broadcast_to_websockets(self, event_data: dict[str, Any]) -> None:
"""Отправить событие всем подключенным WebSocket клиентам."""
if not self._websocket_connections:
return
disconnected = set()
message = json.dumps(event_data, default=str, ensure_ascii=False)
for ws in self._websocket_connections:
try:
await ws.send_text(message)
except Exception as error:
logger.warning("Failed to send WebSocket message: %s", error)
disconnected.add(ws)
# Удаляем отключенные соединения
for ws in disconnected:
self.unregister_websocket(ws)
# Глобальный экземпляр event emitter
event_emitter = EventEmitter()
+319
View File
@@ -0,0 +1,319 @@
"""Сервис для работы с API Freekassa."""
import hashlib
import hmac
import time
import logging
from typing import Optional, Dict, Any, Set
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
# IP-адреса Freekassa для проверки webhook
FREEKASSA_IPS: Set[str] = {
"168.119.157.136",
"168.119.60.227",
"178.154.197.79",
"51.250.54.238",
}
API_BASE_URL = "https://api.fk.life/v1"
class FreekassaService:
"""Сервис для работы с API Freekassa."""
def __init__(self):
self._shop_id: Optional[int] = None
self._api_key: Optional[str] = None
self._secret1: Optional[str] = None
self._secret2: Optional[str] = None
@property
def shop_id(self) -> int:
if self._shop_id is None:
self._shop_id = settings.FREEKASSA_SHOP_ID
return self._shop_id or 0
@property
def api_key(self) -> str:
if self._api_key is None:
self._api_key = settings.FREEKASSA_API_KEY
return self._api_key or ""
@property
def secret1(self) -> str:
if self._secret1 is None:
self._secret1 = settings.FREEKASSA_SECRET_WORD_1
return self._secret1 or ""
@property
def secret2(self) -> str:
if self._secret2 is None:
self._secret2 = settings.FREEKASSA_SECRET_WORD_2
return self._secret2 or ""
def _generate_api_signature_hmac(self, params: Dict[str, Any]) -> str:
"""
Генерирует подпись для API запроса (HMAC-SHA256).
Используется для API методов (создание заказа и т.д.)
"""
# Исключаем signature из параметров и сортируем по ключу
sign_data = {k: v for k, v in params.items() if k != "signature"}
sorted_items = sorted(sign_data.items())
# Формируем строку: значения через |
msg = "|".join(str(v) for _, v in sorted_items)
# HMAC-SHA256
return hmac.new(
self.api_key.encode("utf-8"),
msg.encode("utf-8"),
hashlib.sha256
).hexdigest()
def _generate_api_signature(self, params: Dict[str, Any]) -> str:
"""
Генерирует подпись для API запроса.
Для новых API методов используется HMAC-SHA256.
"""
return self._generate_api_signature_hmac(params)
def generate_form_signature(
self, amount: float, currency: str, order_id: str
) -> str:
"""
Генерирует подпись для платежной формы.
Формат: MD5(shop_id:amount:secret1:currency:order_id)
"""
# Приводим amount к int, если это целое число
final_amount = int(amount) if float(amount).is_integer() else amount
sign_string = f"{self.shop_id}:{final_amount}:{self.secret1}:{currency}:{order_id}"
return hashlib.md5(sign_string.encode()).hexdigest()
def verify_webhook_signature(
self, shop_id: int, amount: float, order_id: str, sign: str
) -> bool:
"""
Проверяет подпись webhook уведомления.
Формат: MD5(shop_id:amount:secret2:order_id)
"""
# Приводим amount к int, если это целое число
final_amount = int(amount) if float(amount).is_integer() else amount
expected_sign = hashlib.md5(
f"{shop_id}:{final_amount}:{self.secret2}:{order_id}".encode()
).hexdigest()
return sign.lower() == expected_sign.lower()
def verify_webhook_ip(self, ip: str) -> bool:
"""Проверяет, что IP входит в разрешенный список Freekassa."""
return ip in FREEKASSA_IPS
def build_payment_url(
self,
order_id: str,
amount: float,
currency: str = "RUB",
email: Optional[str] = None,
phone: Optional[str] = None,
payment_system_id: Optional[int] = None,
lang: str = "ru",
) -> str:
"""
Формирует URL для перенаправления на оплату (форма выбора).
Используется когда FREEKASSA_USE_API = False.
"""
# Приводим amount к int, если это целое число
final_amount = int(amount) if float(amount).is_integer() else amount
signature = self.generate_form_signature(final_amount, currency, order_id)
params = {
"m": self.shop_id,
"oa": final_amount,
"currency": currency,
"o": order_id,
"s": signature,
"lang": lang,
}
if email:
params["em"] = email
if phone:
params["phone"] = phone
# Используем payment_system_id из настроек, если не передан явно
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID
if ps_id:
params["i"] = ps_id
query = "&".join(f"{k}={v}" for k, v in params.items())
return f"https://pay.freekassa.ru/?{query}"
async def create_order(
self,
order_id: str,
amount: float,
currency: str = "RUB",
email: Optional[str] = None,
ip: Optional[str] = None,
payment_system_id: Optional[int] = None,
success_url: Optional[str] = None,
failure_url: Optional[str] = None,
notification_url: Optional[str] = None,
) -> Dict[str, Any]:
"""
Создает заказ через API Freekassa.
POST /orders/create
Используется для NSPK СБП (payment_system_id=44) и других методов.
Возвращает словарь с 'location' (ссылка на оплату).
"""
# Приводим amount к int, если это целое число
final_amount = int(amount) if float(amount).is_integer() else amount
# Используем payment_system_id из настроек, если не передан явно
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID or 1
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()), # Наносекунды для уникальности
"paymentId": str(order_id),
"i": ps_id,
"email": email or "user@example.com",
"ip": ip or "127.0.0.1",
"amount": final_amount,
"currency": currency,
}
# Генерируем подпись HMAC-SHA256
params["signature"] = self._generate_api_signature(params)
logger.info(f"Freekassa API create_order params: {params}")
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{API_BASE_URL}/orders/create",
json=params,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=30),
) as response:
text = await response.text()
logger.info(f"Freekassa API response: {text}")
data = await response.json()
if response.status != 200 or data.get("type") == "error":
logger.error(f"Freekassa create_order error: {data}")
raise Exception(
f"Freekassa API error: {data.get('message', 'Unknown error')}"
)
return data
except aiohttp.ClientError as e:
logger.exception(f"Freekassa API connection error: {e}")
raise
async def create_order_and_get_url(
self,
order_id: str,
amount: float,
currency: str = "RUB",
email: Optional[str] = None,
ip: Optional[str] = None,
payment_system_id: Optional[int] = None,
) -> str:
"""
Создает заказ через API и возвращает URL для оплаты.
Удобный метод для получения только ссылки.
"""
result = await self.create_order(
order_id=order_id,
amount=amount,
currency=currency,
email=email,
ip=ip,
payment_system_id=payment_system_id,
)
location = result.get("location")
if not location:
raise Exception("Freekassa API did not return payment URL (location)")
return location
async def get_order_status(self, order_id: str) -> Dict[str, Any]:
"""
Получает статус заказа.
POST /orders
"""
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
"paymentId": str(order_id),
}
params["signature"] = self._generate_api_signature(params)
logger.info(f"Freekassa get_order_status params: {params}")
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{API_BASE_URL}/orders",
json=params,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=30),
) as response:
text = await response.text()
logger.info(f"Freekassa get_order_status response: {text}")
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f"Freekassa API connection error: {e}")
raise
async def get_balance(self) -> Dict[str, Any]:
"""Получает баланс магазина."""
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
}
params["signature"] = self._generate_api_signature(params)
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{API_BASE_URL}/balance",
json=params,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=30),
) as response:
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f"Freekassa API connection error: {e}")
raise
async def get_payment_systems(self) -> Dict[str, Any]:
"""Получает список доступных платежных систем."""
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
}
params["signature"] = self._generate_api_signature(params)
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{API_BASE_URL}/currencies",
json=params,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=30),
) as response:
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f"Freekassa API connection error: {e}")
raise
# Singleton instance
freekassa_service = FreekassaService()
+3
View File
@@ -719,6 +719,9 @@ class MenuLayoutService:
if conditions.get("traffic_topup_enabled") is True:
if not settings.is_traffic_topup_enabled():
return False
# В режиме тарифов докупка трафика недоступна
if settings.is_tariffs_mode():
return False
# is_admin
if conditions.get("is_admin") is True:
+14 -1
View File
@@ -299,6 +299,9 @@ class NalogoQueueService:
queue_length = 0
total_amount = 0.0
queued_receipts = []
pending_verification_count = 0
pending_verification_amount = 0.0
pending_verification_receipts = []
if self._nalogo_service:
queue_length = await self._nalogo_service.get_queue_length()
@@ -306,6 +309,12 @@ class NalogoQueueService:
queued_receipts = await self._nalogo_service.get_queued_receipts()
total_amount = sum(r.get("amount", 0) for r in queued_receipts)
# Чеки ожидающие ручной проверки
pending_verification_count = await self._nalogo_service.get_pending_verification_count()
if pending_verification_count > 0:
pending_verification_receipts = await self._nalogo_service.get_pending_verification_receipts()
pending_verification_amount = sum(r.get("amount", 0) for r in pending_verification_receipts)
return {
"running": self.is_running(),
"check_interval_seconds": self._check_interval,
@@ -313,7 +322,11 @@ class NalogoQueueService:
"queue_length": queue_length,
"total_amount": total_amount,
"max_attempts": self._max_attempts,
"queued_receipts": queued_receipts[:10], # Показываем только первые 10
"queued_receipts": queued_receipts[:10],
# Чеки требующие ручной проверки (таймаут после успешной авторизации)
"pending_verification_count": pending_verification_count,
"pending_verification_amount": pending_verification_amount,
"pending_verification_receipts": pending_verification_receipts[:10],
}
+266 -19
View File
@@ -13,6 +13,7 @@ from app.utils.cache import cache
logger = logging.getLogger(__name__)
NALOGO_QUEUE_KEY = "nalogo:receipt_queue"
NALOGO_PENDING_VERIFICATION_KEY = "nalogo:pending_verification"
class NaloGoService:
@@ -37,10 +38,13 @@ class NaloGoService:
"Функционал чеков будет ОТКЛЮЧЕН.")
else:
try:
# Таймаут 30 секунд — nalog.ru иногда отвечает медленно
timeout = getattr(settings, 'NALOGO_TIMEOUT', 30.0)
self.client = Client(
base_url="https://lknpd.nalog.ru/api",
storage_path=storage_path,
device_id=device_id or "bot-device-123"
device_id=device_id or "bot-device-123",
timeout=timeout,
)
self.inn = inn
self.password = password
@@ -99,10 +103,11 @@ class NaloGoService:
)
return False
# Проверяем не в очереди ли уже
# Атомарная проверка и установка флага "в очереди" (защита от race condition)
queued_key = f"nalogo:queued:{payment_id}"
already_queued = await cache.get(queued_key)
if already_queued:
lock_acquired = await cache.setnx(queued_key, "queued", expire=7 * 24 * 3600)
if not lock_acquired:
# Ключ уже существует — чек уже в очереди
logger.info(
f"Чек для payment_id={payment_id} уже в очереди, пропускаем дубликат"
)
@@ -121,18 +126,152 @@ class NaloGoService:
}
success = await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
if success:
# Помечаем что чек в очереди (TTL 7 дней)
if payment_id:
queued_key = f"nalogo:queued:{payment_id}"
await cache.set(queued_key, "queued", expire=7 * 24 * 3600)
queue_len = await cache.llen(NALOGO_QUEUE_KEY)
logger.info(
f"Чек добавлен в очередь (payment_id={payment_id}, "
f"сумма={amount}₽, в очереди: {queue_len})"
)
else:
# Если не удалось добавить в очередь — удаляем флаг
if payment_id:
queued_key = f"nalogo:queued:{payment_id}"
await cache.delete(queued_key)
return success
async def _save_pending_verification(
self,
name: str,
amount: float,
quantity: int,
client_info: Optional[Dict[str, Any]],
payment_id: Optional[str],
telegram_user_id: Optional[int],
amount_kopeks: Optional[int],
error_message: str,
) -> bool:
"""Сохранить чек в очередь ожидающих проверки.
Используется когда таймаут произошёл ПОСЛЕ успешной аутентификации
чек мог быть создан на сервере, но ответ не пришёл.
"""
receipt_data = {
"name": name,
"amount": amount,
"quantity": quantity,
"client_info": client_info,
"payment_id": payment_id,
"telegram_user_id": telegram_user_id,
"amount_kopeks": amount_kopeks,
"created_at": datetime.now().isoformat(),
"error": error_message,
"status": "pending_verification",
}
success = await cache.lpush(NALOGO_PENDING_VERIFICATION_KEY, receipt_data)
if success:
count = await cache.llen(NALOGO_PENDING_VERIFICATION_KEY)
logger.warning(
f"Чек сохранён для ручной проверки (payment_id={payment_id}, "
f"сумма={amount}₽, всего ожидают проверки: {count})"
)
return success
async def get_pending_verification_count(self) -> int:
"""Получить количество чеков ожидающих проверки."""
return await cache.llen(NALOGO_PENDING_VERIFICATION_KEY)
async def get_pending_verification_receipts(self) -> list:
"""Получить список чеков ожидающих проверки."""
return await cache.lrange(NALOGO_PENDING_VERIFICATION_KEY)
async def mark_pending_as_verified(
self,
payment_id: str,
receipt_uuid: Optional[str] = None,
was_created: bool = True,
) -> Optional[Dict[str, Any]]:
"""Пометить чек как проверенный и удалить из очереди.
Args:
payment_id: ID платежа
receipt_uuid: UUID чека если был создан в налоговой
was_created: True если чек был создан, False если не был
Returns:
Данные удалённого чека или None если не найден
"""
receipts = await self.get_pending_verification_receipts()
updated_receipts = []
removed_receipt = None
for receipt in receipts:
if receipt.get("payment_id") == payment_id:
removed_receipt = receipt
if was_created and receipt_uuid:
# Сохраняем что чек создан
created_key = f"nalogo:created:{payment_id}"
await cache.set(created_key, receipt_uuid, expire=30 * 24 * 3600)
logger.info(
f"Чек {payment_id} помечен как созданный: {receipt_uuid}"
)
else:
updated_receipts.append(receipt)
if removed_receipt:
# Очищаем и перезаписываем список
await cache.delete(NALOGO_PENDING_VERIFICATION_KEY)
for r in reversed(updated_receipts): # reversed чтобы сохранить порядок
await cache.lpush(NALOGO_PENDING_VERIFICATION_KEY, r)
logger.info(f"Чек {payment_id} удалён из очереди проверки")
return removed_receipt
async def retry_pending_receipt(self, payment_id: str) -> Optional[str]:
"""Повторно отправить чек из очереди проверки.
Используется когда проверили что чек НЕ был создан в налоговой.
Returns:
UUID созданного чека или None
"""
receipts = await self.get_pending_verification_receipts()
target_receipt = None
for receipt in receipts:
if receipt.get("payment_id") == payment_id:
target_receipt = receipt
break
if not target_receipt:
logger.warning(f"Чек {payment_id} не найден в очереди проверки")
return None
# Пытаемся создать чек
receipt_uuid = await self.create_receipt(
name=target_receipt.get("name", ""),
amount=target_receipt.get("amount", 0),
quantity=target_receipt.get("quantity", 1),
client_info=target_receipt.get("client_info"),
payment_id=payment_id,
queue_on_failure=False, # Не добавлять обратно в очередь
telegram_user_id=target_receipt.get("telegram_user_id"),
amount_kopeks=target_receipt.get("amount_kopeks"),
)
if receipt_uuid:
# Удаляем из очереди проверки
await self.mark_pending_as_verified(payment_id, receipt_uuid, was_created=True)
logger.info(f"Чек {payment_id} успешно создан после ручной проверки: {receipt_uuid}")
return receipt_uuid
async def clear_pending_verification(self) -> int:
"""Очистить всю очередь проверки (после полной ручной сверки)."""
count = await self.get_pending_verification_count()
if count > 0:
await cache.delete(NALOGO_PENDING_VERIFICATION_KEY)
logger.info(f"Очередь проверки очищена: удалено {count} чеков")
return count
async def authenticate(self) -> bool:
"""Аутентификация в сервисе NaloGO."""
if not self.configured:
@@ -196,19 +335,41 @@ class NaloGoService:
)
return already_created # Возвращаем ранее созданный uuid
# ЭТАП 1: Аутентификация
# Если не прошла — чек точно не создавался, безопасно добавить в очередь
auth_was_successful = False
try:
# Аутентифицируемся, если нужно
if not hasattr(self.client, '_access_token') or not self.client._access_token:
auth_success = await self.authenticate()
if not auth_success:
# Если сервис недоступен — добавляем в очередь
# Аутентификация не прошла — чек не создавался, безопасно в очередь
if queue_on_failure:
await self._queue_receipt(
name, amount, quantity, client_info, payment_id,
telegram_user_id, amount_kopeks
)
return None
auth_was_successful = True
except Exception as auth_error:
# Ошибка аутентификации — чек не создавался, безопасно в очередь
if self._is_service_unavailable(auth_error):
logger.warning(
f"NaloGO недоступен при аутентификации, чек в очередь "
f"(payment_id={payment_id}, сумма={amount}₽)"
)
if queue_on_failure:
await self._queue_receipt(
name, amount, quantity, client_info, payment_id,
telegram_user_id, amount_kopeks
)
else:
logger.error("Ошибка аутентификации NaloGO: %s", auth_error, exc_info=True)
return None
# ЭТАП 2: Создание чека
# Если аутентификация прошла и получили таймаут — чек МОГ быть создан!
# НЕ добавляем в очередь, требуется ручная проверка
try:
income_api = self.client.income()
# Создаем клиента, если передана информация
@@ -245,16 +406,26 @@ class NaloGoService:
return None
except Exception as error:
# ВАЖНО: Аутентификация была успешной, запрос на создание чека УШЁЛ
# При таймауте чек МОГ быть создан на сервере — НЕ добавляем в очередь!
if self._is_service_unavailable(error):
logger.warning(
"NaloGO временно недоступен, чек будет отправлен позже "
f"(payment_id={payment_id}, сумма={amount}₽)"
error_msg = str(error)[:200]
logger.error(
f"⚠️ ТАЙМАУТ после успешной аутентификации! Чек МОГ быть создан! "
f"(payment_id={payment_id}, сумма={amount}₽). "
f"Сохраняем в очередь проверки. Проверьте lknpd.nalog.ru"
)
# Сохраняем в очередь для ручной проверки
await self._save_pending_verification(
name=name,
amount=amount,
quantity=quantity,
client_info=client_info,
payment_id=payment_id,
telegram_user_id=telegram_user_id,
amount_kopeks=amount_kopeks,
error_message=error_msg,
)
if queue_on_failure:
await self._queue_receipt(
name, amount, quantity, client_info, payment_id,
telegram_user_id, amount_kopeks
)
else:
logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
return None
@@ -276,6 +447,82 @@ class NaloGoService:
receipt_data["attempts"] = receipt_data.get("attempts", 0) + 1
return await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
async def find_duplicate_receipt(
self,
amount: float,
created_at: datetime,
time_window_minutes: int = 10,
) -> Optional[str]:
"""Проверяет, не был ли уже создан чек с такой суммой в заданном временном окне.
Используется для защиты от дублей при таймаутах когда сервер создал чек,
но ответ не вернулся.
Args:
amount: Сумма чека в рублях
created_at: Время создания записи в очереди
time_window_minutes: Окно поиска в минутах (±)
Returns:
UUID чека если дубликат найден, None если не найден
"""
if not self.configured:
return None
try:
# Запрашиваем чеки за день когда был создан запрос
from_date = created_at.date()
to_date = from_date + timedelta(days=1)
incomes = await self.get_incomes(
from_date=from_date,
to_date=to_date,
limit=50,
)
if not incomes:
return None
# Ищем чек с такой же суммой в пределах временного окна
for income in incomes:
income_amount = float(income.get("totalAmount", income.get("amount", 0)))
# Проверяем сумму (с погрешностью 0.01)
if abs(income_amount - amount) > 0.01:
continue
# Проверяем время
operation_time_str = income.get("operationTime")
if operation_time_str:
try:
from dateutil.parser import isoparse
operation_time = isoparse(operation_time_str)
# Убираем timezone для сравнения
if operation_time.tzinfo:
operation_time = operation_time.replace(tzinfo=None)
created_at_naive = created_at.replace(tzinfo=None) if created_at.tzinfo else created_at
time_diff = abs((operation_time - created_at_naive).total_seconds())
if time_diff <= time_window_minutes * 60:
receipt_uuid = income.get("approvedReceiptUuid", income.get("receiptUuid"))
if receipt_uuid:
logger.info(
f"Найден дубликат чека: {receipt_uuid} "
f"(сумма={income_amount}₽, время={operation_time}, "
f"разница={time_diff:.0f}с)"
)
return receipt_uuid
except Exception as parse_error:
logger.debug(f"Ошибка парсинга времени чека: {parse_error}")
continue
return None
except Exception as error:
logger.warning(f"Ошибка проверки дубликата чека: {error}")
return None
async def get_incomes(
self,
from_date: Optional[date] = None,
+4
View File
@@ -14,6 +14,8 @@ from .mulenpay import MulenPayPaymentMixin
from .pal24 import Pal24PaymentMixin
from .platega import PlategaPaymentMixin
from .wata import WataPaymentMixin
from .cloudpayments import CloudPaymentsPaymentMixin
from .freekassa import FreekassaPaymentMixin
__all__ = [
"PaymentCommonMixin",
@@ -26,4 +28,6 @@ __all__ = [
"Pal24PaymentMixin",
"PlategaPaymentMixin",
"WataPaymentMixin",
"CloudPaymentsPaymentMixin",
"FreekassaPaymentMixin",
]
-7
View File
@@ -71,15 +71,8 @@ class PaymentCommonMixin:
),
)
# Кнопка активации подписки (всегда отображается)
activate_subscription_button = build_miniapp_or_callback_button(
text="🚀 Активировать подписку",
callback_data="menu_buy" # Используем ту же callback_data что и "Купить подписку"
)
keyboard_rows: list[list[InlineKeyboardButton]] = [
[first_button],
[activate_subscription_button]
]
# Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему.
+533
View File
@@ -0,0 +1,533 @@
"""Mixin для интеграции с Freekassa."""
from __future__ import annotations
import json
import uuid
import logging
from datetime import datetime, timedelta
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.freekassa_service import freekassa_service
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
from app.utils.payment_logger import payment_logger as logger
class FreekassaPaymentMixin:
"""Mixin для работы с платежами Freekassa."""
async def create_freekassa_payment(
self,
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
description: str = "Пополнение баланса",
email: Optional[str] = None,
language: str = "ru",
) -> Optional[Dict[str, Any]]:
"""
Создает платеж Freekassa.
Args:
db: Сессия БД
user_id: ID пользователя
amount_kopeks: Сумма в копейках
description: Описание платежа
email: Email пользователя
language: Язык интерфейса
Returns:
Словарь с данными платежа или None при ошибке
"""
if not settings.is_freekassa_enabled():
logger.error("Freekassa не настроен")
return None
# Валидация лимитов
if amount_kopeks < settings.FREEKASSA_MIN_AMOUNT_KOPEKS:
logger.warning(
"Freekassa: сумма %s меньше минимальной %s",
amount_kopeks,
settings.FREEKASSA_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.FREEKASSA_MAX_AMOUNT_KOPEKS:
logger.warning(
"Freekassa: сумма %s больше максимальной %s",
amount_kopeks,
settings.FREEKASSA_MAX_AMOUNT_KOPEKS,
)
return None
# Генерируем уникальный order_id
order_id = f"fk_{user_id}_{uuid.uuid4().hex[:12]}"
amount_rubles = amount_kopeks / 100
currency = settings.FREEKASSA_CURRENCY
# Срок действия платежа
expires_at = datetime.utcnow() + timedelta(
seconds=settings.FREEKASSA_PAYMENT_TIMEOUT_SECONDS
)
# Метаданные
metadata = {
"user_id": user_id,
"amount_kopeks": amount_kopeks,
"description": description,
"language": language,
"type": "balance_topup",
}
try:
# Выбираем способ создания платежа: API или форма
if settings.FREEKASSA_USE_API:
# Используем API для создания заказа (нужно для NSPK СБП)
payment_url = await freekassa_service.create_order_and_get_url(
order_id=order_id,
amount=amount_rubles,
currency=currency,
email=email,
payment_system_id=settings.FREEKASSA_PAYMENT_SYSTEM_ID,
)
logger.info(
"Freekassa API: создан заказ order_id=%s, url=%s",
order_id,
payment_url,
)
else:
# Генерируем URL для формы оплаты (стандартный способ)
payment_url = freekassa_service.build_payment_url(
order_id=order_id,
amount=amount_rubles,
currency=currency,
email=email,
lang=language,
)
# Импортируем CRUD модуль
freekassa_crud = import_module("app.database.crud.freekassa")
# Сохраняем в БД
local_payment = await freekassa_crud.create_freekassa_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
expires_at=expires_at,
metadata_json=json.dumps(metadata, ensure_ascii=False),
)
logger.info(
"Freekassa: создан платеж order_id=%s, user_id=%s, amount=%s %s, use_api=%s",
order_id,
user_id,
amount_rubles,
currency,
settings.FREEKASSA_USE_API,
)
return {
"order_id": order_id,
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_rubles,
"currency": currency,
"payment_url": payment_url,
"expires_at": expires_at.isoformat(),
"local_payment_id": local_payment.id,
}
except Exception as e:
logger.exception("Freekassa: ошибка создания платежа: %s", e)
return None
async def process_freekassa_webhook(
self,
db: AsyncSession,
*,
merchant_id: int,
amount: float,
order_id: str,
sign: str,
intid: str,
cur_id: Optional[int] = None,
client_ip: str,
) -> bool:
"""
Обрабатывает webhook от Freekassa.
Args:
db: Сессия БД
merchant_id: ID магазина (MERCHANT_ID)
amount: Сумма платежа (AMOUNT)
order_id: Номер заказа (MERCHANT_ORDER_ID)
sign: Подпись (SIGN)
intid: ID транзакции Freekassa
cur_id: ID валюты/платежной системы (CUR_ID)
client_ip: IP клиента
Returns:
True если платеж успешно обработан
"""
try:
# Проверка IP
if not freekassa_service.verify_webhook_ip(client_ip):
logger.warning("Freekassa webhook: недоверенный IP %s", client_ip)
return False
# Проверка подписи
if not freekassa_service.verify_webhook_signature(
merchant_id, amount, order_id, sign
):
logger.warning(
"Freekassa webhook: неверная подпись для order_id=%s", order_id
)
return False
# Импортируем CRUD модуль
freekassa_crud = import_module("app.database.crud.freekassa")
# Получаем платеж из БД
payment = await freekassa_crud.get_freekassa_payment_by_order_id(
db, order_id
)
if not payment:
logger.warning(
"Freekassa webhook: платеж не найден order_id=%s", order_id
)
return False
# Проверка дублирования
if payment.is_paid:
logger.info(
"Freekassa webhook: платеж уже обработан order_id=%s", order_id
)
return True
# Проверка суммы
expected_amount = payment.amount_kopeks / 100
if abs(amount - expected_amount) > 0.01:
logger.warning(
"Freekassa webhook: несоответствие суммы ожидалось=%s, получено=%s",
expected_amount,
amount,
)
return False
# Обновляем статус платежа
callback_payload = {
"merchant_id": merchant_id,
"amount": amount,
"order_id": order_id,
"intid": intid,
"cur_id": cur_id,
}
payment = await freekassa_crud.update_freekassa_payment_status(
db=db,
payment=payment,
status="success",
is_paid=True,
freekassa_order_id=intid,
payment_system_id=cur_id,
callback_payload=callback_payload,
)
# Финализируем платеж (начисляем баланс, создаем транзакцию)
return await self._finalize_freekassa_payment(
db, payment, intid=intid, trigger="webhook"
)
except Exception as e:
logger.exception("Freekassa webhook: ошибка обработки: %s", e)
return False
async def _finalize_freekassa_payment(
self,
db: AsyncSession,
payment: Any,
*,
intid: Optional[str],
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
payment_module = import_module("app.services.payment_service")
if payment.transaction_id:
logger.info(
"Freekassa платеж %s уже привязан к транзакции (trigger=%s)",
payment.order_id,
trigger,
)
return True
# Получаем пользователя
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error(
"Пользователь %s не найден для Freekassa платежа %s (trigger=%s)",
payment.user_id,
payment.order_id,
trigger,
)
return False
# Создаем транзакцию
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через Freekassa (#{intid or payment.order_id})",
payment_method=PaymentMethod.FREEKASSA,
external_id=str(intid) if intid else payment.order_id,
is_completed=True,
)
# Связываем платеж с транзакцией
freekassa_crud = import_module("app.database.crud.freekassa")
await freekassa_crud.update_freekassa_payment_status(
db=db,
payment=payment,
status=payment.status,
transaction_id=transaction.id,
)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
# Начисляем баланс
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "Первое пополнение" if was_first_topup else "Пополнение"
await db.commit()
# Обработка реферального пополнения
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения Freekassa: %s", error
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
await db.refresh(payment)
# Отправка уведомления админам
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки админ уведомления Freekassa: %s", error
)
# Отправка уведомления пользователю
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
display_name = settings.get_freekassa_display_name()
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
f"💳 Способ: {display_name}\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления пользователю Freekassa: %s", error
)
# Автопокупка подписки
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER",
"У вас есть незавершенное оформление подписки. Вернуться?",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"BALANCE_TOPUP_CART_BUTTON",
"🛒 Продолжить оформление",
),
callback_data="return_to_saved_cart",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
chat_id=user.telegram_id,
text=(
"✅ Баланс пополнен на "
f"{settings.format_price(payment.amount_kopeks)}!\n\n"
f"{cart_message}"
),
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
user.id,
error,
exc_info=True,
)
logger.info(
"✅ Обработан Freekassa платеж %s для пользователя %s (trigger=%s)",
payment.order_id,
payment.user_id,
trigger,
)
return True
async def check_freekassa_payment_status(
self,
db: AsyncSession,
order_id: str,
) -> Optional[Dict[str, Any]]:
"""
Проверяет статус платежа через API.
Args:
db: Сессия БД
order_id: Номер заказа
Returns:
Данные о статусе платежа
"""
try:
status_data = await freekassa_service.get_order_status(order_id)
return status_data
except Exception as e:
logger.exception("Freekassa: ошибка проверки статуса: %s", e)
return None
async def get_freekassa_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
"""
Проверяет статус платежа Freekassa по локальному ID.
Freekassa не предоставляет API для проверки статуса платежа,
поэтому возвращаем текущее состояние из БД.
Args:
db: Сессия БД
local_payment_id: Внутренний ID платежа
Returns:
Dict с информацией о платеже или None если не найден
"""
freekassa_crud = import_module("app.database.crud.freekassa")
payment = await freekassa_crud.get_freekassa_payment_by_id(db, local_payment_id)
if not payment:
logger.warning("Freekassa payment not found: id=%s", local_payment_id)
return None
# Freekassa не имеет API для проверки статуса,
# информация приходит только через webhook
return {
"payment": payment,
"status": payment.status or "pending",
"is_paid": payment.is_paid,
}
+3 -6
View File
@@ -667,13 +667,10 @@ class YooKassaPaymentMixin:
)
notification_service = AdminNotificationService(self.bot)
# Обновляем пользователя, чтобы избежать проблем с ленивой загрузкой
from app.database.crud.user import get_user_by_id
refreshed_user = await get_user_by_id(db, user.id)
# Перезагрузка user при lazy-loading ошибке
# происходит внутри send_balance_topup_notification
await notification_service.send_balance_topup_notification(
refreshed_user or user,
user,
transaction,
old_balance,
topup_status=topup_status,
+2
View File
@@ -29,6 +29,7 @@ from app.services.payment import (
WataPaymentMixin,
)
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
from app.services.payment.freekassa import FreekassaPaymentMixin
from app.services.yookassa_service import YooKassaService
from app.services.wata_service import WataService
from app.services.cloudpayments_service import CloudPaymentsService
@@ -297,6 +298,7 @@ class PaymentService(
PlategaPaymentMixin,
WataPaymentMixin,
CloudPaymentsPaymentMixin,
FreekassaPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -19,6 +19,7 @@ from app.database.database import AsyncSessionLocal
from app.database.models import (
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
MulenPayPayment,
Pal24Payment,
@@ -66,6 +67,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.CRYPTOBOT,
PaymentMethod.PLATEGA,
PaymentMethod.CLOUDPAYMENTS,
PaymentMethod.FREEKASSA,
}
)
@@ -79,6 +81,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.CRYPTOBOT,
PaymentMethod.PLATEGA,
PaymentMethod.CLOUDPAYMENTS,
PaymentMethod.FREEKASSA,
}
)
@@ -100,6 +103,8 @@ def method_display_name(method: PaymentMethod) -> str:
return "Heleket"
if method == PaymentMethod.CLOUDPAYMENTS:
return "CloudPayments"
if method == PaymentMethod.FREEKASSA:
return "Freekassa"
if method == PaymentMethod.TELEGRAM_STARS:
return "Telegram Stars"
return method.value
@@ -122,6 +127,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
return settings.is_heleket_enabled()
if method == PaymentMethod.CLOUDPAYMENTS:
return settings.is_cloudpayments_enabled()
if method == PaymentMethod.FREEKASSA:
return settings.is_freekassa_enabled()
return False
@@ -362,6 +369,13 @@ def _is_cloudpayments_pending(payment: CloudPaymentsPayment) -> bool:
return status in {"pending", "authorized"}
def _is_freekassa_pending(payment: FreekassaPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or "").lower()
return status in {"pending", "created", "processing"}
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
payload = payment.payload or ""
match = re.search(r"_(\d+)$", payload)
@@ -621,6 +635,31 @@ async def _fetch_cloudpayments_payments(db: AsyncSession, cutoff: datetime) -> L
return records
async def _fetch_freekassa_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(FreekassaPayment)
.options(selectinload(FreekassaPayment.user))
.where(FreekassaPayment.created_at >= cutoff)
.order_by(desc(FreekassaPayment.created_at))
)
result = await db.execute(stmt)
records: List[PendingPayment] = []
for payment in result.scalars().all():
if not _is_freekassa_pending(payment):
continue
record = _build_record(
PaymentMethod.FREEKASSA,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
stmt = (
select(Transaction)
@@ -666,6 +705,7 @@ async def list_recent_pending_payments(
await _fetch_heleket_payments(db, cutoff),
await _fetch_cryptobot_payments(db, cutoff),
await _fetch_cloudpayments_payments(db, cutoff),
await _fetch_freekassa_payments(db, cutoff),
await _fetch_stars_transactions(db, cutoff),
)
@@ -806,6 +846,20 @@ async def get_payment_record(
is_paid=bool(payment.is_paid),
)
if method == PaymentMethod.FREEKASSA:
payment = await db.get(FreekassaPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=["user"])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or "",
is_paid=bool(payment.is_paid),
)
if method == PaymentMethod.TELEGRAM_STARS:
transaction = await db.get(Transaction, local_payment_id)
if not transaction:
@@ -860,6 +914,9 @@ async def run_manual_check(
elif method == PaymentMethod.CLOUDPAYMENTS:
result = await payment_service.get_cloudpayments_payment_status(db, local_payment_id)
payment = result.get("payment") if result else None
elif method == PaymentMethod.FREEKASSA:
result = await payment_service.get_freekassa_payment_status(db, local_payment_id)
payment = result.get("payment") if result else None
else:
logger.warning("Manual check requested for unsupported method %s", method)
return None
+6 -1
View File
@@ -51,7 +51,12 @@ class PromoCodeService:
existing_use = await check_user_promocode_usage(db, user_id, promocode.id)
if existing_use:
return {"success": False, "error": "already_used_by_user"}
# Проверка "только для первой покупки"
if getattr(promocode, 'first_purchase_only', False):
if getattr(user, 'has_had_paid_subscription', False):
return {"success": False, "error": "not_first_purchase"}
balance_before_kopeks = user.balance_kopeks
result_description = await self._apply_promocode_effects(db, user, promocode)
@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.subscription import extend_subscription
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.crud.user import get_user_by_id, subtract_user_balance
from app.database.models import Subscription, TransactionType, User
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
@@ -73,6 +73,17 @@ async def _prepare_auto_purchase(
)
return None
# Перезагружаем user с нужными связями (user_promo_groups),
# т.к. после db.refresh() в payment-сервисах связи сбрасываются
fresh_user = await get_user_by_id(db, user.id)
if not fresh_user:
logger.warning(
"🔁 Автопокупка: не удалось перезагрузить пользователя %s",
user.telegram_id,
)
return None
user = fresh_user
miniapp_service = MiniAppSubscriptionPurchaseService()
context = await miniapp_service.build_options(db, user)
@@ -123,6 +134,57 @@ def _safe_int(value: Optional[object], default: int = 0) -> int:
return default
def _apply_promo_discount_for_tariff(price: int, discount_percent: int) -> int:
"""Применяет скидку промогруппы к цене тарифа."""
if discount_percent <= 0:
return price
discount = int(price * discount_percent / 100)
return max(0, price - discount)
async def _get_tariff_price_for_period(
db: AsyncSession,
user: User,
tariff_id: int,
period_days: int,
) -> Optional[int]:
"""Получает актуальную цену тарифа для заданного периода с учётом скидки пользователя."""
from app.database.crud.tariff import get_tariff_by_id
from app.utils.promo_offer import get_user_active_promo_discount_percent
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
logger.warning(
"🔁 Автопокупка: тариф %s недоступен для пользователя %s",
tariff_id,
user.telegram_id,
)
return None
prices = tariff.period_prices or {}
base_price = prices.get(str(period_days))
if base_price is None:
logger.warning(
"🔁 Автопокупка: период %s дней недоступен для тарифа %s",
period_days,
tariff_id,
)
return None
# Получаем скидку пользователя
discount_percent = 0
promo_group = getattr(user, 'promo_group', None)
if promo_group:
discount_percent = getattr(promo_group, 'server_discount_percent', 0)
personal_discount = get_user_active_promo_discount_percent(user)
if personal_discount > discount_percent:
discount_percent = personal_discount
final_price = _apply_promo_discount_for_tariff(base_price, discount_percent)
return final_price
async def _prepare_auto_extend_context(
db: AsyncSession,
user: User,
@@ -151,11 +213,6 @@ async def _prepare_auto_extend_context(
return None
period_days = _safe_int(cart_data.get("period_days"))
price_kopeks = _safe_int(
cart_data.get("total_price")
or cart_data.get("price")
or cart_data.get("final_price"),
)
if period_days <= 0:
logger.warning(
@@ -165,6 +222,30 @@ async def _prepare_auto_extend_context(
)
return None
# Если в корзине есть tariff_id - пересчитываем цену по актуальному тарифу
tariff_id = cart_data.get("tariff_id")
if tariff_id:
tariff_id = _safe_int(tariff_id)
price_kopeks = await _get_tariff_price_for_period(db, user, tariff_id, period_days)
if price_kopeks is None:
# Тариф недоступен или период отсутствует - используем сохранённую цену как fallback
price_kopeks = _safe_int(
cart_data.get("total_price")
or cart_data.get("price")
or cart_data.get("final_price"),
)
logger.warning(
"🔁 Автопокупка: не удалось пересчитать цену тарифа %s, используем сохранённую: %s",
tariff_id,
price_kopeks,
)
else:
price_kopeks = _safe_int(
cart_data.get("total_price")
or cart_data.get("price")
or cart_data.get("final_price"),
)
if price_kopeks <= 0:
logger.warning(
"🔁 Автопокупка: некорректная цена продления (%s) у пользователя %s",
@@ -173,7 +254,14 @@ async def _prepare_auto_extend_context(
)
return None
description = cart_data.get("description") or f"Продление подписки на {period_days} дней"
# Формируем описание с учётом тарифа
if tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, tariff_id)
tariff_name = tariff.name if tariff else "тариф"
description = cart_data.get("description") or f"Продление тарифа {tariff_name} на {period_days} дней"
else:
description = cart_data.get("description") or f"Продление подписки на {period_days} дней"
device_limit = cart_data.get("device_limit")
if device_limit is not None:
@@ -697,7 +785,7 @@ async def auto_activate_subscription_after_topup(
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
balance = user.balance_kopeks
available_periods = sorted([int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS], reverse=True)
available_periods = sorted(settings.get_available_subscription_periods(), reverse=True)
if not available_periods:
logger.warning("🔁 Автоактивация: нет доступных периодов подписки")
@@ -331,6 +331,11 @@ class SubscriptionRenewalService:
if devices_limit is None:
devices_limit = settings.DEFAULT_DEVICE_LIMIT
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
# поэтому не должен учитываться как платное устройство при продлении
if getattr(subscription, 'modem_enabled', False):
devices_limit = max(1, devices_limit - 1)
total_cost, details = await calculate_subscription_total_cost(
db,
period_days,
+19 -2
View File
@@ -396,11 +396,23 @@ class SubscriptionService:
await api.disable_user(user_uuid)
logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}")
return True
except Exception as e:
logger.error(f"Ошибка отключения RemnaWave пользователя: {e}")
return False
async def enable_remnawave_user(self, user_uuid: str) -> bool:
"""Включить пользователя в RemnaWave (реактивация)."""
try:
async with self.get_api_client() as api:
await api.enable_user(user_uuid)
logger.info(f"✅ Включен RemnaWave пользователь {user_uuid}")
return True
except Exception as e:
logger.error(f"Ошибка включения RemnaWave пользователя: {e}")
return False
async def revoke_subscription(
self,
db: AsyncSession,
@@ -720,6 +732,11 @@ class SubscriptionService:
else:
device_limit = forced_limit
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
# поэтому не должен учитываться как платное устройство при продлении
if getattr(subscription, 'modem_enabled', False):
device_limit = max(1, device_limit - 1)
devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
devices_discount_percent = _resolve_discount_percent(
user,
+9
View File
@@ -84,6 +84,7 @@ class BotConfigurationService:
"CRYPTOBOT": "🪙 CryptoBot",
"HELEKET": "🪙 Heleket",
"CLOUDPAYMENTS": "💳 CloudPayments",
"FREEKASSA": "💳 Freekassa",
"YOOKASSA": "🟣 YooKassa",
"PLATEGA": "💳 {platega_name}",
"TRIBUTE": "🎁 Tribute",
@@ -140,6 +141,7 @@ class BotConfigurationService:
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
"CLOUDPAYMENTS": "CloudPayments: оплата банковскими картами, Public ID, API Secret и вебхуки.",
"FREEKASSA": "Freekassa: ID магазина, API ключ, секретные слова и вебхуки.",
"PLATEGA": "{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.",
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
@@ -313,6 +315,7 @@ class BotConfigurationService:
"CRYPTOBOT_": "CRYPTOBOT",
"HELEKET_": "HELEKET",
"CLOUDPAYMENTS_": "CLOUDPAYMENTS",
"FREEKASSA_": "FREEKASSA",
"PLATEGA_": "PLATEGA",
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",
@@ -416,6 +419,12 @@ class BotConfigurationService:
ChoiceOption(4, "4 — НДС 20%"),
ChoiceOption(5, "5 — НДС 10/110"),
ChoiceOption(6, "6 — НДС 20/120"),
ChoiceOption(7, "7 — НДС 5%"),
ChoiceOption(8, "8 — НДС 7%"),
ChoiceOption(9, "9 — НДС 5/105"),
ChoiceOption(10, "10 — НДС 7/107"),
ChoiceOption(11, "11 — НДС 22%"),
ChoiceOption(12, "12 — НДС 22/122"),
],
"MULENPAY_LANGUAGE": [
ChoiceOption("ru", "🇷🇺 Русский"),
@@ -6,9 +6,6 @@ import logging
import asyncio
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from decimal import Decimal
import aiohttp
from app.config import settings
from app.services.admin_notification_service import AdminNotificationService
+69 -38
View File
@@ -1,7 +1,6 @@
import json
import logging
from typing import Optional, Dict, Any
from datetime import timedelta
import redis.asyncio as redis
@@ -9,104 +8,136 @@ from app.config import settings
logger = logging.getLogger(__name__)
class UserCartService:
"""
Сервис для работы с корзиной пользователя через Redis
Сервис для работы с корзиной пользователя через Redis.
Использует ленивую инициализацию Redis-клиента для graceful fallback
при недоступности Redis.
"""
def __init__(self):
self.redis_client = None
self._setup_redis()
def _setup_redis(self):
"""Инициализация Redis клиента"""
self._redis_client: Optional[redis.Redis] = None
self._initialized: bool = False
def _get_redis_client(self) -> Optional[redis.Redis]:
"""Ленивая инициализация Redis клиента."""
if self._initialized:
return self._redis_client
try:
self.redis_client = redis.from_url(settings.REDIS_URL)
self._redis_client = redis.from_url(settings.REDIS_URL)
self._initialized = True
logger.debug("Redis клиент для корзины инициализирован")
except Exception as e:
logger.error(f"Ошибка подключения к Redis: {e}")
raise
async def save_user_cart(self, user_id: int, cart_data: Dict[str, Any], ttl: int = 3600) -> bool:
logger.warning(f"Не удалось подключиться к Redis для корзины: {e}")
self._redis_client = None
self._initialized = True
return self._redis_client
async def save_user_cart(
self, user_id: int, cart_data: Dict[str, Any], ttl: Optional[int] = None
) -> bool:
"""
Сохранить корзину пользователя в Redis
Сохранить корзину пользователя в Redis.
Args:
user_id: ID пользователя
cart_data: Данные корзины (параметры подписки)
ttl: Время жизни ключа в секундах (по умолчанию 1 час)
ttl: Время жизни ключа в секундах (по умолчанию из settings.CART_TTL_SECONDS)
Returns:
bool: Успешность сохранения
"""
client = self._get_redis_client()
if client is None:
return False
try:
key = f"user_cart:{user_id}"
json_data = json.dumps(cart_data, ensure_ascii=False)
await self.redis_client.setex(key, ttl, json_data)
logger.info(f"Корзина пользователя {user_id} сохранена в Redis")
effective_ttl = ttl if ttl is not None else settings.CART_TTL_SECONDS
await client.setex(key, effective_ttl, json_data)
logger.debug(f"Корзина пользователя {user_id} сохранена в Redis")
return True
except Exception as e:
logger.error(f"Ошибка сохранения корзины пользователя {user_id}: {e}")
return False
async def get_user_cart(self, user_id: int) -> Optional[Dict[str, Any]]:
"""
Получить корзину пользователя из Redis
Получить корзину пользователя из Redis.
Args:
user_id: ID пользователя
Returns:
dict: Данные корзины или None
"""
client = self._get_redis_client()
if client is None:
return None
try:
key = f"user_cart:{user_id}"
json_data = await self.redis_client.get(key)
json_data = await client.get(key)
if json_data:
cart_data = json.loads(json_data)
logger.info(f"Корзина пользователя {user_id} загружена из Redis")
logger.debug(f"Корзина пользователя {user_id} загружена из Redis")
return cart_data
return None
except Exception as e:
logger.error(f"Ошибка получения корзины пользователя {user_id}: {e}")
return None
async def delete_user_cart(self, user_id: int) -> bool:
"""
Удалить корзину пользователя из Redis
Удалить корзину пользователя из Redis.
Args:
user_id: ID пользователя
Returns:
bool: Успешность удаления
"""
client = self._get_redis_client()
if client is None:
return False
try:
key = f"user_cart:{user_id}"
result = await self.redis_client.delete(key)
result = await client.delete(key)
if result:
logger.info(f"Корзина пользователя {user_id} удалена из Redis")
logger.debug(f"Корзина пользователя {user_id} удалена из Redis")
return bool(result)
except Exception as e:
logger.error(f"Ошибка удаления корзины пользователя {user_id}: {e}")
return False
async def has_user_cart(self, user_id: int) -> bool:
"""
Проверить наличие корзины у пользователя
Проверить наличие корзины у пользователя.
Args:
user_id: ID пользователя
Returns:
bool: Наличие корзины
"""
client = self._get_redis_client()
if client is None:
return False
try:
key = f"user_cart:{user_id}"
exists = await self.redis_client.exists(key)
exists = await client.exists(key)
return bool(exists)
except Exception as e:
logger.error(f"Ошибка проверки наличия корзины пользователя {user_id}: {e}")
return False
# Глобальный экземпляр сервиса
user_cart_service = UserCartService()
# Глобальный экземпляр сервиса (инициализация Redis отложена)
user_cart_service = UserCartService()
+2 -2
View File
@@ -224,7 +224,7 @@ class UserService:
db: AsyncSession,
min_balance_kopeks: int,
page: int = 1,
limit: int = 20,
limit: int = 10,
) -> Dict[str, Any]:
"""Возвращает пользователей с истекшей подпиской и достаточным балансом."""
try:
@@ -247,7 +247,7 @@ class UserService:
.limit(limit)
)
result = await db.execute(query)
users = result.scalars().all()
users = result.scalars().unique().all()
count_query = (
select(func.count(User.id))
+192
View File
@@ -0,0 +1,192 @@
from __future__ import annotations
import asyncio
import hashlib
import hmac
import json
import logging
from dataclasses import dataclass
from typing import Any, Optional
import aiohttp
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.webhook import (
get_active_webhooks_for_event,
record_webhook_delivery,
update_webhook_stats,
)
logger = logging.getLogger(__name__)
@dataclass
class DeliveryResult:
"""Результат доставки webhook."""
webhook: Any
event_type: str
payload: dict[str, Any]
status: str
response_status: Optional[int] = None
response_body: Optional[str] = None
error_message: Optional[str] = None
class WebhookService:
"""Сервис для отправки webhooks."""
def __init__(self) -> None:
self._session: Optional[aiohttp.ClientSession] = None
async def _get_session(self) -> aiohttp.ClientSession:
"""Получить или создать HTTP сессию."""
if self._session is None or self._session.closed:
timeout = aiohttp.ClientTimeout(total=10, connect=5)
self._session = aiohttp.ClientSession(timeout=timeout)
return self._session
async def close(self) -> None:
"""Закрыть HTTP сессию."""
if self._session and not self._session.closed:
await self._session.close()
def _sign_payload(self, payload: str, secret: str) -> str:
"""Подписать payload с помощью секрета."""
return hmac.new(
secret.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
async def send_webhook(
self,
db: AsyncSession,
event_type: str,
payload: dict[str, Any],
) -> None:
"""Отправить webhook для события."""
webhooks = await get_active_webhooks_for_event(db, event_type)
if not webhooks:
logger.debug("No active webhooks for event type: %s", event_type)
return
# Выполняем HTTP запросы параллельно (без операций с БД)
tasks = [
self._deliver_webhook_http(webhook, event_type, payload)
for webhook in webhooks
]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Записываем результаты в БД последовательно (избегаем concurrent session access)
for result in results:
if isinstance(result, Exception):
logger.exception("Unexpected error during webhook delivery: %s", result)
continue
if isinstance(result, DeliveryResult):
await self._record_result(db, result)
async def _deliver_webhook_http(
self,
webhook: Any,
event_type: str,
payload: dict[str, Any],
) -> DeliveryResult:
"""Выполнить HTTP доставку webhook (без операций с БД)."""
payload_json = json.dumps(payload, default=str, ensure_ascii=False)
headers = {
"Content-Type": "application/json",
"X-Webhook-Event": event_type,
"X-Webhook-Id": str(webhook.id),
}
# Добавляем подпись, если есть секрет
if webhook.secret:
signature = self._sign_payload(payload_json, webhook.secret)
headers["X-Webhook-Signature"] = f"sha256={signature}"
try:
session = await self._get_session()
async with session.post(
webhook.url,
data=payload_json,
headers=headers,
) as response:
response_body = await response.text()
# Ограничиваем размер ответа для хранения
if len(response_body) > 1000:
response_body = response_body[:1000] + "... (truncated)"
status = "success" if 200 <= response.status < 300 else "failed"
error_message = None
if status == "failed":
error_message = f"HTTP {response.status}: {response_body[:500]}"
return DeliveryResult(
webhook=webhook,
event_type=event_type,
payload=payload,
status=status,
response_status=response.status,
response_body=response_body,
error_message=error_message,
)
except asyncio.TimeoutError:
return DeliveryResult(
webhook=webhook,
event_type=event_type,
payload=payload,
status="failed",
error_message="Request timeout",
)
except Exception as error:
return DeliveryResult(
webhook=webhook,
event_type=event_type,
payload=payload,
status="failed",
error_message=str(error),
)
async def _record_result(self, db: AsyncSession, result: DeliveryResult) -> None:
"""Записать результат доставки в БД (последовательно)."""
try:
await record_webhook_delivery(
db,
webhook_id=result.webhook.id,
event_type=result.event_type,
payload=result.payload,
status=result.status,
response_status=result.response_status,
response_body=result.response_body,
error_message=result.error_message,
)
await update_webhook_stats(db, result.webhook, result.status == "success")
if result.status == "success":
logger.info(
"Webhook %s delivered successfully to %s",
result.webhook.id,
result.webhook.url,
)
else:
logger.warning(
"Webhook %s delivery failed: %s",
result.webhook.id,
result.error_message,
)
except Exception as error:
logger.exception(
"Failed to record webhook delivery result for %s: %s",
result.webhook.id,
error,
)
# Глобальный экземпляр сервиса
webhook_service = WebhookService()

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