Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6488dcfcb2 | |||
| 9ec5f7f59e | |||
| 0621a3febc | |||
| ebd6bee05e | |||
| 119f463c36 | |||
| a3903a252e | |||
| 65ba50c2cf | |||
| cc54a7ad2f | |||
| 7b0403a307 | |||
| 142ff14a50 | |||
| 29a3b395b6 | |||
| 49871f82f3 | |||
| efa3a5d457 | |||
| 0b86f379b4 | |||
| 1cae7130bc | |||
| 45410168af | |||
| 5482e609f8 | |||
| e79f598d17 | |||
| 056070b6a4 | |||
| 8b53c73ce8 | |||
| e6ebf81752 | |||
| 11b8ab1959 | |||
| 17e9259eb1 | |||
| 02c30f8e7e | |||
| 15c7cc2a58 | |||
| f2dbab6171 | |||
| 17af51ce0b | |||
| 8f7fa76e6a | |||
| 4648a82da9 | |||
| 5be82f2d78 | |||
| 9e3aa23f69 | |||
| 46da31d89c | |||
| 5f219c33e6 | |||
| 94fcf20d17 | |||
| 9d39901f78 | |||
| 5cf3f2f76e | |||
| 2f90f9134d | |||
| c57de1081a | |||
| 33d5155a8d | |||
| 9828ff0845 | |||
| da6f746b09 | |||
| 165965d8ea | |||
| 916ad9d567 |
+4
-11
@@ -152,7 +152,7 @@ REMNAWAVE_API_KEY=your_api_key_here
|
||||
|
||||
# Тип авторизации: "api_key", "basic_auth", "caddy"
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
REMNAWAVE_CADDY_TOKEN=YWRtaW46cGFzc3dvcmQ=
|
||||
REMNAWAVE_CADDY_TOKEN=
|
||||
|
||||
# Для панелей с Basic Auth (опционально)
|
||||
REMNAWAVE_USERNAME=
|
||||
@@ -544,7 +544,6 @@ PAL24_SHOP_ID=
|
||||
PAL24_SIGNATURE_TOKEN=
|
||||
PAL24_BASE_URL=https://pal24.pro/api/v1/
|
||||
PAL24_WEBHOOK_PATH=/pal24-webhook
|
||||
PAL24_WEBHOOK_PORT=8084
|
||||
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS=10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS=100000000
|
||||
@@ -741,7 +740,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh,fa
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
@@ -830,7 +829,7 @@ WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
WEBHOOK_WORKERS=4
|
||||
WEBHOOK_ENQUEUE_TIMEOUT=0.1
|
||||
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
|
||||
BOT_RUN_MODE=polling # polling, webhook или both
|
||||
BOT_RUN_MODE=polling # polling или webhook
|
||||
|
||||
# ===== КОНКУРСНАЯ СИСТЕМА =====
|
||||
CONTESTS_ENABLED=false
|
||||
@@ -838,15 +837,9 @@ CONTESTS_BUTTON_VISIBLE=false
|
||||
# Реферальные конкурсы (турниры среди рефералов)
|
||||
REFERRAL_CONTESTS_ENABLED=false
|
||||
|
||||
# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# ===== АВТОПОКУПКА ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# Автоматическая покупка из сохранённой корзины после пополнения баланса
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
|
||||
# Умная автоактивация: система сама решает — продлить или создать подписку
|
||||
# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
|
||||
# Показывать предупреждение об активации подписки после пополнения баланса
|
||||
# Если true - после пополнения показывает сообщение с кнопками: "Активировать", "Продлить", "Добавить устройства"
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=false
|
||||
|
||||
# ===== КНОПКА АКТИВАЦИИ =====
|
||||
ACTIVATE_BUTTON_VISIBLE=false
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.7.2"
|
||||
".": "3.9.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,66 @@
|
||||
# Changelog
|
||||
|
||||
## [3.9.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.8.0...v3.9.0) (2026-02-09)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add lite mode functionality with endpoints for retrieval and update ([7b0403a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b0403a307702c24efefc5c14af8cb2fb7525671))
|
||||
* add Persian (fa) locale with complete translations ([29a3b39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29a3b395b6e67e4ce2437b75120b78c76b69ff4f))
|
||||
* allow tariff deletion with active subscriptions ([ebd6bee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebd6bee05ed7d9187de9394c64dfd745bb06b65a))
|
||||
* **localization:** add Persian (fa) locale support and wire it across app flows ([cc54a7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc54a7ad2fb98fe6e662e1923027f4989ae72868))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* nullify payment FK references before deleting transactions in user restoration ([0b86f37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b86f379b4e55e499ca3d189137e2aed865774b5))
|
||||
* prevent sync from overwriting end_date for non-ACTIVE panel users ([49871f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49871f82f37d84979ea9ec91055e3f046d5854be))
|
||||
* promo code max_uses=0 conversion and trial UX after promo activation ([1cae713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cae7130bc87493ab8c7691b3c22ead8189dab55))
|
||||
* skip users with active subscriptions in admin inactive cleanup ([e79f598](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e79f598d17ffa76372e6f88d2a498accf8175c76))
|
||||
* use selection.period.days instead of selection.period_days ([4541016](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/45410168afe683675003a1c41c17074a54ce04f1))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* cache logo file_id to avoid re-uploading on every message ([142ff14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/142ff14a502e629446be7d67fab880d12bee149d))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove "both" mode from BOT_RUN_MODE, keep only polling and webhook ([efa3a5d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efa3a5d4579f24dabeeba01a4f2e981144dd6022))
|
||||
* remove Flask, use FastAPI exclusively for all webhooks ([119f463](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/119f463c36a95685c3bc6cdf704e746b0ba20d56))
|
||||
* remove smart auto-activation & activation prompt, fix production bugs ([a3903a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a3903a252efdd0db4b42ca3fd6771f1627050a7f))
|
||||
|
||||
## [3.8.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.2...v3.8.0) (2026-02-08)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add admin device management endpoints ([c57de10](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c57de1081a9e905ba191f64c37221c36713c82a6))
|
||||
* add admin traffic packages and device limit management ([2f90f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f90f9134df58b8c0a329c20060efcf07d5d92f9))
|
||||
* add admin updates endpoint for bot and cabinet releases ([11b8ab1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11b8ab1959e83fafe405be0b76dfa3dd1580a68b))
|
||||
* add endpoint for updating user referral commission percent ([da6f746](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da6f746b093be8cdbf4e2889c50b35087fbc90de))
|
||||
* add enrichment data to CSV export ([f2dbab6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f2dbab617155cdc41573d885f0e55222e5b9825b))
|
||||
* add server-side sorting for enrichment columns ([15c7cc2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15c7cc2a58e1f1935d10712a981466629db251d1))
|
||||
* add system info endpoint for admin dashboard ([02c30f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02c30f8e7eb6ba90ed8983cfd82199a22b473bbf))
|
||||
* add traffic usage enrichment endpoint with devices, spending, dates, last node ([5cf3f2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5cf3f2f76eb2cd93282f845ea0850f6707bfcc09))
|
||||
* admin panel enhancements & bug fixes ([e6ebf81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebf81752499df8eb0a710072785e3d603dba33))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add debug logging for bulk device response structure ([46da31d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46da31d89c55c225dec9136d225f2db967cf8961))
|
||||
* add email field to traffic table for OAuth/email users ([94fcf20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94fcf20d17c54efd67fa7bd47eff1afdd1507e08))
|
||||
* add email/UUID fallback for OAuth user panel sync ([165965d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/165965d8ea60a002c061fd75f88b759f2da66d7d))
|
||||
* add enrichment device mapping debug logs ([5be82f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5be82f2d78aed9b54d74e86f261baa5655e5dcd9))
|
||||
* include additional devices in tariff renewal price and display ([17e9259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17e9259eb1d41dbf1d313b6a7d500f6458359393))
|
||||
* paginate bulk device endpoint to fetch all HWID devices ([4648a82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4648a82da959410603c92055bcde7f96131e0c29))
|
||||
* read bot version from pyproject.toml when VERSION env is not set ([9828ff0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9828ff0845ec1d199a6fa63fe490ad3570cf9c8f))
|
||||
* revert device pagination, add raw user data field discovery ([8f7fa76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f7fa76e6ab34a3ad2f61f4e1f06026fd3fbf4e3))
|
||||
* use bulk device endpoint instead of per-user calls ([5f219c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f219c33e6d49b0e3e4405a57f8344a4237f1002))
|
||||
* use correct pagination params (start/size) for bulk HWID devices ([17af51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17af51ce0bdfa45197384988d56960a1918ab709))
|
||||
* use per-user panel endpoints for reliable device counts and last node data ([9d39901](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d39901f78ece55c740a5df2603601e5d0b1caca))
|
||||
|
||||
## [3.7.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.1...v3.7.2) (2026-02-08)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.7.2" # x-release-please-version
|
||||
ARG VERSION="v3.9.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -160,7 +160,6 @@ docker compose logs
|
||||
| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
|
||||
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
|
||||
| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. |
|
||||
|
||||
### 2. Минимальные настройки для webhook
|
||||
|
||||
@@ -1012,7 +1011,7 @@ curl -I https://miniapp.domain.com
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------ |
|
||||
| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` |
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling` или `webhook`. |
|
||||
|
||||
[Полный список доступных параметров:](.env.example)
|
||||
|
||||
@@ -1022,7 +1021,7 @@ curl -I https://miniapp.domain.com
|
||||
|
||||
### 🤖 Режимы запуска бота
|
||||
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling` или `webhook`.
|
||||
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
|
||||
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
|
||||
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защащаться от перегрузок.
|
||||
@@ -1343,7 +1342,6 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 🔄 Автоплатёж с настройкой дня списания
|
||||
- 🎁 Реферальные и промо-бонусы
|
||||
- ⚡ **Быстрое пополнение** с кнопками быстрых сумм
|
||||
- 🔄 **Умная автоактивация** подписки после пополнения баланса
|
||||
|
||||
📱 **Управление подписками**
|
||||
|
||||
@@ -1530,7 +1528,6 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
|
||||
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
|
||||
- 💸 **Сервис автопроверки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
|
||||
- 🔄 **Умная автоактивация** - автоматическая активация подписки после пополнения баланса
|
||||
- 📝 **Ротация логов** - автоматическая очистка и архивация старых логов
|
||||
- 🎮 **Система конкурсов** - ежедневные игры и реферальные конкурсы с призами
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from .admin_stats import router as admin_stats_router
|
||||
from .admin_tariffs import router as admin_tariffs_router
|
||||
from .admin_tickets import router as admin_tickets_router
|
||||
from .admin_traffic import router as admin_traffic_router
|
||||
from .admin_updates import router as admin_updates_router
|
||||
from .admin_users import router as admin_users_router
|
||||
from .admin_wheel import router as admin_wheel_router
|
||||
from .auth import router as auth_router
|
||||
@@ -86,6 +87,7 @@ router.include_router(admin_payments_router)
|
||||
router.include_router(admin_promo_offers_router)
|
||||
router.include_router(admin_remnawave_router)
|
||||
router.include_router(admin_email_templates_router)
|
||||
router.include_router(admin_updates_router)
|
||||
router.include_router(admin_traffic_router)
|
||||
|
||||
# WebSocket route
|
||||
|
||||
@@ -337,7 +337,7 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
|
||||
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
|
||||
}
|
||||
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua']
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
@@ -363,13 +363,16 @@ async def create_promocode_endpoint(
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo code with this code already exists')
|
||||
|
||||
# 0 means unlimited — convert to large number for is_valid check (current_uses < max_uses)
|
||||
effective_max_uses = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
promocode = await create_promocode(
|
||||
db,
|
||||
code=normalized_code,
|
||||
type=payload.type,
|
||||
balance_bonus_kopeks=payload.balance_bonus_kopeks,
|
||||
subscription_days=payload.subscription_days,
|
||||
max_uses=payload.max_uses,
|
||||
max_uses=effective_max_uses,
|
||||
valid_until=normalized_valid_until,
|
||||
created_by=admin.id,
|
||||
)
|
||||
@@ -426,7 +429,7 @@ async def update_promocode_endpoint(
|
||||
updates['subscription_days'] = payload.subscription_days
|
||||
|
||||
if payload.max_uses is not None:
|
||||
updates['max_uses'] = payload.max_uses
|
||||
updates['max_uses'] = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
if payload.valid_from is not None:
|
||||
updates['valid_from'] = _normalize_datetime(payload.valid_from)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Admin routes for statistics dashboard in cabinet."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -22,12 +24,15 @@ from app.database.models import (
|
||||
User,
|
||||
)
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_start_time = time.time()
|
||||
|
||||
router = APIRouter(prefix='/admin/stats', tags=['Cabinet Admin Stats'])
|
||||
|
||||
|
||||
@@ -142,6 +147,16 @@ class DashboardStats(BaseModel):
|
||||
tariff_stats: TariffStats | None = None
|
||||
|
||||
|
||||
class SystemInfoResponse(BaseModel):
|
||||
"""System information for admin dashboard."""
|
||||
|
||||
bot_version: str
|
||||
python_version: str
|
||||
uptime_seconds: int
|
||||
users_total: int
|
||||
subscriptions_active: int
|
||||
|
||||
|
||||
# ============ Extended Stats Schemas ============
|
||||
|
||||
|
||||
@@ -309,6 +324,38 @@ async def get_dashboard_stats(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/system-info', response_model=SystemInfoResponse)
|
||||
async def get_system_info(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get system information for admin dashboard."""
|
||||
try:
|
||||
users_total_result = await db.execute(select(func.count()).select_from(User))
|
||||
users_total = users_total_result.scalar() or 0
|
||||
|
||||
subs_active_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
subscriptions_active = subs_active_result.scalar() or 0
|
||||
|
||||
return SystemInfoResponse(
|
||||
bot_version=version_service.current_version,
|
||||
python_version=sys.version.split()[0],
|
||||
uptime_seconds=int(time.time() - _start_time),
|
||||
users_total=users_total,
|
||||
subscriptions_active=subscriptions_active,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get system info: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load system information',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/nodes', response_model=NodesOverview)
|
||||
async def get_nodes_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
|
||||
@@ -412,21 +412,14 @@ async def delete_existing_tariff(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Check if tariff has subscriptions
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
if subs_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Cannot delete tariff with {subs_count} active subscriptions',
|
||||
)
|
||||
|
||||
await delete_tariff(db, tariff)
|
||||
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name}')
|
||||
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name} (affected subscriptions: {subs_count})')
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
return {'message': 'Tariff deleted successfully'}
|
||||
return {'message': 'Tariff deleted successfully', 'affected_subscriptions': subs_count}
|
||||
|
||||
|
||||
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
|
||||
|
||||
@@ -12,20 +12,22 @@ from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User
|
||||
from app.database.models import Subscription, Transaction, TransactionType, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.traffic import (
|
||||
ExportCsvRequest,
|
||||
ExportCsvResponse,
|
||||
TrafficEnrichmentResponse,
|
||||
TrafficNodeInfo,
|
||||
TrafficUsageResponse,
|
||||
UserTrafficEnrichment,
|
||||
UserTrafficItem,
|
||||
)
|
||||
|
||||
@@ -44,6 +46,7 @@ _cache_lock = asyncio.Lock()
|
||||
|
||||
# Valid sort fields for the GET endpoint
|
||||
_SORT_FIELDS = frozenset({'total_bytes', 'full_name', 'tariff_name', 'device_limit', 'traffic_limit_gb'})
|
||||
_ENRICHMENT_SORT_FIELDS = frozenset({'connected', 'total_spent', 'sub_start', 'sub_end', 'last_node'})
|
||||
|
||||
|
||||
def _get_status(sub) -> str | None:
|
||||
@@ -186,9 +189,14 @@ def _build_traffic_items(
|
||||
|
||||
full_name = user.full_name
|
||||
username = user.username
|
||||
email = user.email
|
||||
|
||||
if search_lower:
|
||||
if search_lower not in (full_name or '').lower() and search_lower not in (username or '').lower():
|
||||
if (
|
||||
search_lower not in (full_name or '').lower()
|
||||
and search_lower not in (username or '').lower()
|
||||
and search_lower not in (email or '').lower()
|
||||
):
|
||||
continue
|
||||
|
||||
sub = user.subscription
|
||||
@@ -223,6 +231,7 @@ def _build_traffic_items(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=username,
|
||||
email=email,
|
||||
full_name=full_name,
|
||||
tariff_name=tariff_name,
|
||||
subscription_status=subscription_status,
|
||||
@@ -322,15 +331,31 @@ async def get_traffic_usage(
|
||||
if not node_filter:
|
||||
node_filter = None # No valid nodes matched, treat as "all nodes"
|
||||
|
||||
# Validate sort_by: allow known fields + 'node_<uuid>' for dynamic node columns
|
||||
# Validate sort_by: allow known fields + enrichment fields + 'node_<uuid>'
|
||||
is_node_sort = sort_by.startswith('node_') and sort_by[5:] in all_node_uuids
|
||||
if sort_by not in _SORT_FIELDS and not is_node_sort:
|
||||
is_enrichment_sort = sort_by in _ENRICHMENT_SORT_FIELDS
|
||||
if sort_by not in _SORT_FIELDS and not is_node_sort and not is_enrichment_sort:
|
||||
sort_by = 'total_bytes'
|
||||
|
||||
# For enrichment sort, build items unsorted then sort by enrichment field
|
||||
effective_sort = 'total_bytes' if is_enrichment_sort else sort_by
|
||||
items = _build_traffic_items(
|
||||
user_traffic, user_map, nodes_info, search, sort_by, sort_desc, tariff_filter, status_filter, node_filter
|
||||
user_traffic, user_map, nodes_info, search, effective_sort, sort_desc, tariff_filter, status_filter, node_filter
|
||||
)
|
||||
|
||||
if is_enrichment_sort:
|
||||
enrichment_data = await _build_enrichment(db, user_map)
|
||||
enr_key_map = {
|
||||
'connected': lambda e: e.devices_connected,
|
||||
'total_spent': lambda e: e.total_spent_kopeks,
|
||||
'sub_start': lambda e: e.subscription_start_date or '',
|
||||
'sub_end': lambda e: e.subscription_end_date or '',
|
||||
'last_node': lambda e: e.last_node_name or '',
|
||||
}
|
||||
key_fn = enr_key_map[sort_by]
|
||||
empty = UserTrafficEnrichment()
|
||||
items.sort(key=lambda x: key_fn(enrichment_data.get(x.user_id, empty)), reverse=sort_desc)
|
||||
|
||||
total = len(items)
|
||||
paginated = items[offset : offset + limit]
|
||||
|
||||
@@ -346,6 +371,156 @@ async def get_traffic_usage(
|
||||
)
|
||||
|
||||
|
||||
# ============== Enrichment endpoint ==============
|
||||
|
||||
_enrichment_cache: dict[str, tuple[float, dict[int, UserTrafficEnrichment]]] = {}
|
||||
_ENRICHMENT_CACHE_TTL = 300 # 5 minutes
|
||||
_enrichment_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int, int]:
|
||||
"""Get total spent kopeks for multiple users in a single query."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
)
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
return {row[0]: int(row[1]) for row in result.all()}
|
||||
|
||||
|
||||
async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict[int, UserTrafficEnrichment]:
|
||||
"""Build enrichment data for all users: devices, spending, dates, last node."""
|
||||
uuid_to_user_id: dict[str, int] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uuid_to_user_id[uuid] = user.id
|
||||
|
||||
service = RemnaWaveService()
|
||||
devices_by_user: dict[int, int] = {}
|
||||
last_node_uuid_by_user: dict[int, str] = {}
|
||||
node_uuid_to_name: dict[str, str] = {}
|
||||
|
||||
if service.is_configured:
|
||||
async with service.get_api_client() as api:
|
||||
# 3 bulk calls: nodes + users (paginated) + devices
|
||||
try:
|
||||
nodes_list = await api.get_all_nodes()
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch nodes for enrichment', exc_info=True)
|
||||
nodes_list = []
|
||||
|
||||
for node in nodes_list:
|
||||
node_uuid_to_name[node.uuid] = node.name
|
||||
|
||||
# Fetch all panel users (paginated) for last connected node
|
||||
panel_users = []
|
||||
try:
|
||||
first_page = await api.get_all_users(start=0, size=500)
|
||||
panel_users.extend(first_page['users'])
|
||||
total_panel = first_page['total']
|
||||
|
||||
if total_panel > 500:
|
||||
remaining_tasks = [
|
||||
api.get_all_users(start=offset, size=500) for offset in range(500, total_panel, 500)
|
||||
]
|
||||
pages = await asyncio.gather(*remaining_tasks, return_exceptions=True)
|
||||
for page in pages:
|
||||
if isinstance(page, dict):
|
||||
panel_users.extend(page['users'])
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch panel users for enrichment', exc_info=True)
|
||||
|
||||
for pu in panel_users:
|
||||
uid = uuid_to_user_id.get(pu.uuid)
|
||||
if uid is None:
|
||||
continue
|
||||
if pu.user_traffic and pu.user_traffic.last_connected_node_uuid:
|
||||
last_node_uuid_by_user[uid] = pu.user_traffic.last_connected_node_uuid
|
||||
|
||||
# Bulk device fetch — single API call (paginated with start/size)
|
||||
try:
|
||||
devices_data = await api.get_all_hwid_devices()
|
||||
for device in devices_data.get('devices', []):
|
||||
user_uuid = device.get('userUuid', '')
|
||||
uid = uuid_to_user_id.get(user_uuid)
|
||||
if uid is not None:
|
||||
devices_by_user[uid] = devices_by_user.get(uid, 0) + 1
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch bulk devices for enrichment', exc_info=True)
|
||||
|
||||
# Bulk spending stats
|
||||
all_user_ids = [u.id for u in user_map.values()]
|
||||
spending_map = await _get_bulk_spending(db, all_user_ids)
|
||||
|
||||
# Build enrichment data
|
||||
enrichment: dict[int, UserTrafficEnrichment] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uid = user.id
|
||||
sub = user.subscription
|
||||
|
||||
start_date = None
|
||||
end_date = None
|
||||
if sub:
|
||||
if sub.start_date:
|
||||
start_date = sub.start_date.isoformat()
|
||||
if sub.end_date:
|
||||
end_date = sub.end_date.isoformat()
|
||||
|
||||
last_node_name = None
|
||||
last_uuid = last_node_uuid_by_user.get(uid)
|
||||
if last_uuid:
|
||||
last_node_name = node_uuid_to_name.get(last_uuid)
|
||||
|
||||
enrichment[uid] = UserTrafficEnrichment(
|
||||
devices_connected=devices_by_user.get(uid, 0),
|
||||
total_spent_kopeks=spending_map.get(uid, 0),
|
||||
subscription_start_date=start_date,
|
||||
subscription_end_date=end_date,
|
||||
last_node_name=last_node_name,
|
||||
)
|
||||
|
||||
return enrichment
|
||||
|
||||
|
||||
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
|
||||
async def get_traffic_enrichment(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return enrichment data: device counts, spending, dates, last node."""
|
||||
cache_key = 'enrichment'
|
||||
now = time.time()
|
||||
|
||||
cached = _enrichment_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
|
||||
return TrafficEnrichmentResponse(data=cached[1])
|
||||
|
||||
async with _enrichment_lock:
|
||||
now = time.time()
|
||||
cached = _enrichment_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
|
||||
return TrafficEnrichmentResponse(data=cached[1])
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
enrichment = await _build_enrichment(db, user_map)
|
||||
|
||||
_enrichment_cache[cache_key] = (now, enrichment)
|
||||
|
||||
# Evict expired
|
||||
expired = [k for k, (ts, _) in _enrichment_cache.items() if (now - ts) >= _ENRICHMENT_CACHE_TTL]
|
||||
for k in expired:
|
||||
del _enrichment_cache[k]
|
||||
|
||||
return TrafficEnrichmentResponse(data=enrichment)
|
||||
|
||||
|
||||
@router.post('/export-csv', response_model=ExportCsvResponse)
|
||||
async def export_traffic_csv(
|
||||
request: ExportCsvRequest,
|
||||
@@ -386,6 +561,7 @@ async def export_traffic_csv(
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
|
||||
enrichment = await _build_enrichment(db, user_map)
|
||||
|
||||
# Parse filters
|
||||
tariff_filter: set[str] | None = None
|
||||
@@ -434,12 +610,21 @@ async def export_traffic_csv(
|
||||
'User ID': item.user_id,
|
||||
'Telegram ID': item.telegram_id or '',
|
||||
'Username': item.username or '',
|
||||
'Email': item.email or '',
|
||||
'Full Name': item.full_name,
|
||||
'Tariff': item.tariff_name or '',
|
||||
'Status': item.subscription_status or '',
|
||||
'Traffic Limit (GB)': item.traffic_limit_gb,
|
||||
'Devices': item.device_limit,
|
||||
'Device Limit': item.device_limit,
|
||||
}
|
||||
# Enrichment columns
|
||||
enr = enrichment.get(item.user_id)
|
||||
row['Connected Devices'] = enr.devices_connected if enr else 0
|
||||
row['Total Spent (RUB)'] = round(enr.total_spent_kopeks / 100, 2) if enr else 0
|
||||
row['Sub Start'] = enr.subscription_start_date or '' if enr else ''
|
||||
row['Sub End'] = enr.subscription_end_date or '' if enr else ''
|
||||
row['Last Node'] = enr.last_node_name or '' if enr else ''
|
||||
|
||||
for node in csv_nodes:
|
||||
row[f'{node.node_name} (bytes)'] = item.node_traffic.get(node.node_uuid, 0)
|
||||
row['Total (bytes)'] = item.total_bytes
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Admin routes for version and release information."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/updates', tags=['Cabinet Admin Updates'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class ReleaseItem(BaseModel):
|
||||
tag_name: str
|
||||
name: str
|
||||
body: str
|
||||
published_at: str
|
||||
prerelease: bool
|
||||
|
||||
|
||||
class ProjectReleasesInfo(BaseModel):
|
||||
current_version: str
|
||||
has_updates: bool
|
||||
releases: list[ReleaseItem]
|
||||
repo_url: str
|
||||
|
||||
|
||||
class ReleasesResponse(BaseModel):
|
||||
bot: ProjectReleasesInfo
|
||||
cabinet: ProjectReleasesInfo
|
||||
|
||||
|
||||
# ============ Cabinet releases cache ============
|
||||
|
||||
CABINET_REPO = 'BEDOLAGA-DEV/bedolaga-cabinet'
|
||||
_cabinet_cache: dict = {}
|
||||
_cabinet_last_check: datetime | None = None
|
||||
_CACHE_TTL = 3600
|
||||
|
||||
|
||||
async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
|
||||
global _cabinet_last_check
|
||||
|
||||
if not force and _cabinet_cache.get('releases') and _cabinet_last_check:
|
||||
if datetime.now() - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
|
||||
return _cabinet_cache['releases']
|
||||
|
||||
url = f'https://api.github.com/repos/{CABINET_REPO}/releases'
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
releases = []
|
||||
for item in data[:20]:
|
||||
releases.append(
|
||||
{
|
||||
'tag_name': item['tag_name'],
|
||||
'name': item.get('name') or item['tag_name'],
|
||||
'body': item.get('body') or '',
|
||||
'published_at': item['published_at'],
|
||||
'prerelease': item.get('prerelease', False),
|
||||
}
|
||||
)
|
||||
_cabinet_cache['releases'] = releases
|
||||
_cabinet_last_check = datetime.now()
|
||||
logger.info('Fetched %d cabinet releases from GitHub', len(releases))
|
||||
return releases
|
||||
logger.warning('GitHub API returned status %d for cabinet releases', response.status)
|
||||
return _cabinet_cache.get('releases', [])
|
||||
except TimeoutError:
|
||||
logger.warning('Timeout fetching cabinet releases from GitHub')
|
||||
return _cabinet_cache.get('releases', [])
|
||||
except Exception as e:
|
||||
logger.error('Error fetching cabinet releases: %s', e)
|
||||
return _cabinet_cache.get('releases', [])
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('/releases', response_model=ReleasesResponse)
|
||||
async def get_releases(
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
) -> ReleasesResponse:
|
||||
"""Get release information for bot and cabinet."""
|
||||
# Bot releases
|
||||
bot_releases_raw = await version_service._fetch_releases()
|
||||
has_updates, _ = await version_service.check_for_updates()
|
||||
|
||||
bot_releases = [
|
||||
ReleaseItem(
|
||||
tag_name=r.tag_name,
|
||||
name=r.name,
|
||||
body=r.full_description,
|
||||
published_at=r.published_at.isoformat(),
|
||||
prerelease=r.prerelease,
|
||||
)
|
||||
for r in bot_releases_raw[:10]
|
||||
]
|
||||
|
||||
bot_info = ProjectReleasesInfo(
|
||||
current_version=version_service.current_version,
|
||||
has_updates=has_updates,
|
||||
releases=bot_releases,
|
||||
repo_url=f'https://github.com/{version_service.repo}',
|
||||
)
|
||||
|
||||
# Cabinet releases
|
||||
cabinet_releases_raw = await _fetch_cabinet_releases()
|
||||
cabinet_releases = [ReleaseItem(**r) for r in cabinet_releases_raw[:10]]
|
||||
|
||||
# Current version = latest non-prerelease tag
|
||||
cabinet_current = ''
|
||||
for r in cabinet_releases_raw:
|
||||
if not r.get('prerelease', False):
|
||||
cabinet_current = r['tag_name']
|
||||
break
|
||||
|
||||
cabinet_info = ProjectReleasesInfo(
|
||||
current_version=cabinet_current,
|
||||
has_updates=False,
|
||||
releases=cabinet_releases,
|
||||
repo_url=f'https://github.com/{CABINET_REPO}',
|
||||
)
|
||||
|
||||
return ReleasesResponse(bot=bot_info, cabinet=cabinet_info)
|
||||
@@ -28,6 +28,7 @@ from app.database.models import (
|
||||
PromoGroup,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
TrafficPurchase,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
@@ -37,8 +38,10 @@ from app.utils.timezone import panel_datetime_to_naive_utc
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.users import (
|
||||
DeleteDeviceResponse,
|
||||
DeleteUserRequest,
|
||||
DeleteUserResponse,
|
||||
DeviceInfo,
|
||||
DisableUserRequest,
|
||||
DisableUserResponse,
|
||||
FullDeleteUserRequest,
|
||||
@@ -46,6 +49,7 @@ from ..schemas.users import (
|
||||
PanelSyncStatusResponse,
|
||||
PanelUserInfo,
|
||||
PeriodPriceInfo,
|
||||
ResetDevicesResponse,
|
||||
ResetSubscriptionRequest,
|
||||
ResetSubscriptionResponse,
|
||||
ResetTrialRequest,
|
||||
@@ -55,10 +59,13 @@ from ..schemas.users import (
|
||||
SyncFromPanelResponse,
|
||||
SyncToPanelRequest,
|
||||
SyncToPanelResponse,
|
||||
TrafficPurchaseItem,
|
||||
UpdateBalanceRequest,
|
||||
UpdateBalanceResponse,
|
||||
UpdatePromoGroupRequest,
|
||||
UpdatePromoGroupResponse,
|
||||
UpdateReferralCommissionRequest,
|
||||
UpdateReferralCommissionResponse,
|
||||
UpdateRestrictionsRequest,
|
||||
UpdateRestrictionsResponse,
|
||||
UpdateSubscriptionRequest,
|
||||
@@ -68,6 +75,7 @@ from ..schemas.users import (
|
||||
UserAvailableTariffItem,
|
||||
UserAvailableTariffsResponse,
|
||||
UserDetailResponse,
|
||||
UserDevicesResponse,
|
||||
UserListItem,
|
||||
UserNodeUsageItem,
|
||||
UserNodeUsageResponse,
|
||||
@@ -157,13 +165,43 @@ def _build_subscription_info(subscription: Subscription, tariff_name: str | None
|
||||
|
||||
|
||||
async def _build_subscription_info_async(db: AsyncSession, subscription: Subscription) -> UserSubscriptionInfo:
|
||||
"""Build UserSubscriptionInfo from Subscription model, fetching tariff name asynchronously."""
|
||||
"""Build UserSubscriptionInfo from Subscription model, fetching tariff name and traffic purchases."""
|
||||
tariff_name = None
|
||||
if subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
tariff_name = tariff.name
|
||||
return _build_subscription_info(subscription, tariff_name=tariff_name)
|
||||
|
||||
# Fetch traffic purchases
|
||||
now = datetime.utcnow()
|
||||
tp_query = (
|
||||
select(TrafficPurchase)
|
||||
.where(TrafficPurchase.subscription_id == subscription.id)
|
||||
.order_by(TrafficPurchase.created_at.desc())
|
||||
)
|
||||
tp_result = await db.execute(tp_query)
|
||||
purchases = tp_result.scalars().all()
|
||||
|
||||
traffic_purchase_items = []
|
||||
for p in purchases:
|
||||
delta = p.expires_at - now
|
||||
days_remaining = max(0, delta.days)
|
||||
is_expired = now >= p.expires_at
|
||||
traffic_purchase_items.append(
|
||||
TrafficPurchaseItem(
|
||||
id=p.id,
|
||||
traffic_gb=p.traffic_gb,
|
||||
expires_at=p.expires_at,
|
||||
created_at=p.created_at,
|
||||
days_remaining=days_remaining,
|
||||
is_expired=is_expired,
|
||||
)
|
||||
)
|
||||
|
||||
info = _build_subscription_info(subscription, tariff_name=tariff_name)
|
||||
info.purchased_traffic_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
info.traffic_purchases = traffic_purchase_items
|
||||
return info
|
||||
|
||||
|
||||
async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription: Subscription) -> dict:
|
||||
@@ -198,12 +236,16 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
description = settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
@@ -213,7 +255,15 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
async with service.get_api_client() as api:
|
||||
panel_uuid = user.remnawave_uuid
|
||||
|
||||
# Try to find existing user
|
||||
# Try to find existing user by UUID first
|
||||
if panel_uuid:
|
||||
existing_user = await api.get_user_by_uuid(panel_uuid)
|
||||
if not existing_user:
|
||||
logger.warning(f'User {user.id} has stale remnawave_uuid {panel_uuid}, clearing')
|
||||
panel_uuid = None
|
||||
user.remnawave_uuid = None
|
||||
|
||||
# Fallback: search by telegram_id
|
||||
if not panel_uuid and user.telegram_id:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if existing_users:
|
||||
@@ -221,6 +271,14 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
user.remnawave_uuid = panel_uuid
|
||||
changes['remnawave_uuid_discovered'] = panel_uuid
|
||||
|
||||
# Fallback: search by email (for OAuth users without telegram_id)
|
||||
if not panel_uuid and user.email:
|
||||
existing_users = await api.get_user_by_email(user.email)
|
||||
if existing_users:
|
||||
panel_uuid = existing_users[0].uuid
|
||||
user.remnawave_uuid = panel_uuid
|
||||
changes['remnawave_uuid_discovered'] = panel_uuid
|
||||
|
||||
if panel_uuid:
|
||||
# Update existing user
|
||||
update_kwargs = {
|
||||
@@ -256,6 +314,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
'traffic_limit_bytes': traffic_limit_bytes,
|
||||
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
|
||||
'telegram_id': user.telegram_id,
|
||||
'email': user.email,
|
||||
'description': description,
|
||||
'active_internal_squads': subscription.connected_squads or [],
|
||||
}
|
||||
@@ -612,15 +671,30 @@ async def get_user_panel_info(
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured or not user.telegram_id:
|
||||
if not service.is_configured:
|
||||
return UserPanelInfoResponse(found=False)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if not panel_users:
|
||||
return UserPanelInfoResponse(found=False)
|
||||
panel_user = None
|
||||
|
||||
panel_user = panel_users[0]
|
||||
# Try by UUID first (works for all users including OAuth)
|
||||
if user.remnawave_uuid:
|
||||
panel_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
|
||||
# Fallback: search by telegram_id
|
||||
if not panel_user and user.telegram_id:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if panel_users:
|
||||
panel_user = panel_users[0]
|
||||
|
||||
# Fallback: search by email (OAuth users)
|
||||
if not panel_user and user.email:
|
||||
panel_users_by_email = await api.get_user_by_email(user.email)
|
||||
if panel_users_by_email:
|
||||
panel_user = panel_users_by_email[0]
|
||||
|
||||
if not panel_user:
|
||||
return UserPanelInfoResponse(found=False)
|
||||
|
||||
# Resolve last connected node name via accessible nodes (lighter than get_all_nodes)
|
||||
last_node_name = None
|
||||
@@ -1060,6 +1134,113 @@ async def update_user_subscription(
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
if request.action == 'add_traffic':
|
||||
if not request.traffic_gb:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='traffic_gb parameter is required for add_traffic action',
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import add_subscription_traffic
|
||||
|
||||
await add_subscription_traffic(db, subscription, request.traffic_gb)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(f'Admin {admin.id} added {request.traffic_gb} GB traffic for user {user_id}')
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
message=f'Added {request.traffic_gb} GB traffic (30 days)',
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
if request.action == 'remove_traffic':
|
||||
if not request.traffic_purchase_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='traffic_purchase_id parameter is required for remove_traffic action',
|
||||
)
|
||||
|
||||
# Find the traffic purchase
|
||||
tp_query = select(TrafficPurchase).where(
|
||||
TrafficPurchase.id == request.traffic_purchase_id,
|
||||
TrafficPurchase.subscription_id == subscription.id,
|
||||
)
|
||||
tp_result = await db.execute(tp_query)
|
||||
traffic_purchase = tp_result.scalar_one_or_none()
|
||||
if not traffic_purchase:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Traffic purchase not found',
|
||||
)
|
||||
|
||||
removed_gb = traffic_purchase.traffic_gb
|
||||
|
||||
# Decrement counters
|
||||
subscription.traffic_limit_gb = max(0, subscription.traffic_limit_gb - removed_gb)
|
||||
current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
subscription.purchased_traffic_gb = max(0, current_purchased - removed_gb)
|
||||
|
||||
# Delete the purchase record
|
||||
await db.delete(traffic_purchase)
|
||||
|
||||
# Recalculate traffic_reset_at from remaining active purchases
|
||||
now = datetime.utcnow()
|
||||
remaining_query = select(TrafficPurchase).where(
|
||||
TrafficPurchase.subscription_id == subscription.id,
|
||||
TrafficPurchase.expires_at > now,
|
||||
TrafficPurchase.id != request.traffic_purchase_id,
|
||||
)
|
||||
remaining_result = await db.execute(remaining_query)
|
||||
remaining_purchases = remaining_result.scalars().all()
|
||||
|
||||
if remaining_purchases:
|
||||
subscription.traffic_reset_at = min(p.expires_at for p in remaining_purchases)
|
||||
else:
|
||||
subscription.traffic_reset_at = None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(
|
||||
f'Admin {admin.id} removed traffic purchase {request.traffic_purchase_id} ({removed_gb} GB) for user {user_id}'
|
||||
)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
message=f'Removed {removed_gb} GB traffic package',
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
if request.action == 'set_device_limit':
|
||||
if request.device_limit is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='device_limit parameter is required for set_device_limit action',
|
||||
)
|
||||
|
||||
subscription.device_limit = request.device_limit
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(f'Admin {admin.id} set device limit to {request.device_limit} for user {user_id}')
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
message=f'Device limit set to {request.device_limit}',
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Unknown action: {request.action}',
|
||||
@@ -1140,6 +1321,11 @@ async def get_user_available_tariffs(
|
||||
price_per_day_kopeks=tariff.price_per_day_kopeks,
|
||||
min_days=tariff.min_days,
|
||||
max_days=tariff.max_days,
|
||||
device_price_kopeks=tariff.device_price_kopeks,
|
||||
max_device_limit=tariff.max_device_limit,
|
||||
traffic_topup_enabled=tariff.traffic_topup_enabled,
|
||||
traffic_topup_packages=tariff.traffic_topup_packages or {},
|
||||
max_topup_traffic_gb=tariff.max_topup_traffic_gb,
|
||||
is_available=is_available,
|
||||
requires_promo_group=requires_promo_group,
|
||||
)
|
||||
@@ -1326,6 +1512,173 @@ async def update_user_promo_group(
|
||||
)
|
||||
|
||||
|
||||
# === Referral Commission ===
|
||||
|
||||
|
||||
@router.post('/{user_id}/referral-commission', response_model=UpdateReferralCommissionResponse)
|
||||
async def update_user_referral_commission(
|
||||
user_id: int,
|
||||
request: UpdateReferralCommissionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's individual referral commission percentage."""
|
||||
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',
|
||||
)
|
||||
|
||||
old_commission = user.referral_commission_percent
|
||||
user.referral_commission_percent = request.commission_percent
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f'Admin {admin.id} changed referral commission for user {user_id}: {old_commission} -> {request.commission_percent}'
|
||||
)
|
||||
|
||||
return UpdateReferralCommissionResponse(
|
||||
success=True,
|
||||
old_commission_percent=old_commission,
|
||||
new_commission_percent=request.commission_percent,
|
||||
message='Referral commission updated',
|
||||
)
|
||||
|
||||
|
||||
# === Devices ===
|
||||
|
||||
|
||||
@router.get('/{user_id}/devices', response_model=UserDevicesResponse)
|
||||
async def get_user_devices(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user devices from Remnawave panel."""
|
||||
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 not user.remnawave_uuid:
|
||||
return UserDevicesResponse()
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured:
|
||||
return UserDevicesResponse()
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
response = await api.get_user_devices(user.remnawave_uuid)
|
||||
|
||||
devices = []
|
||||
for d in response.get('devices', []):
|
||||
hwid = d.get('hwid') or d.get('deviceId') or d.get('id')
|
||||
if not hwid:
|
||||
continue
|
||||
devices.append(
|
||||
DeviceInfo(
|
||||
hwid=hwid,
|
||||
platform=d.get('platform') or d.get('platformType') or '',
|
||||
device_model=d.get('deviceModel') or d.get('model') or d.get('name') or '',
|
||||
created_at=d.get('updatedAt') or d.get('lastSeen') or d.get('createdAt'),
|
||||
)
|
||||
)
|
||||
|
||||
device_limit = 0
|
||||
if user.subscription:
|
||||
device_limit = user.subscription.device_limit or 0
|
||||
|
||||
return UserDevicesResponse(
|
||||
devices=devices,
|
||||
total=response.get('total', len(devices)),
|
||||
device_limit=device_limit,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching devices for user {user_id}: {e}')
|
||||
return UserDevicesResponse()
|
||||
|
||||
|
||||
@router.delete('/{user_id}/devices/{hwid}', response_model=DeleteDeviceResponse)
|
||||
async def delete_user_device(
|
||||
user_id: int,
|
||||
hwid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a single device for user."""
|
||||
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 not user.remnawave_uuid:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='User has no panel account')
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
success = await api.remove_device(user.remnawave_uuid, hwid)
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.id} deleted device {hwid} for user {user_id}')
|
||||
return DeleteDeviceResponse(success=True, message='Device deleted', deleted_hwid=hwid)
|
||||
return DeleteDeviceResponse(success=False, message='Failed to delete device')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error deleting device {hwid} for user {user_id}: {e}')
|
||||
return DeleteDeviceResponse(success=False, message=str(e))
|
||||
|
||||
|
||||
@router.delete('/{user_id}/devices', response_model=ResetDevicesResponse)
|
||||
async def reset_user_devices(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset all devices for user."""
|
||||
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 not user.remnawave_uuid:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='User has no panel account')
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
devices_info = await api.get_user_devices(user.remnawave_uuid)
|
||||
devices = devices_info.get('devices', [])
|
||||
total = len(devices)
|
||||
|
||||
if total == 0:
|
||||
return ResetDevicesResponse(success=True, message='No devices to reset', deleted_count=0)
|
||||
|
||||
deleted = 0
|
||||
for d in devices:
|
||||
device_hwid = d.get('hwid') or d.get('deviceId') or d.get('id')
|
||||
if device_hwid:
|
||||
try:
|
||||
await api.remove_device(user.remnawave_uuid, device_hwid)
|
||||
deleted += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f'Admin {admin.id} reset devices for user {user_id}: {deleted}/{total}')
|
||||
return ResetDevicesResponse(success=True, message=f'Deleted {deleted}/{total} devices', deleted_count=deleted)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error resetting devices for user {user_id}: {e}')
|
||||
return ResetDevicesResponse(success=False, message=str(e))
|
||||
|
||||
|
||||
# === Delete User ===
|
||||
|
||||
|
||||
@@ -1754,11 +2107,27 @@ async def get_user_sync_status(
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
if service.is_configured and user.telegram_id:
|
||||
if service.is_configured:
|
||||
async with service.get_api_client() as api:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if panel_users:
|
||||
panel_user = panel_users[0]
|
||||
panel_user = None
|
||||
|
||||
# Try by UUID first (works for all users including OAuth)
|
||||
if user.remnawave_uuid:
|
||||
panel_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
|
||||
# Fallback: search by telegram_id
|
||||
if not panel_user and user.telegram_id:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if panel_users:
|
||||
panel_user = panel_users[0]
|
||||
|
||||
# Fallback: search by email (OAuth users)
|
||||
if not panel_user and user.email:
|
||||
panel_users_by_email = await api.get_user_by_email(user.email)
|
||||
if panel_users_by_email:
|
||||
panel_user = panel_users_by_email[0]
|
||||
|
||||
if panel_user:
|
||||
panel_found = True
|
||||
panel_status = panel_user.status.value if panel_user.status else None
|
||||
panel_expire_at = panel_user.expire_at
|
||||
@@ -1884,27 +2253,30 @@ async def sync_user_from_panel(
|
||||
errors = []
|
||||
panel_info = None
|
||||
|
||||
# Email-only users cannot be synced from panel by telegram_id
|
||||
if not user.telegram_id:
|
||||
return SyncFromPanelResponse(
|
||||
success=False,
|
||||
message='Cannot sync email-only user',
|
||||
errors=["Email-only users don't have telegram_id for panel lookup"],
|
||||
)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
# Find user in panel
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
# Find user in panel: UUID → telegram_id → email
|
||||
panel_user = None
|
||||
|
||||
if not panel_users:
|
||||
if user.remnawave_uuid:
|
||||
panel_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
|
||||
if not panel_user and user.telegram_id:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if panel_users:
|
||||
panel_user = panel_users[0]
|
||||
|
||||
if not panel_user and user.email:
|
||||
panel_users_by_email = await api.get_user_by_email(user.email)
|
||||
if panel_users_by_email:
|
||||
panel_user = panel_users_by_email[0]
|
||||
|
||||
if not panel_user:
|
||||
return SyncFromPanelResponse(
|
||||
success=False,
|
||||
message='User not found in panel',
|
||||
errors=['No user with this telegram_id found in Remnawave panel'],
|
||||
errors=['No user found in Remnawave panel by UUID, telegram_id, or email'],
|
||||
)
|
||||
|
||||
panel_user = panel_users[0]
|
||||
|
||||
# Build panel info
|
||||
active_squads = []
|
||||
if hasattr(panel_user, 'active_internal_squads') and panel_user.active_internal_squads:
|
||||
@@ -2113,19 +2485,31 @@ async def sync_user_to_panel(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
description = settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(sub)
|
||||
traffic_limit_bytes = sub.traffic_limit_gb * (1024**3) if sub.traffic_limit_gb > 0 else 0
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
# Try to find existing user in panel
|
||||
# Validate existing UUID
|
||||
if panel_uuid:
|
||||
existing_user = await api.get_user_by_uuid(panel_uuid)
|
||||
if not existing_user:
|
||||
logger.warning(f'User {user.id} has stale remnawave_uuid {panel_uuid}, clearing')
|
||||
panel_uuid = None
|
||||
user.remnawave_uuid = None
|
||||
|
||||
# Fallback: search by telegram_id
|
||||
if not panel_uuid and user.telegram_id:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if existing_users:
|
||||
@@ -2133,6 +2517,14 @@ async def sync_user_to_panel(
|
||||
user.remnawave_uuid = panel_uuid
|
||||
changes['remnawave_uuid_discovered'] = panel_uuid
|
||||
|
||||
# Fallback: search by email (OAuth users)
|
||||
if not panel_uuid and user.email:
|
||||
existing_users = await api.get_user_by_email(user.email)
|
||||
if existing_users:
|
||||
panel_uuid = existing_users[0].uuid
|
||||
user.remnawave_uuid = panel_uuid
|
||||
changes['remnawave_uuid_discovered'] = panel_uuid
|
||||
|
||||
if panel_uuid:
|
||||
# Update existing user
|
||||
update_kwargs = {'uuid': panel_uuid}
|
||||
@@ -2178,6 +2570,7 @@ async def sync_user_to_panel(
|
||||
'traffic_limit_bytes': traffic_limit_bytes,
|
||||
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
|
||||
'telegram_id': user.telegram_id,
|
||||
'email': user.email,
|
||||
'description': description,
|
||||
'active_internal_squads': sub.connected_squads or [],
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ EMAIL_AUTH_ENABLED_KEY = 'CABINET_EMAIL_AUTH_ENABLED' # Stores "true" or "false
|
||||
YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeric string)
|
||||
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
|
||||
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
|
||||
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
|
||||
|
||||
# Allowed image types
|
||||
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
|
||||
@@ -144,6 +145,18 @@ class EmailAuthEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class LiteModeEnabledResponse(BaseModel):
|
||||
"""Lite mode enabled setting."""
|
||||
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class LiteModeEnabledUpdate(BaseModel):
|
||||
"""Request to update lite mode setting."""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
class AnalyticsCountersResponse(BaseModel):
|
||||
"""Analytics counter settings."""
|
||||
|
||||
@@ -718,3 +731,39 @@ async def update_analytics_counters(
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
)
|
||||
|
||||
|
||||
# ============ Lite Mode Routes ============
|
||||
|
||||
|
||||
@router.get('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def get_lite_mode_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get lite mode enabled setting.
|
||||
This is a public endpoint - no authentication required.
|
||||
When enabled, shows simplified dashboard with minimal features.
|
||||
"""
|
||||
lite_mode_value = await get_setting_value(db, LITE_MODE_ENABLED_KEY)
|
||||
|
||||
if lite_mode_value is not None:
|
||||
enabled = lite_mode_value.lower() == 'true'
|
||||
return LiteModeEnabledResponse(enabled=enabled)
|
||||
|
||||
# Default: disabled
|
||||
return LiteModeEnabledResponse(enabled=False)
|
||||
|
||||
|
||||
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def update_lite_mode_enabled(
|
||||
payload: LiteModeEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update lite mode enabled setting. Admin only."""
|
||||
await set_setting_value(db, LITE_MODE_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} set lite mode enabled: {payload.enabled}')
|
||||
|
||||
return LiteModeEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -20,6 +20,30 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/info', tags=['Cabinet Info'])
|
||||
|
||||
_LANGUAGE_META: dict[str, tuple[str, str]] = {
|
||||
'ru': ('Русский', '🇷🇺'),
|
||||
'en': ('English', '🇬🇧'),
|
||||
'ua': ('Українська', '🇺🇦'),
|
||||
'zh': ('中文', '🇨🇳'),
|
||||
'fa': ('فارسی', '🇮🇷'),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_language_code(value: str | None) -> str:
|
||||
return (value or '').strip().lower().split('-', 1)[0]
|
||||
|
||||
|
||||
def _get_available_language_codes() -> list[str]:
|
||||
codes: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for code in settings.get_available_languages():
|
||||
normalized = _normalize_language_code(code)
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
codes.append(normalized)
|
||||
return codes
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
@@ -212,12 +236,19 @@ async def get_service_info():
|
||||
@router.get('/languages')
|
||||
async def get_available_languages():
|
||||
"""Get list of available languages."""
|
||||
codes = _get_available_language_codes()
|
||||
default_language = _normalize_language_code(getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru')
|
||||
|
||||
return {
|
||||
'languages': [
|
||||
{'code': 'ru', 'name': 'Русский', 'flag': '🇷🇺'},
|
||||
{'code': 'en', 'name': 'English', 'flag': '🇬🇧'},
|
||||
{
|
||||
'code': code,
|
||||
'name': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[0],
|
||||
'flag': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[1],
|
||||
}
|
||||
for code in codes
|
||||
],
|
||||
'default': getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru',
|
||||
'default': default_language,
|
||||
}
|
||||
|
||||
|
||||
@@ -236,16 +267,15 @@ async def update_user_language(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's language preference."""
|
||||
language = request.get('language', 'ru')
|
||||
|
||||
valid_languages = ['ru', 'en']
|
||||
if language not in valid_languages:
|
||||
requested_language = _normalize_language_code(request.get('language', 'ru'))
|
||||
available_languages = _get_available_language_codes()
|
||||
if requested_language not in available_languages:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid language. Supported: {", ".join(valid_languages)}',
|
||||
detail=f'Invalid language. Supported: {", ".join(available_languages)}',
|
||||
)
|
||||
|
||||
user.language = language
|
||||
user.language = requested_language
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
@@ -1662,7 +1662,7 @@ async def submit_purchase(
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
transaction=None,
|
||||
period_days=selection.period_days,
|
||||
period_days=selection.period.days,
|
||||
was_trial_conversion=result.get('was_trial_conversion', False),
|
||||
amount_kopeks=pricing.final_total,
|
||||
purchase_type='renewal' if not is_new_subscription else None,
|
||||
|
||||
@@ -13,6 +13,7 @@ class UserTrafficItem(BaseModel):
|
||||
user_id: int
|
||||
telegram_id: int | None
|
||||
username: str | None
|
||||
email: str | None
|
||||
full_name: str
|
||||
tariff_name: str | None
|
||||
subscription_status: str | None
|
||||
@@ -33,6 +34,18 @@ class TrafficUsageResponse(BaseModel):
|
||||
available_statuses: list[str]
|
||||
|
||||
|
||||
class UserTrafficEnrichment(BaseModel):
|
||||
devices_connected: int = 0
|
||||
total_spent_kopeks: int = 0
|
||||
subscription_start_date: str | None = None
|
||||
subscription_end_date: str | None = None
|
||||
last_node_name: str | None = None
|
||||
|
||||
|
||||
class TrafficEnrichmentResponse(BaseModel):
|
||||
data: dict[int, UserTrafficEnrichment]
|
||||
|
||||
|
||||
class ExportCsvRequest(BaseModel):
|
||||
period: int = Field(30, ge=1, le=30)
|
||||
start_date: str | None = None
|
||||
|
||||
@@ -39,6 +39,17 @@ class SortByEnum(str, Enum):
|
||||
# === User Subscription Info ===
|
||||
|
||||
|
||||
class TrafficPurchaseItem(BaseModel):
|
||||
"""Individual traffic purchase record."""
|
||||
|
||||
id: int
|
||||
traffic_gb: int
|
||||
expires_at: datetime
|
||||
created_at: datetime
|
||||
days_remaining: int
|
||||
is_expired: bool
|
||||
|
||||
|
||||
class UserSubscriptionInfo(BaseModel):
|
||||
"""User subscription information."""
|
||||
|
||||
@@ -55,6 +66,8 @@ class UserSubscriptionInfo(BaseModel):
|
||||
autopay_enabled: bool = False
|
||||
is_active: bool = False
|
||||
days_remaining: int = 0
|
||||
purchased_traffic_gb: int = 0
|
||||
traffic_purchases: list[TrafficPurchaseItem] = []
|
||||
|
||||
|
||||
class UserPromoGroupInfo(BaseModel):
|
||||
@@ -285,6 +298,12 @@ class UpdateSubscriptionRequest(BaseModel):
|
||||
# For toggle_autopay
|
||||
autopay_enabled: bool | None = Field(None, description='Enable/disable autopay')
|
||||
|
||||
# For add_traffic action
|
||||
traffic_gb: int | None = Field(None, ge=1, description='Traffic GB to add')
|
||||
|
||||
# For remove_traffic action
|
||||
traffic_purchase_id: int | None = Field(None, description='Traffic purchase ID to remove')
|
||||
|
||||
# For create new subscription
|
||||
is_trial: bool | None = Field(None, description='Is trial subscription')
|
||||
device_limit: int | None = Field(None, ge=1, description='Device limit')
|
||||
@@ -348,6 +367,56 @@ class UpdatePromoGroupResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class UpdateReferralCommissionRequest(BaseModel):
|
||||
"""Request to update user referral commission percent."""
|
||||
|
||||
commission_percent: int | None = Field(
|
||||
None, ge=0, le=100, description='Referral commission percent (null for default)'
|
||||
)
|
||||
|
||||
|
||||
class UpdateReferralCommissionResponse(BaseModel):
|
||||
"""Response after referral commission update."""
|
||||
|
||||
success: bool
|
||||
old_commission_percent: int | None = None
|
||||
new_commission_percent: int | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class DeviceInfo(BaseModel):
|
||||
"""Individual device info."""
|
||||
|
||||
hwid: str
|
||||
platform: str = ''
|
||||
device_model: str = ''
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class UserDevicesResponse(BaseModel):
|
||||
"""User devices from panel."""
|
||||
|
||||
devices: list[DeviceInfo] = []
|
||||
total: int = 0
|
||||
device_limit: int = 0
|
||||
|
||||
|
||||
class DeleteDeviceResponse(BaseModel):
|
||||
"""Response after device deletion."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deleted_hwid: str | None = None
|
||||
|
||||
|
||||
class ResetDevicesResponse(BaseModel):
|
||||
"""Response after resetting all devices."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deleted_count: int = 0
|
||||
|
||||
|
||||
class DeleteUserRequest(BaseModel):
|
||||
"""Request to delete user."""
|
||||
|
||||
@@ -441,6 +510,15 @@ class UserAvailableTariffItem(BaseModel):
|
||||
min_days: int = 1
|
||||
max_days: int = 365
|
||||
|
||||
# Device limits
|
||||
device_price_kopeks: int | None = None
|
||||
max_device_limit: int | None = None
|
||||
|
||||
# Traffic topup
|
||||
traffic_topup_enabled: bool = False
|
||||
traffic_topup_packages: dict[str, int] = {}
|
||||
max_topup_traffic_gb: int = 0
|
||||
|
||||
# Access info
|
||||
is_available: bool = True # Available for this user's promo group
|
||||
requires_promo_group: bool = False # Requires specific promo group
|
||||
|
||||
@@ -119,7 +119,7 @@ class EmailService:
|
||||
verification_token: Verification token
|
||||
verification_url: Base URL for verification (token will be appended)
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template (already wrapped in base template)
|
||||
|
||||
@@ -174,6 +174,16 @@ class EmailService:
|
||||
'ignore': 'Якщо ви не створювали акаунт, просто проігноруйте цей лист.',
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'تایید آدرس ایمیل',
|
||||
'intro': 'از ثبتنام شما سپاسگزاریم! لطفاً با کلیک روی دکمه زیر ایمیل خود را تایید کنید:',
|
||||
'button': 'تایید ایمیل',
|
||||
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
|
||||
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
|
||||
'ignore': 'اگر شما این حساب را ایجاد نکردهاید، این ایمیل را نادیده بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
@@ -236,7 +246,7 @@ class EmailService:
|
||||
reset_token: Password reset token
|
||||
reset_url: Base URL for password reset (token will be appended)
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template (already wrapped in base template)
|
||||
|
||||
@@ -291,6 +301,16 @@ class EmailService:
|
||||
'warning': "Якщо ви не запитували скидання пароля, проігноруйте цей лист або зв'яжіться з підтримкою.",
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'بازنشانی رمز عبور',
|
||||
'intro': 'درخواستی برای بازنشانی رمز عبور شما دریافت شد. برای تعیین رمز جدید روی دکمه زیر بزنید:',
|
||||
'button': 'بازنشانی رمز عبور',
|
||||
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
|
||||
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
|
||||
'warning': 'اگر شما درخواست بازنشانی رمز عبور ندادهاید، این ایمیل را نادیده بگیرید یا با پشتیبانی تماس بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
@@ -352,7 +372,7 @@ class EmailService:
|
||||
to_email: New email address
|
||||
code: 6-digit verification code
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template
|
||||
|
||||
@@ -401,6 +421,15 @@ class EmailService:
|
||||
'ignore': 'Якщо ви не запитували зміну email, просто проігноруйте цей лист.',
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'کد تایید تغییر ایمیل',
|
||||
'intro': 'شما درخواست تغییر ایمیل دادهاید. برای تایید از کد زیر استفاده کنید:',
|
||||
'code_label': 'کد تایید شما:',
|
||||
'expires': f'این کد تا {expire_minutes} دقیقه معتبر است.',
|
||||
'ignore': 'اگر شما درخواست تغییر ایمیل ندادهاید، این ایمیل را نادیده بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Email notification templates for different notification types.
|
||||
|
||||
Supports multiple languages: ru, en, zh, ua
|
||||
Supports multiple languages: ru, en, zh, ua, fa
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
@@ -27,7 +27,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
Args:
|
||||
notification_type: Type of notification
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
context: Context data for template rendering
|
||||
|
||||
Returns:
|
||||
@@ -72,6 +72,7 @@ class EmailNotificationTemplates:
|
||||
'en': 'This is an automated message. Please do not reply to this email.',
|
||||
'zh': '这是一封自动发送的邮件,请勿回复。',
|
||||
'ua': 'Це автоматичне повідомлення. Будь ласка, не відповідайте на цей лист.',
|
||||
'fa': 'این یک پیام خودکار است. لطفاً به این ایمیل پاسخ ندهید.',
|
||||
}
|
||||
footer_text = footer_texts.get(language, footer_texts['ru'])
|
||||
|
||||
@@ -182,6 +183,7 @@ class EmailNotificationTemplates:
|
||||
'en': 'Open Dashboard',
|
||||
'zh': '打开控制面板',
|
||||
'ua': 'Відкрити особистий кабінет',
|
||||
'fa': 'باز کردن پنل کاربری',
|
||||
}
|
||||
text = texts.get(language, texts['en'])
|
||||
|
||||
|
||||
+3
-20
@@ -339,12 +339,6 @@ class Settings(BaseSettings):
|
||||
NALOGO_STORAGE_PATH: str = './nalogo_tokens.json'
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
# Показывать предупреждение об активации подписки после пополнения баланса
|
||||
# Если True - после пополнения показывает большое сообщение с кнопками:
|
||||
# "Активировать", "Продлить", "Добавить устройства"
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: bool = False
|
||||
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW: bool = False
|
||||
@@ -409,7 +403,6 @@ class Settings(BaseSettings):
|
||||
PAL24_SIGNATURE_TOKEN: str | None = None
|
||||
PAL24_BASE_URL: str = 'https://pal24.pro/api/v1/'
|
||||
PAL24_WEBHOOK_PATH: str = '/pal24-webhook'
|
||||
PAL24_WEBHOOK_PORT: int = 8084
|
||||
PAL24_PAYMENT_DESCRIPTION: str = 'Пополнение баланса'
|
||||
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
@@ -531,7 +524,7 @@ class Settings(BaseSettings):
|
||||
SKIP_REFERRAL_CODE: bool = False
|
||||
|
||||
DEFAULT_LANGUAGE: str = 'ru'
|
||||
AVAILABLE_LANGUAGES: str = 'ru,en'
|
||||
AVAILABLE_LANGUAGES: str = 'ru,en,ua,zh,fa'
|
||||
LANGUAGE_SELECTION_ENABLED: bool = True
|
||||
|
||||
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
|
||||
@@ -1183,22 +1176,12 @@ class Settings(BaseSettings):
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_auto_activate_after_topup_enabled(self) -> bool:
|
||||
"""Умная автоактивация после пополнения баланса (без корзины)."""
|
||||
value = getattr(self, 'AUTO_ACTIVATE_AFTER_TOPUP_ENABLED', False)
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
return normalized in {'1', 'true', 'yes', 'on'}
|
||||
|
||||
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']
|
||||
defaults = ['ru', 'en', 'ua', 'zh', 'fa']
|
||||
|
||||
try:
|
||||
langs = self.AVAILABLE_LANGUAGES
|
||||
@@ -2449,7 +2432,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_bot_run_mode(self) -> str:
|
||||
mode = (self.BOT_RUN_MODE or 'polling').strip().lower()
|
||||
if mode not in {'polling', 'webhook', 'both'}:
|
||||
if mode not in {'polling', 'webhook'}:
|
||||
return 'polling'
|
||||
return mode
|
||||
|
||||
|
||||
@@ -95,6 +95,25 @@ async def create_trial_subscription(
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
# Check for existing PENDING trial subscription (retry after failed payment)
|
||||
existing = await get_subscription_by_user_id(db, user_id)
|
||||
if existing and existing.is_trial and existing.status == SubscriptionStatus.PENDING.value:
|
||||
existing.status = SubscriptionStatus.ACTIVE.value
|
||||
existing.start_date = datetime.utcnow()
|
||||
existing.end_date = end_date
|
||||
existing.traffic_limit_gb = traffic_limit_gb
|
||||
existing.device_limit = device_limit
|
||||
existing.connected_squads = final_squads
|
||||
existing.tariff_id = tariff_id
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
logger.info(
|
||||
'🎁 Обновлена PENDING триальная подписка %s для пользователя %s',
|
||||
existing.id,
|
||||
user_id,
|
||||
)
|
||||
return existing
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
|
||||
@@ -28,6 +28,13 @@ from app.utils.validators import sanitize_telegram_name
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_language_code(language: str | None, fallback: str = 'ru') -> str:
|
||||
normalized = (language or '').strip().lower()
|
||||
if '-' in normalized:
|
||||
normalized = normalized.split('-', 1)[0]
|
||||
return normalized or fallback
|
||||
|
||||
|
||||
def _build_spending_stats_select():
|
||||
"""
|
||||
Возвращает базовый SELECT для статистики трат пользователей.
|
||||
@@ -232,6 +239,7 @@ async def create_user_no_commit(
|
||||
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
promo_group_id = default_group.id
|
||||
@@ -243,7 +251,7 @@ async def create_user_no_commit(
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -277,6 +285,7 @@ async def create_user(
|
||||
) -> User:
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
|
||||
attempts = 3
|
||||
|
||||
@@ -291,7 +300,7 @@ async def create_user(
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -360,6 +369,8 @@ async def update_user(db: AsyncSession, user: User, **kwargs) -> User:
|
||||
for field, value in kwargs.items():
|
||||
if field in ('first_name', 'last_name'):
|
||||
value = sanitize_telegram_name(value)
|
||||
if field == 'language':
|
||||
value = _normalize_language_code(value)
|
||||
if hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
|
||||
@@ -1060,6 +1071,7 @@ async def create_user_by_email(
|
||||
Created User object
|
||||
"""
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
|
||||
user = User(
|
||||
@@ -1071,7 +1083,7 @@ async def create_user_by_email(
|
||||
username=None,
|
||||
first_name=sanitize_telegram_name(first_name) if first_name else None,
|
||||
last_name=None,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -1283,6 +1295,7 @@ async def create_user_by_oauth(
|
||||
) -> User:
|
||||
"""Create a new user via OAuth provider."""
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
@@ -1297,7 +1310,7 @@ async def create_user_by_oauth(
|
||||
username=sanitize_telegram_name(username) if username else None,
|
||||
first_name=sanitize_telegram_name(first_name) if first_name else None,
|
||||
last_name=sanitize_telegram_name(last_name) if last_name else None,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
|
||||
Vendored
-166
@@ -1,166 +0,0 @@
|
||||
"""Flask webhook server for PayPalych callbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from asyncio import AbstractEventLoop
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.pal24_service import Pal24APIError, Pal24Service
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_payload() -> dict[str, str]:
|
||||
if request.is_json:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
logger.warning('Pal24 webhook JSON payload не является объектом: %s', payload)
|
||||
return {}
|
||||
|
||||
if request.form:
|
||||
return {k: v for k, v in request.form.items()}
|
||||
|
||||
try:
|
||||
raw_body = request.data.decode('utf-8')
|
||||
if raw_body:
|
||||
payload = json.loads(raw_body)
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
except json.JSONDecodeError:
|
||||
logger.debug('Pal24 webhook body не удалось распарсить как JSON')
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def create_pal24_flask_app(
|
||||
payment_service: PaymentService,
|
||||
loop: AbstractEventLoop,
|
||||
) -> Flask:
|
||||
pal24_service = Pal24Service()
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=['POST'])
|
||||
def pal24_webhook() -> tuple:
|
||||
if not pal24_service.is_configured:
|
||||
logger.error('Pal24 webhook получен, но сервис не настроен')
|
||||
return jsonify({'status': 'error', 'reason': 'service_not_configured'}), 503
|
||||
|
||||
logger.debug('Получен Pal24 webhook: headers=%s', dict(request.headers))
|
||||
|
||||
payload = _normalize_payload()
|
||||
if not payload:
|
||||
logger.warning('Пустой Pal24 webhook')
|
||||
return jsonify({'status': 'error', 'reason': 'empty_payload'}), 400
|
||||
|
||||
try:
|
||||
parsed_payload = pal24_service.parse_callback(payload)
|
||||
except Pal24APIError as error:
|
||||
logger.error('Ошибка валидации Pal24 webhook: %s', error)
|
||||
return jsonify({'status': 'error', 'reason': str(error)}), 400
|
||||
|
||||
async def process() -> bool:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
return await payment_service.process_pal24_callback(db, parsed_payload)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(process(), loop)
|
||||
processed = future.result(timeout=settings.PAL24_REQUEST_TIMEOUT)
|
||||
except FuturesTimeoutError:
|
||||
logger.error('Обработка Pal24 webhook превысила таймаут %sс', settings.PAL24_REQUEST_TIMEOUT)
|
||||
return jsonify({'status': 'error', 'reason': 'timeout'}), 504
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception('Критическая ошибка обработки Pal24 webhook: %s', error)
|
||||
return jsonify({'status': 'error', 'reason': 'internal_error'}), 500
|
||||
|
||||
if processed:
|
||||
return jsonify({'status': 'ok'}), 200
|
||||
return jsonify({'status': 'error', 'reason': 'not_processed'}), 400
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=['GET'])
|
||||
def pal24_health() -> tuple:
|
||||
return jsonify(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'pal24_webhook',
|
||||
'enabled': settings.is_pal24_enabled(),
|
||||
}
|
||||
), 200
|
||||
|
||||
@app.route('/pal24/health', methods=['GET'])
|
||||
def pal24_additional_health() -> tuple:
|
||||
return jsonify(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'pal24_webhook',
|
||||
'path': settings.PAL24_WEBHOOK_PATH,
|
||||
}
|
||||
), 200
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class Pal24WebhookServer:
|
||||
"""Threaded Flask server for Pal24 callbacks."""
|
||||
|
||||
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
|
||||
self.app = create_pal24_flask_app(payment_service, loop)
|
||||
self._server: Any | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._server:
|
||||
logger.warning('Pal24 webhook server уже запущен')
|
||||
return
|
||||
|
||||
self._server = make_server(
|
||||
host='0.0.0.0',
|
||||
port=settings.PAL24_WEBHOOK_PORT,
|
||||
app=self.app,
|
||||
threaded=True,
|
||||
)
|
||||
|
||||
def _serve() -> None:
|
||||
logger.info(
|
||||
'Pal24 webhook сервер запущен на %s:%s%s',
|
||||
'0.0.0.0',
|
||||
settings.PAL24_WEBHOOK_PORT,
|
||||
settings.PAL24_WEBHOOK_PATH,
|
||||
)
|
||||
self._server.serve_forever()
|
||||
|
||||
self._thread = threading.Thread(target=_serve, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._server:
|
||||
logger.info('Останавливаем Pal24 webhook сервер')
|
||||
self._server.shutdown()
|
||||
self._server = None
|
||||
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
|
||||
|
||||
async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer:
|
||||
loop = asyncio.get_running_loop()
|
||||
server = Pal24WebhookServer(payment_service, loop)
|
||||
await loop.run_in_executor(None, server.start)
|
||||
return server
|
||||
Vendored
+24
@@ -999,6 +999,30 @@ class RemnaWaveAPI:
|
||||
uuid=data['uuid'], name=data['name'], view_position=data['viewPosition'], config=data.get('config')
|
||||
)
|
||||
|
||||
async def get_all_hwid_devices(self) -> dict[str, Any]:
|
||||
"""GET /api/hwid/devices — all devices for all users (paginated, max 1000/page)."""
|
||||
all_devices: list[dict[str, Any]] = []
|
||||
start = 0
|
||||
page_size = 1000
|
||||
|
||||
while True:
|
||||
response = await self._make_request('GET', '/api/hwid/devices', params={'start': start, 'size': page_size})
|
||||
data = response.get('response', {'devices': [], 'total': 0})
|
||||
devices = data.get('devices', [])
|
||||
total = data.get('total', 0)
|
||||
all_devices.extend(devices)
|
||||
|
||||
if len(all_devices) >= total or not devices:
|
||||
break
|
||||
start += len(devices)
|
||||
|
||||
return {'devices': all_devices, 'total': len(all_devices)}
|
||||
|
||||
async def get_all_panel_subscriptions(self) -> list[dict[str, Any]]:
|
||||
"""GET /api/subscriptions — all panel subscriptions."""
|
||||
response = await self._make_request('GET', '/api/subscriptions')
|
||||
return response.get('response') or []
|
||||
|
||||
async def get_user_devices(self, user_uuid: str) -> dict[str, Any]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
|
||||
|
||||
@@ -2602,8 +2602,15 @@ async def show_inactive_users(callback: types.CallbackQuery, db_user: User, db:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
with_active_sub = sum(1 for u in inactive_users if u.subscription and u.subscription.is_active)
|
||||
will_delete = len(inactive_users) - with_active_sub
|
||||
|
||||
text = '🗑️ <b>Неактивные пользователи</b>\n'
|
||||
text += f'Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n'
|
||||
text += f'Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n'
|
||||
if with_active_sub > 0:
|
||||
text += f'🛡️ С активной подпиской (не будут удалены): {with_active_sub}\n'
|
||||
text += f'🗑️ Будет удалено: {will_delete}\n'
|
||||
text += '\n'
|
||||
|
||||
for user in inactive_users[:10]:
|
||||
if user.telegram_id:
|
||||
@@ -2612,7 +2619,9 @@ async def show_inactive_users(callback: types.CallbackQuery, db_user: User, db:
|
||||
else:
|
||||
user_link = f'<b>{user.full_name}</b>'
|
||||
user_id_display = user.email or f'#{user.id}'
|
||||
text += f'👤 {user_link}\n'
|
||||
has_active = user.subscription and user.subscription.is_active
|
||||
sub_badge = ' 🛡️' if has_active else ''
|
||||
text += f'👤 {user_link}{sub_badge}\n'
|
||||
text += f'🆔 <code>{user_id_display}</code>\n'
|
||||
last_activity_display = (
|
||||
format_time_ago(user.last_activity, db_user.language) if user.last_activity else 'Никогда'
|
||||
@@ -4255,10 +4264,14 @@ async def _calculate_subscription_period_price(
|
||||
@error_handler
|
||||
async def cleanup_inactive_users(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
user_service = UserService()
|
||||
deleted_count = await user_service.cleanup_inactive_users(db)
|
||||
deleted_count, skipped_count = await user_service.cleanup_inactive_users(db)
|
||||
|
||||
text = f'✅ Очистка завершена\n\nУдалено неактивных пользователей: {deleted_count}'
|
||||
if skipped_count > 0:
|
||||
text += f'\n⏭️ Пропущено (активная подписка): {skipped_count}'
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ Очистка завершена\n\nУдалено неактивных пользователей: {deleted_count}',
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')]]
|
||||
),
|
||||
@@ -4621,6 +4634,8 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email,
|
||||
user_id=target_user.id,
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
@@ -4634,6 +4649,8 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email,
|
||||
user_id=target_user.id,
|
||||
)
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
create_kwargs = dict(
|
||||
@@ -4645,10 +4662,12 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email,
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
+80
-19
@@ -22,6 +22,7 @@ from app.database.crud.user import (
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.database.models import PinnedMessage, SubscriptionStatus, UserStatus
|
||||
from app.keyboards.inline import (
|
||||
get_back_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
get_main_menu_keyboard_async,
|
||||
get_post_registration_keyboard,
|
||||
@@ -485,9 +486,24 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.info(f'🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию')
|
||||
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import delete, update as sa_update
|
||||
|
||||
from app.database.models import PromoCodeUse, ReferralEarning, SubscriptionServer, Transaction
|
||||
from app.database.models import (
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
PromoCodeUse,
|
||||
ReferralEarning,
|
||||
SubscriptionServer,
|
||||
Transaction,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
|
||||
if user.subscription:
|
||||
await decrement_subscription_server_counts(db, user.subscription)
|
||||
@@ -502,9 +518,37 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
|
||||
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id))
|
||||
|
||||
await db.execute(
|
||||
sa_update(ReferralEarning)
|
||||
.where(ReferralEarning.user_id == user.id)
|
||||
.values(referral_transaction_id=None)
|
||||
)
|
||||
await db.execute(
|
||||
sa_update(ReferralEarning)
|
||||
.where(ReferralEarning.referral_id == user.id)
|
||||
.values(referral_transaction_id=None)
|
||||
)
|
||||
await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user.id))
|
||||
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id))
|
||||
|
||||
# Обнуляем transaction_id во всех таблицах платежей перед удалением транзакций
|
||||
payment_models = [
|
||||
YooKassaPayment,
|
||||
CryptoBotPayment,
|
||||
HeleketPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
WataPayment,
|
||||
PlategaPayment,
|
||||
CloudPaymentsPayment,
|
||||
FreekassaPayment,
|
||||
KassaAiPayment,
|
||||
]
|
||||
for payment_model in payment_models:
|
||||
await db.execute(
|
||||
sa_update(payment_model).where(payment_model.user_id == user.id).values(transaction_id=None)
|
||||
)
|
||||
|
||||
await db.execute(delete(Transaction).where(Transaction.user_id == user.id))
|
||||
|
||||
user.status = UserStatus.ACTIVE.value
|
||||
@@ -1450,9 +1494,16 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
|
||||
if offer_text and not skip_welcome_offer:
|
||||
try:
|
||||
# Если у пользователя уже есть подписка (например, от промокода), не предлагаем триал
|
||||
user_has_subscription = user.subscription and getattr(user.subscription, 'is_active', False)
|
||||
if user_has_subscription:
|
||||
keyboard = get_back_keyboard(user.language, callback_data='back_to_menu')
|
||||
else:
|
||||
keyboard = get_post_registration_keyboard(user.language)
|
||||
|
||||
await message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(user.language),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}')
|
||||
await _send_pinned_message(message.bot, db, user)
|
||||
@@ -1829,9 +1880,7 @@ async def required_sub_channel_check(
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
@@ -1858,13 +1907,14 @@ async def required_sub_channel_check(
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=menu_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
_cache_logo_file_id(_result)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
@@ -1924,9 +1974,7 @@ async def required_sub_channel_check(
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
@@ -1953,13 +2001,14 @@ async def required_sub_channel_check(
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=menu_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
_cache_logo_file_id(_result)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
@@ -1979,19 +2028,18 @@ async def required_sub_channel_check(
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
else:
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=rules_text,
|
||||
reply_markup=get_rules_keyboard(language),
|
||||
)
|
||||
_cache_logo_file_id(_result)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
@@ -2000,9 +2048,22 @@ async def required_sub_channel_check(
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
error_msg = str(e).lower()
|
||||
if 'query is too old' in error_msg or 'query id is invalid' in error_msg:
|
||||
logger.debug('Устаревший callback в required_sub_channel_check, игнорируем')
|
||||
else:
|
||||
logger.error(f'Ошибка Telegram API в required_sub_channel_check: {e}')
|
||||
try:
|
||||
await query.answer(f'{texts.ERROR}!', show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в required_sub_channel_check: {e}')
|
||||
await query.answer(f'{texts.ERROR}!', show_alert=True)
|
||||
try:
|
||||
await query.answer(f'{texts.ERROR}!', show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
@@ -416,8 +416,14 @@ def get_traffic_switch_keyboard(
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=button_text, callback_data=f'switch_traffic_{gb}')])
|
||||
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
buttons.append(
|
||||
[InlineKeyboardButton(text='⬅️ Назад' if language == 'ru' else '⬅️ Back', callback_data='subscription_settings')]
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='⬅️ Назад' if language_code in {'ru', 'fa'} else '⬅️ Back',
|
||||
callback_data='subscription_settings',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
@@ -416,6 +416,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
).format(
|
||||
amount=texts.format_price(price),
|
||||
period=period_label,
|
||||
months=period_label,
|
||||
)
|
||||
if total_discount > 0:
|
||||
cost_text += texts.t(
|
||||
|
||||
@@ -3227,6 +3227,9 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
|
||||
await db.refresh(db_user)
|
||||
|
||||
# Сохраняем ID до начала транзакции (на случай detached session)
|
||||
user_id_snapshot = db_user.id
|
||||
|
||||
# Создаем триальную подписку
|
||||
subscription: Subscription | None = None
|
||||
remnawave_user = None
|
||||
@@ -3388,22 +3391,33 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Unexpected error during paid trial activation for user %s: %s',
|
||||
db_user.id,
|
||||
user_id_snapshot,
|
||||
error,
|
||||
)
|
||||
# Пытаемся откатить и вернуть деньги
|
||||
if subscription:
|
||||
await rollback_trial_subscription_activation(db, subscription)
|
||||
from app.database.crud.user import add_user_balance
|
||||
# Откатываем сессию чтобы очистить PendingRollbackError
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
trial_price_kopeks,
|
||||
texts.t('TRIAL_REFUND_DESCRIPTION', 'Возврат за неудачную активацию триала'),
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
await db.refresh(db_user)
|
||||
# Пытаемся вернуть деньги
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
trial_price_kopeks,
|
||||
texts.t('TRIAL_REFUND_DESCRIPTION', 'Возврат за неудачную активацию триала'),
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
await db.refresh(db_user)
|
||||
except Exception as refund_error:
|
||||
logger.error(
|
||||
'Failed to refund trial payment for user %s: %s',
|
||||
user_id_snapshot,
|
||||
refund_error,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
|
||||
@@ -1424,10 +1424,23 @@ async def confirm_daily_tariff_purchase(
|
||||
# ==================== Продление по тарифу ====================
|
||||
|
||||
|
||||
def _calc_extra_devices_cost(tariff: Tariff, subscription_device_limit: int, period_days: int) -> int:
|
||||
"""Рассчитывает стоимость дополнительных устройств сверх тарифа для периода."""
|
||||
additional = max(0, subscription_device_limit - (tariff.device_limit or 1))
|
||||
if additional <= 0:
|
||||
return 0
|
||||
device_price = getattr(tariff, 'device_price_kopeks', None) or 0
|
||||
if device_price <= 0:
|
||||
return 0
|
||||
months = max(1, round(period_days / 30))
|
||||
return additional * device_price * months
|
||||
|
||||
|
||||
def get_tariff_extend_keyboard(
|
||||
tariff: Tariff,
|
||||
language: str,
|
||||
db_user: User | None = None,
|
||||
subscription_device_limit: int | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру выбора периода для продления по тарифу с учетом скидок по периодам."""
|
||||
texts = get_texts(language)
|
||||
@@ -1438,6 +1451,10 @@ def get_tariff_extend_keyboard(
|
||||
period = int(period_str)
|
||||
price = prices[period_str]
|
||||
|
||||
# Добавляем стоимость дополнительных устройств
|
||||
if subscription_device_limit is not None:
|
||||
price += _calc_extra_devices_cost(tariff, subscription_device_limit, period)
|
||||
|
||||
# Получаем скидку для конкретного периода
|
||||
discount_percent = 0
|
||||
if db_user:
|
||||
@@ -1508,13 +1525,17 @@ async def show_tariff_extend(
|
||||
if has_period_discounts:
|
||||
discount_hint = '\n🎁 <i>Скидки зависят от выбранного периода</i>'
|
||||
|
||||
actual_device_limit = subscription.device_limit or tariff.device_limit
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🔄 <b>Продление подписки</b>{discount_hint}\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n\n'
|
||||
f'📱 Устройств: {actual_device_limit}\n\n'
|
||||
'Выберите период продления:',
|
||||
reply_markup=get_tariff_extend_keyboard(tariff, db_user.language, db_user=db_user),
|
||||
reply_markup=get_tariff_extend_keyboard(
|
||||
tariff, db_user.language, db_user=db_user, subscription_device_limit=actual_device_limit
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -1538,12 +1559,16 @@ async def select_tariff_extend_period(
|
||||
await callback.answer('Тариф недоступен', show_alert=True)
|
||||
return
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
actual_device_limit = (subscription.device_limit if subscription else None) or tariff.device_limit
|
||||
|
||||
# Получаем скидку для выбранного периода
|
||||
discount_percent = _get_user_period_discount(db_user, period)
|
||||
|
||||
# Получаем цену
|
||||
# Получаем цену (тариф + дополнительные устройства)
|
||||
prices = tariff.period_prices or {}
|
||||
base_price = prices.get(str(period), 0)
|
||||
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period)
|
||||
final_price = _apply_promo_discount(base_price, discount_percent)
|
||||
|
||||
# Проверяем баланс
|
||||
@@ -1560,7 +1585,7 @@ async def select_tariff_extend_period(
|
||||
f'✅ <b>Подтверждение продления</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'📱 Устройств: {actual_device_limit}\n'
|
||||
f'📅 Период: {_format_period(period)}\n'
|
||||
f'{discount_text}\n'
|
||||
f'💰 <b>К оплате: {_format_price_kopeks(final_price)}</b>\n\n'
|
||||
@@ -1572,9 +1597,6 @@ async def select_tariff_extend_period(
|
||||
else:
|
||||
missing = final_price - user_balance
|
||||
|
||||
# Получаем текущую подписку для сохранения в корзину
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
# Сохраняем данные корзины для автопокупки после пополнения
|
||||
cart_data = {
|
||||
'cart_mode': 'extend',
|
||||
@@ -1588,7 +1610,7 @@ async def select_tariff_extend_period(
|
||||
'return_to_cart': True,
|
||||
'description': f'Продление тарифа {tariff.name} на {period} дней',
|
||||
'traffic_limit_gb': tariff.traffic_limit_gb,
|
||||
'device_limit': tariff.device_limit,
|
||||
'device_limit': actual_device_limit,
|
||||
'allowed_squads': tariff.allowed_squads or [],
|
||||
'discount_percent': discount_percent,
|
||||
}
|
||||
@@ -1641,12 +1663,15 @@ async def confirm_tariff_extend(
|
||||
await callback.answer('Подписка не найдена', show_alert=True)
|
||||
return
|
||||
|
||||
actual_device_limit = subscription.device_limit or tariff.device_limit
|
||||
|
||||
data = await state.get_data()
|
||||
discount_percent = data.get('extend_discount_percent', 0)
|
||||
|
||||
# Получаем цену
|
||||
# Получаем цену (тариф + дополнительные устройства)
|
||||
prices = tariff.period_prices or {}
|
||||
base_price = prices.get(str(period), 0)
|
||||
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period)
|
||||
final_price = _apply_promo_discount(base_price, discount_percent)
|
||||
|
||||
# Проверяем баланс
|
||||
@@ -1724,7 +1749,7 @@ async def confirm_tariff_extend(
|
||||
f'🎉 <b>Подписка успешно продлена!</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'📱 Устройств: {actual_device_limit}\n'
|
||||
f'📅 Добавлено: {_format_period(period)}\n'
|
||||
f'💰 Списано: {_format_price_kopeks(final_price)}',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
|
||||
+18
-6
@@ -247,6 +247,8 @@ _LANGUAGE_DISPLAY_NAMES = {
|
||||
'zh-hant': '🇹🇼 中文 (繁體)',
|
||||
'vi': '🇻🇳 Tiếng Việt',
|
||||
'vi-vn': '🇻🇳 Tiếng Việt',
|
||||
'fa': '🇮🇷 فارسی',
|
||||
'fa-ir': '🇮🇷 فارسی',
|
||||
}
|
||||
|
||||
|
||||
@@ -1789,6 +1791,8 @@ def get_add_traffic_keyboard(
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
|
||||
texts = get_texts(language)
|
||||
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
months_multiplier = 1
|
||||
period_text = ''
|
||||
@@ -1826,17 +1830,20 @@ def get_add_traffic_keyboard(
|
||||
total_discount = discount_per_month * months_multiplier
|
||||
|
||||
if gb == 0:
|
||||
if language == 'ru':
|
||||
if use_russian_fallback:
|
||||
text = f'♾️ Безлимитный трафик - {total_price // 100} ₽{period_text}'
|
||||
else:
|
||||
text = f'♾️ Unlimited traffic - {total_price // 100} ₽{period_text}'
|
||||
elif language == 'ru':
|
||||
elif use_russian_fallback:
|
||||
text = f'📊 +{gb} ГБ трафика - {total_price // 100} ₽{period_text}'
|
||||
else:
|
||||
text = f'📊 +{gb} GB traffic - {total_price // 100} ₽{period_text}'
|
||||
|
||||
if discount_percent > 0 and total_discount > 0:
|
||||
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
|
||||
if use_russian_fallback:
|
||||
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
|
||||
else:
|
||||
text += f' (discount {discount_percent}%: -{total_discount // 100}₽)'
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
|
||||
|
||||
@@ -1861,6 +1868,8 @@ def get_add_traffic_keyboard_from_tariff(
|
||||
discount_percent: Процент скидки
|
||||
"""
|
||||
texts = get_texts(language)
|
||||
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
if not packages:
|
||||
return InlineKeyboardMarkup(
|
||||
@@ -1888,15 +1897,18 @@ def get_add_traffic_keyboard_from_tariff(
|
||||
discount_percent,
|
||||
)
|
||||
|
||||
period_text = ' /мес' if language == 'ru' else ' /mo'
|
||||
period_text = ' /мес' if use_russian_fallback else ' /mo'
|
||||
|
||||
if language == 'ru':
|
||||
if use_russian_fallback:
|
||||
text = f'📊 +{gb} ГБ трафика - {discounted_price // 100} ₽{period_text}'
|
||||
else:
|
||||
text = f'📊 +{gb} GB traffic - {discounted_price // 100} ₽{period_text}'
|
||||
|
||||
if discount_percent > 0 and discount_value > 0:
|
||||
text += f' (скидка {discount_percent}%: -{discount_value // 100}₽)'
|
||||
if use_russian_fallback:
|
||||
text += f' (скидка {discount_percent}%: -{discount_value // 100}₽)'
|
||||
else:
|
||||
text += f' (discount {discount_percent}%: -{discount_value // 100}₽)'
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ def ensure_locale_templates() -> None:
|
||||
_copy_locale(template, destination / template.name)
|
||||
return
|
||||
|
||||
for locale_code in ('ru', 'en'):
|
||||
for locale_code in ('ru', 'en', 'fa'):
|
||||
source_path = _DEFAULT_LOCALES_DIR / f'{locale_code}.json'
|
||||
target_path = destination / f'{locale_code}.json'
|
||||
|
||||
|
||||
@@ -1548,6 +1548,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED": "We couldn't finish setting up the trial. Any charge has been refunded. Please try again later.",
|
||||
"TRIAL_ROLLBACK_FAILED": "We couldn't cancel the trial activation after a payment error. Please contact support and try again later.",
|
||||
"TRIAL_REFUND_FAILED": "We couldn't refund the trial activation charge. Please contact support immediately.",
|
||||
"TRIAL_PAYMENT_DESCRIPTION": "Trial subscription payment",
|
||||
"TRIAL_REFUND_DESCRIPTION": "Refund for failed trial activation",
|
||||
"TRIAL_ACTIVATION_ERROR": "❌ An error occurred during trial activation. Funds have been returned to your balance.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 {amount} has been deducted from your balance.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1569,6 +1569,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED": "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
|
||||
"TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
|
||||
"TRIAL_REFUND_FAILED": "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
|
||||
"TRIAL_PAYMENT_DESCRIPTION": "Оплата пробной подписки",
|
||||
"TRIAL_REFUND_DESCRIPTION": "Возврат за неудачную активацию триала",
|
||||
"TRIAL_ACTIVATION_ERROR": "❌ Произошла ошибка при активации триала. Средства возвращены на баланс.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 С вашего баланса списано {amount}.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
|
||||
|
||||
@@ -1479,6 +1479,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED": "Не вдалося завершити активацію тріалу. Кошти повернуто на баланс. Спробуйте пізніше.",
|
||||
"TRIAL_ROLLBACK_FAILED": "Не вдалося скасувати активацію тріалу після помилки списання. Зв'яжіться з підтримкою і спробуйте пізніше.",
|
||||
"TRIAL_REFUND_FAILED": "Не вдалося повернути оплату за активацію тріалу. Негайно зв'яжіться з підтримкою.",
|
||||
"TRIAL_PAYMENT_DESCRIPTION": "Оплата пробної підписки",
|
||||
"TRIAL_REFUND_DESCRIPTION": "Повернення за невдалу активацію тріалу",
|
||||
"TRIAL_ACTIVATION_ERROR": "❌ Виникла помилка при активації тріалу. Кошти повернуто на баланс.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 З вашого балансу списано {amount}.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ призупинено</b>\n\nМи не знайшли вашу підписку на наш канал, тому тестову підписку вимкнено.\n\nПідпишіться на канал і натисніть «{check_button}», щоб повернути доступ.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестова підписка скоро закінчиться!</b>\n\nВаша тестова підписка закінчується через декілька годин.\n\n💎 <b>Не хочете залишитися без VPN?</b>\nПереходьте на повну підписку!\n\n🔥 <b>Спеціальна пропозиція:</b>\n• 30 днів усього за {price}\n• Безлімітний трафік \n• Всі сервери доступні\n• Швидкість до 1ГБіт/сек\n\n⚡️ Встигніть оформити до закінчення тестового періоду!\n",
|
||||
|
||||
@@ -1477,6 +1477,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED":"未能完成试用激活。资金已退回余额。请稍后再试。",
|
||||
"TRIAL_ROLLBACK_FAILED":"扣款失败后未能取消试用激活。请联系支持并稍后再试。",
|
||||
"TRIAL_REFUND_FAILED":"未能退还试用激活付款。请立即联系支持。",
|
||||
"TRIAL_PAYMENT_DESCRIPTION":"试用订阅付款",
|
||||
"TRIAL_REFUND_DESCRIPTION":"试用激活失败退款",
|
||||
"TRIAL_ACTIVATION_ERROR":"❌ 试用激活出错。资金已退回余额。",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE":"💳已从您的余额中扣除{amount}。",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED":"\n🚫<b>访问已暂停</b>\n\n我们未找到您对我们频道的订阅,因此试用订阅已禁用。\n\n请订阅频道并点击“{check_button}”以恢复访问。",
|
||||
"TRIAL_ENDING_SOON":"\n🎁<b>试用订阅即将结束!</b>\n\n您的试用订阅将在几小时后过期。\n\n💎<b>不想没有VPN吗?</b>\n升级到完整订阅!\n\n🔥<b>特别优惠:</b>\n•30天仅需{price}\n•无限流量 \n•所有服务器可用\n•速度高达1Gbit/s\n\n⚡️赶在试用期结束前订购吧!\n",
|
||||
@@ -1807,6 +1810,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED":"未能完成试用激活。资金已退回余额。请稍后再试。",
|
||||
"TRIAL_ROLLBACK_FAILED":"扣款失败后未能取消试用激活。请联系支持并稍后再试。",
|
||||
"TRIAL_REFUND_FAILED":"未能退还试用激活付款。请立即联系支持。",
|
||||
"TRIAL_PAYMENT_DESCRIPTION":"试用订阅付款",
|
||||
"TRIAL_REFUND_DESCRIPTION":"试用激活失败退款",
|
||||
"TRIAL_ACTIVATION_ERROR":"❌ 试用激活出错。资金已退回余额。",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE":"💳已从您的余额中扣除{amount}。",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED":"\n🚫<b>访问已暂停</b>\n\n我们未找到您对我们频道的订阅,因此试用订阅已禁用。\n\n请订阅频道并点击“{check_button}”以恢复访问。",
|
||||
"TRIAL_ENDING_SOON":"\n🎁<b>试用订阅即将结束!</b>\n\n您的试用订阅将在几小时后过期。\n\n💎<b>不想没有VPN吗?</b>\n升级到完整订阅!\n\n🔥<b>特别优惠:</b>\n•30天仅需{price}\n•无限流量 \n•所有服务器可用\n•速度高达1Gbit/s\n\n⚡️赶在试用期结束前订购吧!\n",
|
||||
|
||||
@@ -35,6 +35,18 @@ _DYNAMIC_LANGUAGE_CONFIGS = {
|
||||
'Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n'
|
||||
),
|
||||
},
|
||||
'fa': {
|
||||
'traffic_pattern': '📊 {size} گیگابایت - {price}',
|
||||
'unlimited_pattern': '📊 نامحدود - {price}',
|
||||
'support_info': (
|
||||
'\n🛟 <b>پشتیبانی</b>\n\n'
|
||||
'برای هرگونه سؤال به پشتیبانی پیام دهید:\n\n'
|
||||
'👤 {support_username}\n\n'
|
||||
'• 🎫 ایجاد تیکت\n'
|
||||
'• 📋 تیکتهای من\n'
|
||||
'• 💬 تماس مستقیم\n'
|
||||
),
|
||||
},
|
||||
'en': {
|
||||
'traffic_pattern': '📊 {size} GB - {price}',
|
||||
'unlimited_pattern': '📊 Unlimited - {price}',
|
||||
|
||||
@@ -6,7 +6,6 @@ from typing import Any
|
||||
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -108,13 +107,17 @@ class MonitoringService:
|
||||
|
||||
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and (text is None or len(text) <= 1000):
|
||||
try:
|
||||
return await self.bot.send_photo(
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
result = await self.bot.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
_cache_logo_file_id(result)
|
||||
return result
|
||||
except TelegramBadRequest as exc:
|
||||
logger.warning(
|
||||
'Не удалось отправить сообщение с логотипом пользователю %s: %s. Отправляем текстовое сообщение.',
|
||||
|
||||
@@ -12,7 +12,6 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.cloudpayments_service import CloudPaymentsAPIError
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -262,22 +261,11 @@ class CloudPaymentsPaymentMixin:
|
||||
logger.exception('Ошибка отправки уведомления CloudPayments: %s', error)
|
||||
|
||||
# Auto-purchase if enabled
|
||||
auto_purchase_success = False
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=getattr(self, 'bot', None))
|
||||
await auto_purchase_saved_cart_after_topup(db, user, bot=getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.exception('Ошибка автопокупки после CloudPayments: %s', error)
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
# Игнорируем notification_sent т.к. здесь нет дополнительных уведомлений
|
||||
await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=amount_kopeks
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception('Ошибка умной автоактивации после CloudPayments: %s', error)
|
||||
|
||||
return True
|
||||
|
||||
async def process_cloudpayments_fail_webhook(
|
||||
|
||||
@@ -171,79 +171,18 @@ class PaymentCommonMixin:
|
||||
try:
|
||||
payment_method = payment_method_title or 'Банковская карта (YooKassa)'
|
||||
|
||||
# Проверяем, нужно ли показывать яркое предупреждение об активации
|
||||
if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||
# Определяем статус подписки для выбора правильной кнопки
|
||||
has_active_subscription = False
|
||||
if user_snapshot:
|
||||
try:
|
||||
subscription = user_snapshot.subscription
|
||||
has_active_subscription = bool(
|
||||
subscription
|
||||
and not getattr(subscription, 'is_trial', False)
|
||||
and getattr(subscription, 'is_active', False)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Яркое сообщение с восклицательными знаками
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(amount_kopeks)}\n'
|
||||
f'💳 Способ: {payment_method}\n\n'
|
||||
'💎 Средства зачислены на ваш баланс!\n\n'
|
||||
'‼️ <b>ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ!</b> ‼️\n\n'
|
||||
'⚠️ Пополнение баланса <b>НЕ АКТИВИРУЕТ</b> подписку автоматически!\n\n'
|
||||
'👇 <b>НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ</b> 👇'
|
||||
)
|
||||
|
||||
# Формируем клавиатуру с кнопками действий
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
# Кнопка активации или продления в зависимости от статуса
|
||||
if has_active_subscription:
|
||||
# Активная платная подписка - показываем продление и изменение устройств
|
||||
keyboard_rows.append(
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text='🔄 ПРОДЛИТЬ ПОДПИСКУ',
|
||||
callback_data='subscription_extend',
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard_rows.append(
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text='📱 Изменить количество устройств',
|
||||
callback_data='subscription_change_devices',
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Нет подписки или истекла - показываем только активацию
|
||||
keyboard_rows.append(
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text='🔥 АКТИВИРОВАТЬ ПОДПИСКУ',
|
||||
callback_data='menu_buy',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
else:
|
||||
# Стандартное сообщение с полной клавиатурой
|
||||
keyboard = await self.build_topup_success_keyboard(user_snapshot)
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(amount_kopeks)}\n'
|
||||
f'💳 Способ: {payment_method}\n\n'
|
||||
'Средства зачислены на ваш баланс!\n\n'
|
||||
'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
||||
'Обязательно активируйте подписку отдельно!\n\n'
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.'
|
||||
)
|
||||
# Стандартное сообщение с полной клавиатурой
|
||||
keyboard = await self.build_topup_success_keyboard(user_snapshot)
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(amount_kopeks)}\n'
|
||||
f'💳 Способ: {payment_method}\n\n'
|
||||
'Средства зачислены на ваш баланс!\n\n'
|
||||
'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
||||
'Обязательно активируйте подписку отдельно!\n\n'
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.'
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.services.subscription_renewal_service import (
|
||||
@@ -361,26 +360,7 @@ class CryptoBotPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=bot_instance,
|
||||
topup_amount=amount_kopeks,
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and bot_instance and not activation_notification_sent:
|
||||
if has_saved_cart and bot_instance:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -14,7 +14,6 @@ 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.payment_logger import payment_logger as logger
|
||||
@@ -388,23 +387,7 @@ class FreekassaPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
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) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -13,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -452,22 +451,6 @@ class HeleketPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, 'bot', None),
|
||||
topup_amount=amount_kopeks,
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с автоактивацией для пользователя %s: %s',
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.kassa_ai_service import kassa_ai_service
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -339,34 +338,14 @@ class KassaAiPaymentMixin:
|
||||
try:
|
||||
display_name = settings.get_kassa_ai_display_name()
|
||||
|
||||
if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||
# Яркое сообщение для тупых
|
||||
from aiogram import types
|
||||
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'💳 Способ: {display_name}\n\n'
|
||||
'💎 Средства зачислены на ваш баланс!\n\n'
|
||||
'‼️ <b>ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ!</b> ‼️\n\n'
|
||||
'⚠️ Пополнение баланса <b>НЕ АКТИВИРУЕТ</b> подписку автоматически!\n\n'
|
||||
'👇 <b>НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ</b> 👇'
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔥 АКТИВИРОВАТЬ ПОДПИСКУ', callback_data='menu_buy')],
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Стандартное сообщение (как было раньше)
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
message = (
|
||||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'💳 Способ: {display_name}\n'
|
||||
f'🆔 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
)
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
message = (
|
||||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'💳 Способ: {display_name}\n'
|
||||
f'🆔 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
@@ -404,23 +383,7 @@ class KassaAiPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
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) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -390,28 +389,7 @@ class MulenPayPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили и есть telegram_id
|
||||
if (
|
||||
has_saved_cart
|
||||
and getattr(self, 'bot', None)
|
||||
and not activation_notification_sent
|
||||
and user.telegram_id
|
||||
):
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.pal24_service import Pal24APIError
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -489,23 +488,7 @@ class Pal24PaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
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) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -470,23 +469,7 @@ class PlategaPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
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) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -19,7 +19,6 @@ from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -534,26 +533,7 @@ class TelegramStarsMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, 'bot', None),
|
||||
topup_amount=amount_kopeks,
|
||||
)
|
||||
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) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
'BALANCE_TOPUP_CART_REMINDER_DETAILED',
|
||||
|
||||
@@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.services.wata_service import WataAPIError, WataService
|
||||
@@ -575,23 +574,7 @@ class WataPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
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) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -16,7 +16,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -847,78 +846,59 @@ class YooKassaPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, 'bot', None),
|
||||
)
|
||||
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) and user.telegram_id:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from aiogram import types
|
||||
|
||||
# Если включен яркий промпт активации, пропускаем старое уведомление
|
||||
# т.к. оно будет отправлено через _send_payment_success_notification
|
||||
if not settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from aiogram import types
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
)
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
)
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data='return_to_saved_cart',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='💰 Мой баланс',
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🏠 Главное меню',
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data='return_to_saved_cart',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='💰 Мой баланс',
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
[
|
||||
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'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
||||
f'Обязательно активируйте подписку отдельно!\n\n'
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}'
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
'У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена',
|
||||
user.id,
|
||||
)
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f'✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n'
|
||||
f'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
||||
f'Обязательно активируйте подписку отдельно!\n\n'
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}'
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
'У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена',
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}',
|
||||
|
||||
@@ -1682,38 +1682,47 @@ class RemnaWaveService:
|
||||
# expire_at приходит в UTC (naive) из _parse_remnawave_date
|
||||
expire_at = self._parse_remnawave_date(expire_at_str)
|
||||
|
||||
# Конвертируем локальную дату из БД в UTC для корректного сравнения
|
||||
# subscription.end_date хранится в локальной таймзоне (MSK)
|
||||
local_end_date_utc = self._local_to_utc(subscription.end_date)
|
||||
# Обновляем end_date только если пользователь ACTIVE в панели.
|
||||
# Для EXPIRED/DISABLED панель может содержать искусственную дату
|
||||
# (установленную _safe_expire_at_for_panel при sync_users_to_panel),
|
||||
# которая не должна перезаписывать реальную дату окончания подписки.
|
||||
if panel_status == 'ACTIVE':
|
||||
# Конвертируем локальную дату из БД в UTC для корректного сравнения
|
||||
local_end_date_utc = self._local_to_utc(subscription.end_date)
|
||||
|
||||
# КРИТИЧНО: НЕ перезаписываем end_date если локальная дата ПОЗЖЕ
|
||||
# Это защищает от ситуации когда подписка была продлена в боте,
|
||||
# но RemnaWave ещё не получил обновление или вернул старую дату
|
||||
time_diff = abs((local_end_date_utc - expire_at).total_seconds())
|
||||
if time_diff > 60:
|
||||
if expire_at > local_end_date_utc:
|
||||
# RemnaWave имеет более позднюю дату - обновляем
|
||||
# Конвертируем UTC обратно в локальное время для сохранения в БД
|
||||
new_end_date_local = (
|
||||
expire_at.replace(tzinfo=self._utc_timezone)
|
||||
.astimezone(self._panel_timezone)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
logger.info(
|
||||
f'✅ Sync: обновлена end_date для user {getattr(user, "telegram_id", "?")}: '
|
||||
f'{subscription.end_date} -> {new_end_date_local} (разница: {time_diff:.0f}с)'
|
||||
)
|
||||
subscription.end_date = new_end_date_local
|
||||
# КРИТИЧНО: НЕ перезаписываем end_date если локальная дата ПОЗЖЕ
|
||||
# Это защищает от ситуации когда подписка была продлена в боте,
|
||||
# но RemnaWave ещё не получил обновление или вернул старую дату
|
||||
time_diff = abs((local_end_date_utc - expire_at).total_seconds())
|
||||
if time_diff > 60:
|
||||
if expire_at > local_end_date_utc:
|
||||
# RemnaWave имеет более позднюю дату - обновляем
|
||||
# Конвертируем UTC обратно в локальное время для сохранения в БД
|
||||
new_end_date_local = (
|
||||
expire_at.replace(tzinfo=self._utc_timezone)
|
||||
.astimezone(self._panel_timezone)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
logger.info(
|
||||
f'✅ Sync: обновлена end_date для user {getattr(user, "telegram_id", "?")}: '
|
||||
f'{subscription.end_date} -> {new_end_date_local} (разница: {time_diff:.0f}с)'
|
||||
)
|
||||
subscription.end_date = new_end_date_local
|
||||
else:
|
||||
# Локальная дата позже - НЕ перезаписываем
|
||||
logger.debug(
|
||||
f'⏭️ Sync: end_date для user {getattr(user, "telegram_id", "?")} актуальна: '
|
||||
f'локальная ({subscription.end_date} / UTC: {local_end_date_utc}) >= RemnaWave ({expire_at} UTC)'
|
||||
)
|
||||
else:
|
||||
# Локальная дата позже - НЕ перезаписываем
|
||||
logger.debug(
|
||||
f'⏭️ Sync: end_date для user {getattr(user, "telegram_id", "?")} актуальна: '
|
||||
f'локальная ({subscription.end_date} / UTC: {local_end_date_utc}) >= RemnaWave ({expire_at} UTC)'
|
||||
f'⏭️ Sync: пропускаем обновление end_date для user {getattr(user, "telegram_id", "?")}: '
|
||||
f'разница слишком мала ({time_diff:.0f}с < 60с)'
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f'⏭️ Sync: пропускаем обновление end_date для user {getattr(user, "telegram_id", "?")}: '
|
||||
f'разница слишком мала ({time_diff:.0f}с < 60с)'
|
||||
f'панель не ACTIVE (статус: {panel_status})'
|
||||
)
|
||||
|
||||
current_time = self._now_utc()
|
||||
@@ -1861,6 +1870,8 @@ class RemnaWaveService:
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
create_kwargs = dict(
|
||||
@@ -1895,6 +1906,15 @@ class RemnaWaveService:
|
||||
panel_uuid = existing_users[0].uuid
|
||||
logger.debug(f'Найден пользователь {user.telegram_id} в панели: {panel_uuid}')
|
||||
|
||||
# Fallback: поиск по email (для OAuth юзеров без telegram_id)
|
||||
if not panel_uuid and user.email:
|
||||
existing_users = await api.get_user_by_email(user.email)
|
||||
if existing_users:
|
||||
panel_uuid = existing_users[0].uuid
|
||||
logger.debug(
|
||||
f'Найден пользователь {user.email} в панели по email: {panel_uuid}'
|
||||
)
|
||||
|
||||
if panel_uuid:
|
||||
update_kwargs = dict(
|
||||
uuid=panel_uuid,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Final
|
||||
|
||||
@@ -24,7 +23,6 @@ from app.utils.timezone import format_local_datetime
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Константы
|
||||
VERSION_ENV_VAR: Final[str] = 'VERSION'
|
||||
DEFAULT_VERSION: Final[str] = 'dev'
|
||||
DEFAULT_AUTH_TYPE: Final[str] = 'api_key'
|
||||
|
||||
@@ -70,10 +68,20 @@ class StartupNotificationService:
|
||||
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
def _get_version(self) -> str:
|
||||
"""Получает версию из переменной окружения VERSION."""
|
||||
version = os.getenv(VERSION_ENV_VAR, '').strip()
|
||||
if version:
|
||||
return version
|
||||
"""Получает версию из pyproject.toml."""
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
pyproject_path = Path(__file__).resolve().parents[2] / 'pyproject.toml'
|
||||
if pyproject_path.exists():
|
||||
for line in pyproject_path.read_text().splitlines():
|
||||
if line.strip().startswith('version'):
|
||||
ver = line.split('=', 1)[1].strip().strip('"').strip("'")
|
||||
if ver:
|
||||
return ver
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return DEFAULT_VERSION
|
||||
|
||||
async def _get_users_count(self) -> int:
|
||||
|
||||
@@ -1814,340 +1814,4 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
return True
|
||||
|
||||
|
||||
async def auto_activate_subscription_after_topup(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
*,
|
||||
bot: Bot | None = None,
|
||||
topup_amount: int | None = None,
|
||||
) -> tuple[bool, bool]:
|
||||
"""
|
||||
Умная автоактивация после пополнения баланса.
|
||||
|
||||
Работает БЕЗ сохранённой корзины:
|
||||
- Если подписка активна — ничего не делает
|
||||
- Если подписка истекла — продлевает с теми же параметрами
|
||||
- Если подписки нет — создаёт новую с дефолтными параметрами
|
||||
|
||||
Выбирает максимальный период, который можно оплатить из баланса.
|
||||
|
||||
Args:
|
||||
topup_amount: Сумма пополнения в копейках (для отображения в уведомлении)
|
||||
|
||||
Returns:
|
||||
tuple[bool, bool]: (success, notification_sent)
|
||||
- success: True если подписка активирована
|
||||
- notification_sent: True если уведомление отправлено пользователю
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Lazy imports to avoid circular dependency
|
||||
from app.cabinet.routes.websocket import (
|
||||
notify_user_subscription_activated,
|
||||
notify_user_subscription_renewed,
|
||||
)
|
||||
from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids
|
||||
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_renewal_service import SubscriptionRenewalService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
if not user or not getattr(user, 'id', None):
|
||||
return (False, False)
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# Если автоактивация отключена - уведомление отправится из _send_payment_success_notification
|
||||
if not settings.is_auto_activate_after_topup_enabled():
|
||||
logger.info(
|
||||
'⚠️ Автоактивация отключена для пользователя %s, уведомление будет отправлено из payment service',
|
||||
_format_user_id(user),
|
||||
)
|
||||
return (False, False)
|
||||
|
||||
# Если подписка активна — ничего не делаем (автоактивация включена, но подписка уже есть)
|
||||
if subscription and subscription.status == 'ACTIVE' and subscription.end_date > datetime.utcnow():
|
||||
logger.info(
|
||||
'🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем',
|
||||
_format_user_id(user),
|
||||
)
|
||||
return (False, False)
|
||||
|
||||
# Определяем параметры подписки
|
||||
if subscription:
|
||||
device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
# В режиме fixed_with_topup при автоактивации используем фиксированный лимит
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_limit_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
traffic_limit_gb = subscription.traffic_limit_gb or 0
|
||||
connected_squads = subscription.connected_squads or []
|
||||
else:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
# В режиме fixed_with_topup при автоактивации используем фиксированный лимит
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_limit_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
traffic_limit_gb = 0
|
||||
connected_squads = []
|
||||
|
||||
# Если серверы не выбраны — берём бесплатные по умолчанию
|
||||
if not connected_squads:
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=user.promo_group_id)
|
||||
connected_squads = [s.squad_uuid for s in available_servers if s.is_available and s.price_kopeks == 0]
|
||||
if not connected_squads and available_servers:
|
||||
connected_squads = [available_servers[0].squad_uuid]
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
|
||||
|
||||
balance = user.balance_kopeks
|
||||
available_periods = sorted(settings.get_available_subscription_periods(), reverse=True)
|
||||
|
||||
if not available_periods:
|
||||
logger.warning('🔁 Автоактивация: нет доступных периодов подписки')
|
||||
return (False, False)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
# Найти максимальный период <= баланса
|
||||
best_period = None
|
||||
best_price = 0
|
||||
|
||||
for period in available_periods:
|
||||
try:
|
||||
price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
period, traffic_limit_gb, server_ids, device_limit, db, user=user
|
||||
)
|
||||
if price <= balance:
|
||||
best_period = period
|
||||
best_price = price
|
||||
break
|
||||
except Exception as calc_error:
|
||||
logger.warning(
|
||||
'🔁 Автоактивация: ошибка расчёта цены для периода %s: %s',
|
||||
period,
|
||||
calc_error,
|
||||
)
|
||||
continue
|
||||
|
||||
if not best_period:
|
||||
logger.info(
|
||||
'🔁 Автоактивация: у пользователя %s недостаточно средств (%s) для любого периода',
|
||||
_format_user_id(user),
|
||||
balance,
|
||||
)
|
||||
# Уведомление отправится из _send_payment_success_notification
|
||||
logger.info(
|
||||
'⚠️ Недостаточно средств для автоактивации пользователя %s, уведомление будет отправлено из payment service',
|
||||
_format_user_id(user),
|
||||
)
|
||||
return (False, False)
|
||||
|
||||
texts = get_texts(getattr(user, 'language', 'ru'))
|
||||
|
||||
try:
|
||||
if subscription:
|
||||
# Продление существующей подписки
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
pricing = await renewal_service.calculate_pricing(db, user, subscription, best_period)
|
||||
|
||||
result = await renewal_service.finalize(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
pricing,
|
||||
description=f'Автоматическое продление на {best_period} дней',
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'✅ Автоактивация: подписка пользователя %s продлена на %s дней за %s коп.',
|
||||
_format_user_id(user),
|
||||
best_period,
|
||||
best_price,
|
||||
)
|
||||
|
||||
# Send WebSocket notification to cabinet frontend
|
||||
try:
|
||||
await notify_user_subscription_renewed(
|
||||
user_id=user.id,
|
||||
new_expires_at=result.subscription.end_date.isoformat() if result.subscription.end_date else '',
|
||||
amount_kopeks=best_price,
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось отправить WS уведомление о продлении для %s: %s',
|
||||
_format_user_id(user),
|
||||
ws_error,
|
||||
)
|
||||
|
||||
# Уведомление пользователю (только для Telegram-пользователей)
|
||||
if bot and user.telegram_id:
|
||||
try:
|
||||
period_label = format_period_description(best_period, getattr(user, 'language', 'ru'))
|
||||
new_end_date = result.subscription.end_date
|
||||
end_date_str = new_end_date.strftime('%d.%m.%Y') if new_end_date else '—'
|
||||
|
||||
message = texts.t(
|
||||
'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED',
|
||||
'✅ Подписка автоматически продлена на {period}.',
|
||||
).format(period=period_label)
|
||||
|
||||
details = texts.t(
|
||||
'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS',
|
||||
'⏰ Новая дата окончания: {date}.',
|
||||
).format(date=end_date_str)
|
||||
|
||||
hint = texts.t(
|
||||
'AUTO_PURCHASE_SUBSCRIPTION_HINT',
|
||||
'Перейдите в раздел «Моя подписка», чтобы получить ссылку.',
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
|
||||
callback_data='menu_subscription',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f'{message}\n{details}\n\n{hint}',
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось уведомить пользователя %s: %s',
|
||||
user.telegram_id or user.id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
else:
|
||||
# Создание новой подписки
|
||||
new_subscription = await create_paid_subscription(
|
||||
db,
|
||||
user.id,
|
||||
best_period,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
await subtract_user_balance(db, user, best_price, f'Активация подписки на {best_period} дней')
|
||||
|
||||
await subscription_service.create_remnawave_user(db, new_subscription)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=best_price,
|
||||
description=f'Активация подписки на {best_period} дней',
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'✅ Автоактивация: новая подписка на %s дней создана для пользователя %s за %s коп.',
|
||||
best_period,
|
||||
_format_user_id(user),
|
||||
best_price,
|
||||
)
|
||||
|
||||
# Send WebSocket notification to cabinet frontend
|
||||
try:
|
||||
await notify_user_subscription_activated(
|
||||
user_id=user.id,
|
||||
expires_at=new_subscription.end_date.isoformat() if new_subscription.end_date else '',
|
||||
tariff_name='',
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось отправить WS уведомление об активации для %s: %s',
|
||||
_format_user_id(user),
|
||||
ws_error,
|
||||
)
|
||||
|
||||
# Уведомление пользователю (только для Telegram-пользователей)
|
||||
if bot and user.telegram_id:
|
||||
try:
|
||||
period_label = format_period_description(best_period, getattr(user, 'language', 'ru'))
|
||||
|
||||
message = texts.t(
|
||||
'AUTO_PURCHASE_SUBSCRIPTION_SUCCESS',
|
||||
'✅ Подписка на {period} автоматически оформлена после пополнения баланса.',
|
||||
).format(period=period_label)
|
||||
|
||||
hint = texts.t(
|
||||
'AUTO_PURCHASE_SUBSCRIPTION_HINT',
|
||||
'Перейдите в раздел «Моя подписка», чтобы получить ссылку.',
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
|
||||
callback_data='menu_subscription',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f'{message}\n\n{hint}',
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
except Exception as notify_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось уведомить пользователя %s: %s',
|
||||
user.telegram_id or user.id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
# Уведомление админам (независимо от telegram_id)
|
||||
if bot:
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
user,
|
||||
new_subscription,
|
||||
None, # transaction
|
||||
best_period,
|
||||
False, # was_trial_conversion
|
||||
)
|
||||
except Exception as admin_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось уведомить админов: %s',
|
||||
admin_error,
|
||||
)
|
||||
|
||||
return (True, True) # success=True, notification_sent=True (об активации)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'❌ Автоактивация: ошибка для пользователя %s: %s',
|
||||
_format_user_id(user),
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return (False, False)
|
||||
|
||||
|
||||
__all__ = ['auto_activate_subscription_after_topup', 'auto_purchase_saved_cart_after_topup']
|
||||
__all__ = ['auto_purchase_saved_cart_after_topup']
|
||||
|
||||
@@ -205,17 +205,24 @@ class SubscriptionService:
|
||||
|
||||
# Ищем существующего пользователя в панели
|
||||
existing_users = []
|
||||
if user.telegram_id:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
elif user.remnawave_uuid:
|
||||
# Для email-пользователей ищем по uuid если есть
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
existing_user = await api.get_user(user.remnawave_uuid)
|
||||
existing_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
if existing_user:
|
||||
existing_users = [existing_user]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not existing_users and user.telegram_id:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
|
||||
# Fallback: поиск по email (для OAuth юзеров без telegram_id)
|
||||
if not existing_users and user.email:
|
||||
try:
|
||||
existing_users = await api.get_user_by_email(user.email)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if existing_users:
|
||||
logger.info(f'🔄 Найден существующий пользователь в панели для {self._format_user_log(user)}')
|
||||
remnawave_user = existing_users[0]
|
||||
|
||||
@@ -260,7 +260,6 @@ class BotConfigurationService:
|
||||
'PAYMENT_BALANCE_TEMPLATE': 'PAYMENT',
|
||||
'PAYMENT_SUBSCRIPTION_TEMPLATE': 'PAYMENT',
|
||||
'AUTO_PURCHASE_AFTER_TOPUP_ENABLED': 'PAYMENT',
|
||||
'SHOW_ACTIVATION_PROMPT_AFTER_TOPUP': 'PAYMENT',
|
||||
'SIMPLE_SUBSCRIPTION_ENABLED': 'SIMPLE_SUBSCRIPTION',
|
||||
'SIMPLE_SUBSCRIPTION_PERIOD_DAYS': 'SIMPLE_SUBSCRIPTION',
|
||||
'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT': 'SIMPLE_SUBSCRIPTION',
|
||||
@@ -585,19 +584,6 @@ class BotConfigurationService:
|
||||
'example': 'true',
|
||||
'warning': ('Используйте с осторожностью: средства будут списаны мгновенно, если корзина найдена.'),
|
||||
},
|
||||
'SHOW_ACTIVATION_PROMPT_AFTER_TOPUP': {
|
||||
'description': (
|
||||
'Включает режим яркого промпта активации подписки после пополнения баланса. '
|
||||
'Вместо обычного уведомления пользователь получит яркое сообщение с восклицательными знаками '
|
||||
'и кнопками для активации/продления подписки или изменения количества устройств.'
|
||||
),
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
'warning': (
|
||||
'При включении пользователи будут получать только яркое уведомление без кнопок баланса и главного меню. '
|
||||
'Эти кнопки появятся после выполнения действия (активация/продление/изменение устройств).'
|
||||
),
|
||||
},
|
||||
'SUPPORT_TICKET_SLA_MINUTES': {
|
||||
'description': 'Лимит времени для ответа модераторов на тикет в минутах.',
|
||||
'format': 'Целое число от 1 до 1440.',
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.database.models import PaymentMethod, TransactionType
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
from app.services.payment_service import PaymentService
|
||||
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
|
||||
@@ -307,23 +306,8 @@ class TributeService:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
session, user, bot=self.bot, topup_amount=amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили и есть telegram_id
|
||||
if has_saved_cart and self.bot and not activation_notification_sent and user_id:
|
||||
# Отправляем уведомление только если есть сохранённая корзина и telegram_id
|
||||
if has_saved_cart and self.bot and user_id:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from aiogram import types
|
||||
|
||||
@@ -1145,25 +1145,41 @@ class UserService:
|
||||
'new_month': 0,
|
||||
}
|
||||
|
||||
async def cleanup_inactive_users(self, db: AsyncSession, months: int = None) -> int:
|
||||
async def cleanup_inactive_users(self, db: AsyncSession, months: int = None) -> tuple[int, int]:
|
||||
"""Clean up inactive users, skipping those with active subscriptions.
|
||||
|
||||
Returns:
|
||||
Tuple of (deleted_count, skipped_active_sub_count).
|
||||
"""
|
||||
try:
|
||||
if months is None:
|
||||
months = settings.INACTIVE_USER_DELETE_MONTHS
|
||||
|
||||
inactive_users = await get_inactive_users(db, months)
|
||||
deleted_count = 0
|
||||
skipped_active_sub = 0
|
||||
|
||||
for user in inactive_users:
|
||||
# Skip users with active paid subscriptions
|
||||
if user.subscription and user.subscription.is_active:
|
||||
skipped_active_sub += 1
|
||||
continue
|
||||
|
||||
success = await self.delete_user_account(db, user.id, 0)
|
||||
if success:
|
||||
deleted_count += 1
|
||||
|
||||
logger.info(f'Удалено {deleted_count} неактивных пользователей')
|
||||
return deleted_count
|
||||
if skipped_active_sub > 0:
|
||||
logger.info(
|
||||
'Пропущено %d неактивных пользователей с активной подпиской',
|
||||
skipped_active_sub,
|
||||
)
|
||||
logger.info('Удалено %d неактивных пользователей', deleted_count)
|
||||
return deleted_count, skipped_active_sub
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка очистки неактивных пользователей: {e}')
|
||||
return 0
|
||||
logger.error('Ошибка очистки неактивных пользователей: %s', e)
|
||||
return 0, 0
|
||||
|
||||
async def get_user_activity_summary(self, db: AsyncSession, user_id: int) -> dict[str, Any]:
|
||||
try:
|
||||
|
||||
@@ -82,16 +82,18 @@ class VersionService:
|
||||
return 'UNKNOW'
|
||||
|
||||
def _get_current_version(self) -> str:
|
||||
import os
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
current = os.getenv('VERSION', '').strip()
|
||||
|
||||
if current:
|
||||
if '-' in current and current.startswith('v'):
|
||||
base_version = current.split('-')[0]
|
||||
if base_version.count('.') == 2:
|
||||
return base_version
|
||||
return current
|
||||
pyproject_path = Path(__file__).resolve().parents[2] / 'pyproject.toml'
|
||||
if pyproject_path.exists():
|
||||
for line in pyproject_path.read_text().splitlines():
|
||||
if line.strip().startswith('version'):
|
||||
ver = line.split('=', 1)[1].strip().strip('"').strip("'")
|
||||
if ver:
|
||||
return ver
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 'UNKNOW'
|
||||
|
||||
|
||||
+18
-10
@@ -87,7 +87,8 @@ def format_time_ago(dt: datetime | str, language: str = 'ru') -> str:
|
||||
|
||||
|
||||
def format_days_declension(days: int, language: str = 'ru') -> str:
|
||||
if language != 'ru':
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
if language_code not in {'ru', 'fa'}:
|
||||
return f'{days} day{"s" if days != 1 else ""}'
|
||||
|
||||
if days % 10 == 1 and days % 100 != 11:
|
||||
@@ -180,42 +181,49 @@ def format_subscription_status(is_active: bool, is_trial: bool, end_date: dateti
|
||||
except (ValueError, AttributeError):
|
||||
end_date = datetime.now()
|
||||
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
if not is_active:
|
||||
return '❌ Неактивна' if language == 'ru' else '❌ Inactive'
|
||||
return '❌ Неактивна' if use_russian_fallback else '❌ Inactive'
|
||||
|
||||
if is_trial:
|
||||
status = '🎁 Тестовая' if language == 'ru' else '🎁 Trial'
|
||||
status = '🎁 Тестовая' if use_russian_fallback else '🎁 Trial'
|
||||
else:
|
||||
status = '✅ Активна' if language == 'ru' else '✅ Active'
|
||||
status = '✅ Активна' if use_russian_fallback else '✅ Active'
|
||||
|
||||
now = datetime.utcnow()
|
||||
if end_date > now:
|
||||
days_left = (end_date - now).days
|
||||
if days_left > 0:
|
||||
status += f' ({days_left} дн.)' if language == 'ru' else f' ({days_left} days)'
|
||||
status += f' ({days_left} дн.)' if use_russian_fallback else f' ({days_left} days)'
|
||||
else:
|
||||
hours_left = (end_date - now).seconds // 3600
|
||||
status += f' ({hours_left} ч.)' if language == 'ru' else f' ({hours_left} hrs)'
|
||||
status += f' ({hours_left} ч.)' if use_russian_fallback else f' ({hours_left} hrs)'
|
||||
else:
|
||||
status = '⏰ Истекла' if language == 'ru' else '⏰ Expired'
|
||||
status = '⏰ Истекла' if use_russian_fallback else '⏰ Expired'
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def format_traffic_usage(used_gb: float, limit_gb: int, language: str = 'ru') -> str:
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
if limit_gb == 0:
|
||||
if language == 'ru':
|
||||
if use_russian_fallback:
|
||||
return f'{used_gb:.1f} ГБ / ∞'
|
||||
return f'{used_gb:.1f} GB / ∞'
|
||||
|
||||
percentage = (used_gb / limit_gb) * 100 if limit_gb > 0 else 0
|
||||
|
||||
if language == 'ru':
|
||||
if use_russian_fallback:
|
||||
return f'{used_gb:.1f} ГБ / {limit_gb} ГБ ({percentage:.1f}%)'
|
||||
return f'{used_gb:.1f} GB / {limit_gb} GB ({percentage:.1f}%)'
|
||||
|
||||
|
||||
def format_boolean(value: bool, language: str = 'ru') -> str:
|
||||
if language == 'ru':
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
if language_code in {'ru', 'fa'}:
|
||||
return '✅ Да' if value else '❌ Нет'
|
||||
return '✅ Yes' if value else '❌ No'
|
||||
|
||||
@@ -81,7 +81,6 @@ class PaymentLogFilter(logging.Filter):
|
||||
'app.external.heleket',
|
||||
'app.external.tribute',
|
||||
'app.external.yookassa_webhook',
|
||||
'app.external.pal24_webhook',
|
||||
'app.external.wata_webhook',
|
||||
'app.external.heleket_webhook',
|
||||
)
|
||||
|
||||
@@ -10,6 +10,28 @@ from app.localization.texts import get_texts
|
||||
|
||||
LOGO_PATH = Path(settings.LOGO_FILE)
|
||||
_PRIVACY_RESTRICTED_CODE = 'BUTTON_USER_PRIVACY_RESTRICTED'
|
||||
|
||||
# Кеш file_id логотипа: после первой загрузки Telegram возвращает file_id,
|
||||
# который можно переиспользовать без повторной загрузки файла (экономит 3-4 сек)
|
||||
_logo_file_id: str | None = None
|
||||
|
||||
|
||||
def get_logo_media():
|
||||
"""Возвращает кешированный file_id или FSInputFile для логотипа."""
|
||||
if _logo_file_id:
|
||||
return _logo_file_id
|
||||
return FSInputFile(LOGO_PATH)
|
||||
|
||||
|
||||
def _cache_logo_file_id(result: Message | None) -> None:
|
||||
"""Извлекает и кеширует file_id логотипа из ответа Telegram."""
|
||||
global _logo_file_id
|
||||
if _logo_file_id or result is None:
|
||||
return
|
||||
if hasattr(result, 'photo') and result.photo:
|
||||
_logo_file_id = result.photo[-1].file_id
|
||||
|
||||
|
||||
_TOPIC_REQUIRED_ERRORS = (
|
||||
'topic must be specified',
|
||||
'TOPIC_CLOSED',
|
||||
@@ -110,8 +132,9 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
|
||||
if LOGO_PATH.exists():
|
||||
try:
|
||||
# Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
result = await self.answer_photo(get_logo_media(), caption=text, **kwargs)
|
||||
_cache_logo_file_id(result)
|
||||
return result
|
||||
except TelegramBadRequest as error:
|
||||
if is_topic_required_error(error):
|
||||
# Канал с топиками — просто игнорируем, нельзя ответить без message_thread_id
|
||||
@@ -163,12 +186,8 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
# Всегда используем логотип если включен режим логотипа,
|
||||
# кроме специальных случаев (QR сообщения)
|
||||
if (settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not is_qr_message(self)) or (
|
||||
is_qr_message(self) and LOGO_PATH.exists()
|
||||
):
|
||||
media = FSInputFile(LOGO_PATH)
|
||||
if LOGO_PATH.exists():
|
||||
media = get_logo_media()
|
||||
else:
|
||||
media = self.photo[-1].file_id
|
||||
media_kwargs = {'media': media, 'caption': text}
|
||||
|
||||
+22
-14
@@ -2,14 +2,16 @@ import asyncio
|
||||
import logging
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError
|
||||
from aiogram.types import FSInputFile, InaccessibleMessage, InputMediaPhoto
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
||||
from aiogram.types import InaccessibleMessage, InputMediaPhoto
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from .message_patch import (
|
||||
LOGO_PATH,
|
||||
_cache_logo_file_id,
|
||||
append_privacy_hint,
|
||||
get_logo_media,
|
||||
is_privacy_restricted_error,
|
||||
is_qr_message,
|
||||
prepare_privacy_safe_kwargs,
|
||||
@@ -23,17 +25,13 @@ RETRY_DELAY = 0.5
|
||||
|
||||
|
||||
def _resolve_media(message: types.Message):
|
||||
# Если сообщение недоступно, возвращаем логотип по умолчанию
|
||||
if isinstance(message, InaccessibleMessage):
|
||||
return FSInputFile(LOGO_PATH)
|
||||
# Всегда используем логотип если включен режим логотипа,
|
||||
# кроме специальных случаев (QR сообщения)
|
||||
return get_logo_media()
|
||||
if settings.ENABLE_LOGO_MODE and not is_qr_message(message):
|
||||
return FSInputFile(LOGO_PATH)
|
||||
# Только если режим логотипа выключен, используем фото из сообщения
|
||||
return get_logo_media()
|
||||
if message.photo:
|
||||
return message.photo[-1].file_id
|
||||
return FSInputFile(LOGO_PATH)
|
||||
return get_logo_media()
|
||||
|
||||
|
||||
def _get_language(callback: types.CallbackQuery) -> str | None:
|
||||
@@ -91,12 +89,13 @@ async def edit_or_answer_photo(
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
try:
|
||||
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists():
|
||||
await callback.message.answer_photo(
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
result = await callback.message.answer_photo(
|
||||
photo=get_logo_media(),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=resolved_parse_mode,
|
||||
)
|
||||
_cache_logo_file_id(result)
|
||||
else:
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
@@ -127,6 +126,8 @@ async def edit_or_answer_photo(
|
||||
reply_markup=keyboard,
|
||||
parse_mode=resolved_parse_mode,
|
||||
)
|
||||
except TelegramForbiddenError:
|
||||
logger.debug('Пользователь заблокировал бота, пропускаем')
|
||||
except TelegramBadRequest as error:
|
||||
try:
|
||||
await callback.message.delete()
|
||||
@@ -141,6 +142,8 @@ async def edit_or_answer_photo(
|
||||
if callback.message.photo:
|
||||
await callback.message.delete()
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
|
||||
except TelegramForbiddenError:
|
||||
logger.debug('Пользователь заблокировал бота, пропускаем')
|
||||
except TelegramBadRequest as error:
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
|
||||
return
|
||||
@@ -168,6 +171,10 @@ async def edit_or_answer_photo(
|
||||
pass
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
|
||||
return
|
||||
except TelegramForbiddenError:
|
||||
# Пользователь заблокировал бота — молча игнорируем
|
||||
logger.debug('Пользователь заблокировал бота, пропускаем edit_media')
|
||||
return
|
||||
except TelegramBadRequest as error:
|
||||
if is_privacy_restricted_error(error):
|
||||
try:
|
||||
@@ -183,13 +190,14 @@ async def edit_or_answer_photo(
|
||||
pass
|
||||
try:
|
||||
# Отправим как фото с логотипом
|
||||
await callback.message.answer_photo(
|
||||
photo=media if isinstance(media, FSInputFile) else FSInputFile(LOGO_PATH),
|
||||
result = await callback.message.answer_photo(
|
||||
photo=get_logo_media(),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=resolved_parse_mode,
|
||||
)
|
||||
except TelegramBadRequest as photo_error:
|
||||
_cache_logo_file_id(result)
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as photo_error:
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode, photo_error)
|
||||
except Exception:
|
||||
# Последний фоллбек — обычный текст
|
||||
|
||||
@@ -307,7 +307,8 @@ def _pluralize_days_ru(n: int) -> str:
|
||||
|
||||
|
||||
def format_period_description(days: int, language: str = 'ru') -> str:
|
||||
if language == 'ru':
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
if language_code in {'ru', 'fa'}:
|
||||
if days == 30:
|
||||
return '1 месяц'
|
||||
if days == 60:
|
||||
|
||||
@@ -4054,7 +4054,7 @@ async def activate_subscription_trial_endpoint(
|
||||
|
||||
language_code = _normalize_language_code(user)
|
||||
charged_amount_label = settings.format_price(charged_amount) if charged_amount > 0 else None
|
||||
if language_code == 'ru':
|
||||
if language_code in {'ru', 'fa'}:
|
||||
if duration_days:
|
||||
message = f'Триал активирован на {duration_days} дн. Приятного пользования!'
|
||||
else:
|
||||
@@ -4065,7 +4065,7 @@ async def activate_subscription_trial_endpoint(
|
||||
message = 'Trial activated successfully. Enjoy!'
|
||||
|
||||
if charged_amount_label:
|
||||
if language_code == 'ru':
|
||||
if language_code in {'ru', 'fa'}:
|
||||
message = f'{message}\n\n💳 С вашего баланса списано {charged_amount_label}.'
|
||||
else:
|
||||
message = f'{message}\n\n💳 {charged_amount_label} has been deducted from your balance.'
|
||||
@@ -4476,7 +4476,7 @@ def _normalize_language_code(user: User | None) -> str:
|
||||
|
||||
def _build_renewal_status_message(user: User | None) -> str:
|
||||
language_code = _normalize_language_code(user)
|
||||
if language_code == 'ru':
|
||||
if language_code in {'ru', 'fa'}:
|
||||
return 'Стоимость указана с учётом ваших текущих серверов, трафика и устройств.'
|
||||
return 'Prices already include your current servers, traffic, and devices.'
|
||||
|
||||
@@ -4493,7 +4493,7 @@ def _build_promo_offer_payload(user: User | None) -> dict[str, Any] | None:
|
||||
payload['expires_at'] = expires_at
|
||||
|
||||
language_code = _normalize_language_code(user)
|
||||
if language_code == 'ru':
|
||||
if language_code in {'ru', 'fa'}:
|
||||
payload['message'] = 'Дополнительная скидка применяется автоматически.'
|
||||
else:
|
||||
payload['message'] = 'Extra discount is applied automatically.'
|
||||
@@ -4527,7 +4527,7 @@ def _build_renewal_success_message(
|
||||
amount_label = settings.format_price(max(0, charged_amount))
|
||||
date_label = format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M') if subscription.end_date else ''
|
||||
|
||||
if language_code == 'ru':
|
||||
if language_code in {'ru', 'fa'}:
|
||||
if charged_amount > 0:
|
||||
message = (
|
||||
f'Подписка продлена до {date_label}. ' if date_label else 'Подписка продлена. '
|
||||
@@ -4543,7 +4543,7 @@ def _build_renewal_success_message(
|
||||
|
||||
if promo_discount_value > 0:
|
||||
discount_label = settings.format_price(promo_discount_value)
|
||||
if language_code == 'ru':
|
||||
if language_code in {'ru', 'fa'}:
|
||||
message += f' Применена дополнительная скидка {discount_label}.'
|
||||
else:
|
||||
message += f' Promo discount applied: {discount_label}.'
|
||||
@@ -4560,7 +4560,7 @@ def _build_renewal_pending_message(
|
||||
amount_label = settings.format_price(max(0, missing_amount))
|
||||
method_title = _format_payment_method_title(method)
|
||||
|
||||
if language_code == 'ru':
|
||||
if language_code in {'ru', 'fa'}:
|
||||
if method_title:
|
||||
return (
|
||||
f'Недостаточно средств на балансе. Доплатите {amount_label} через {method_title}, '
|
||||
|
||||
@@ -190,13 +190,16 @@ async def create_promocode_endpoint(
|
||||
|
||||
creator_id = payload.created_by if payload.created_by is not None and payload.created_by > 0 else None
|
||||
|
||||
# 0 means unlimited — convert to large number for is_valid check (current_uses < max_uses)
|
||||
effective_max_uses = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
promocode = await create_promocode(
|
||||
db,
|
||||
code=normalized_code,
|
||||
type=payload.type,
|
||||
balance_bonus_kopeks=payload.balance_bonus_kopeks,
|
||||
subscription_days=payload.subscription_days,
|
||||
max_uses=payload.max_uses,
|
||||
max_uses=effective_max_uses,
|
||||
valid_until=normalized_valid_until,
|
||||
created_by=creator_id,
|
||||
)
|
||||
@@ -248,7 +251,7 @@ async def update_promocode_endpoint(
|
||||
updates['subscription_days'] = payload.subscription_days
|
||||
|
||||
if payload.max_uses is not None:
|
||||
updates['max_uses'] = payload.max_uses
|
||||
updates['max_uses'] = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
if payload.valid_from is not None:
|
||||
updates['valid_from'] = _normalize_datetime(payload.valid_from)
|
||||
|
||||
@@ -170,9 +170,6 @@
|
||||
- `app/external/pal24_client.py` — Async client for PayPalych (Pal24) API.
|
||||
Классы: `Pal24APIError` — Base error for Pal24 API operations., `Pal24Response` (2 методов) — Wrapper for Pal24 API responses., `Pal24Client` (5 методов) — Async client implementing PayPalych API methods.
|
||||
Функции: нет
|
||||
- `app/external/pal24_webhook.py` — Flask webhook server for PayPalych callbacks.
|
||||
Классы: `Pal24WebhookServer` (3 методов) — Threaded Flask server for Pal24 callbacks.
|
||||
Функции: `_normalize_payload`, `create_pal24_flask_app`
|
||||
- `app/external/remnawave_api.py` — Python-модуль
|
||||
Классы: `UserStatus`, `TrafficLimitStrategy`, `RemnaWaveUser`, `RemnaWaveInternalSquad`, `RemnaWaveNode`, `SubscriptionInfo`, `RemnaWaveAPIError` (1 методов), `RemnaWaveAPI` (8 методов)
|
||||
Функции: `format_bytes`, `parse_bytes`
|
||||
|
||||
@@ -254,31 +254,31 @@ setInterval(() => {
|
||||
### Python Webhook receiver
|
||||
|
||||
```python
|
||||
from flask import Flask, request
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
app = Flask(__name__)
|
||||
app = FastAPI()
|
||||
WEBHOOK_SECRET = "your-secret"
|
||||
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def webhook():
|
||||
@app.post('/webhook')
|
||||
async def webhook(request: Request):
|
||||
signature = request.headers.get('X-Webhook-Signature', '')
|
||||
event_type = request.headers.get('X-Webhook-Event')
|
||||
payload = request.json
|
||||
|
||||
payload = await request.json()
|
||||
|
||||
# Проверка подписи
|
||||
if not verify_signature(payload, signature, WEBHOOK_SECRET):
|
||||
return {'error': 'Invalid signature'}, 401
|
||||
|
||||
raise HTTPException(status_code=401, detail='Invalid signature')
|
||||
|
||||
# Обработка события
|
||||
if event_type == 'user.created':
|
||||
handle_new_user(payload)
|
||||
elif event_type == 'payment.completed':
|
||||
handle_payment(payload)
|
||||
|
||||
return {'status': 'ok'}, 200
|
||||
|
||||
return {'status': 'ok'}
|
||||
|
||||
def verify_signature(payload, signature, secret):
|
||||
payload_json = json.dumps(payload, sort_keys=True)
|
||||
|
||||
@@ -517,8 +517,8 @@ async def main():
|
||||
logger.error('❌ Ошибка подготовки внешней админки: %s', error)
|
||||
|
||||
bot_run_mode = settings.get_bot_run_mode()
|
||||
polling_enabled = bot_run_mode in {'polling', 'both'}
|
||||
telegram_webhook_enabled = bot_run_mode in {'webhook', 'both'}
|
||||
polling_enabled = bot_run_mode == 'polling'
|
||||
telegram_webhook_enabled = bot_run_mode == 'webhook'
|
||||
|
||||
payment_webhooks_enabled = any(
|
||||
[
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.7.2"
|
||||
version = "3.9.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
@@ -21,7 +21,6 @@ dependencies = [
|
||||
'packaging>=23.2',
|
||||
'bcrypt>=4.2.0',
|
||||
'pyjwt>=2.8.0',
|
||||
'flask>=3.1.0',
|
||||
'pyzipper>=0.3.6',
|
||||
]
|
||||
|
||||
|
||||
@@ -46,8 +46,5 @@ packaging==23.2
|
||||
|
||||
aiofiles==23.2.1
|
||||
|
||||
# Вебхуки PayPalych (Flask)
|
||||
Flask==3.1.0
|
||||
|
||||
# Архивирование с паролем
|
||||
pyzipper==0.3.6
|
||||
|
||||
@@ -5,71 +5,6 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def test_notification_message_bright_prompt():
|
||||
"""
|
||||
Тест: проверяем что формируется ЯРКОЕ сообщение с SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=true.
|
||||
"""
|
||||
|
||||
# Эмулируем код из kassa_ai.py
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP = True
|
||||
display_name = 'Kassa AI'
|
||||
amount_formatted = '10₽'
|
||||
|
||||
if SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {amount_formatted}\n'
|
||||
f'💳 Способ: {display_name}\n\n'
|
||||
'💎 Средства зачислены на ваш баланс!\n\n'
|
||||
'‼️ <b>ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ!</b> ‼️\n\n'
|
||||
'⚠️ Пополнение баланса <b>НЕ АКТИВИРУЕТ</b> подписку автоматически!\n\n'
|
||||
'👇 <b>НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ</b> 👇'
|
||||
)
|
||||
else:
|
||||
message = ''
|
||||
|
||||
# Проверки
|
||||
assert '‼️' in message
|
||||
assert 'ВНИМАНИЕ' in message
|
||||
assert 'ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ' in message
|
||||
assert '👇' in message
|
||||
assert display_name in message
|
||||
assert amount_formatted in message
|
||||
print(f'\n✅ ЯРКОЕ сообщение сформировано правильно:\n{message}')
|
||||
|
||||
|
||||
def test_notification_message_standard():
|
||||
"""
|
||||
Тест: проверяем что формируется обычное сообщение с SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=false.
|
||||
"""
|
||||
# Эмулируем код из kassa_ai.py
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP = False
|
||||
display_name = 'Kassa AI'
|
||||
amount_formatted = '10₽'
|
||||
|
||||
if SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||
message = ''
|
||||
else:
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {amount_formatted}\n'
|
||||
f'💳 Способ: {display_name}\n\n'
|
||||
'Средства зачислены на ваш баланс!\n\n'
|
||||
'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
||||
'Обязательно активируйте подписку отдельно!\n\n'
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.'
|
||||
)
|
||||
|
||||
# Проверки
|
||||
assert '‼️' not in message
|
||||
assert 'ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ' not in message
|
||||
assert 'Платеж успешно завершен' in message
|
||||
assert display_name in message
|
||||
assert amount_formatted in message
|
||||
print(f'\n✅ Обычное сообщение сформировано правильно:\n{message}')
|
||||
|
||||
|
||||
def test_telegram_id_saved_before_commit():
|
||||
"""
|
||||
Тест: проверяем что telegram_id сохраняется в локальную переменную ДО commit.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def test_available_languages_default_contains_fa(monkeypatch):
|
||||
monkeypatch.setattr(settings, 'AVAILABLE_LANGUAGES', '', raising=False)
|
||||
languages = settings.get_available_languages()
|
||||
assert 'fa' in languages
|
||||
|
||||
|
||||
def test_available_languages_normalizes_and_deduplicates(monkeypatch):
|
||||
monkeypatch.setattr(settings, 'AVAILABLE_LANGUAGES', 'ru,en,fa,FA,fa-IR', raising=False)
|
||||
languages = settings.get_available_languages()
|
||||
assert languages[0] == 'ru'
|
||||
assert 'en' in languages
|
||||
assert 'fa' in languages
|
||||
assert 'FA' not in languages
|
||||
@@ -31,6 +31,12 @@ def test_format_days_declension_handles_russian_rules() -> None:
|
||||
assert formatters.format_days_declension(10) == '10 дней'
|
||||
|
||||
|
||||
def test_format_days_declension_uses_russian_fallback_for_fa() -> None:
|
||||
"""Для fa используем fallback на русские формы до полной локализации."""
|
||||
assert formatters.format_days_declension(1, language='fa') == '1 день'
|
||||
assert formatters.format_days_declension(3, language='fa') == '3 дня'
|
||||
|
||||
|
||||
def test_format_duration_switches_units() -> None:
|
||||
"""В зависимости от длины интервала выбирается подходящая единица измерения."""
|
||||
assert formatters.format_duration(45) == '45 сек.'
|
||||
@@ -102,3 +108,9 @@ def test_format_boolean_localises_output() -> None:
|
||||
"""Булевые значения отображаются локализованными словами."""
|
||||
assert formatters.format_boolean(True, language='ru') == '✅ Да'
|
||||
assert formatters.format_boolean(False, language='en') == '❌ No'
|
||||
|
||||
|
||||
def test_format_boolean_uses_russian_fallback_for_fa() -> None:
|
||||
"""Для fa булевы значения пока используют базовый ru fallback."""
|
||||
assert formatters.format_boolean(True, language='fa') == '✅ Да'
|
||||
assert formatters.format_boolean(False, language='fa') == '❌ Нет'
|
||||
|
||||
@@ -214,15 +214,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.1.4"
|
||||
@@ -509,23 +500,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/10/c99202719b83e5249f26902ae53a05aea67d840eeb242019322f20fc171c/fastar-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:c4c4bd08df563120cd33e854fe0a93b81579e8571b11f9b7da9e84c37da2d6b6", size = 461078, upload-time = "2025-11-26T02:36:04.94Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "blinker" },
|
||||
{ name = "click" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markupsafe" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.8.0"
|
||||
@@ -653,15 +627,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
@@ -1149,7 +1114,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remnawave-bedolaga-telegram-bot"
|
||||
version = "3.6.0"
|
||||
version = "3.8.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiogram" },
|
||||
@@ -1159,7 +1124,6 @@ dependencies = [
|
||||
{ name = "bcrypt" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "flask" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "python-dateutil" },
|
||||
@@ -1188,7 +1152,6 @@ requires-dist = [
|
||||
{ name = "bcrypt", specifier = ">=4.2.0" },
|
||||
{ name = "cryptography", specifier = ">=41.0.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" },
|
||||
{ name = "flask", specifier = ">=3.1.0" },
|
||||
{ name = "packaging", specifier = ">=23.2" },
|
||||
{ name = "pyjwt", specifier = ">=2.8.0" },
|
||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||
@@ -1501,18 +1464,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wrapt"
|
||||
version = "2.0.1"
|
||||
|
||||
Reference in New Issue
Block a user