Merge branch 'Fr1ngg:main' into main
This commit is contained in:
+20
-1
@@ -67,6 +67,14 @@ REMNAWAVE_SECRET_KEY=
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
|
||||
# Шаблон имени пользователя в панели Remnawave
|
||||
# Доступные плейсхолдеры аналогичны описанию выше
|
||||
# {full_name} — Имя, Фамилия из Telegram
|
||||
# {username} — @логин из Telegram (c @)
|
||||
# {username_clean} — логин из Telegram (без @)
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
|
||||
|
||||
# Режим удаления пользователей из панели RemnaWave
|
||||
# delete - полностью удалить пользователя из панели
|
||||
# disable - только деактивировать пользователя
|
||||
@@ -146,6 +154,10 @@ TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,
|
||||
|
||||
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
|
||||
PRICE_PER_DEVICE=10000
|
||||
# Включить выбор количества устройств при покупке и продлении
|
||||
DEVICES_SELECTION_ENABLED=true
|
||||
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT=0
|
||||
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_PROGRAM_ENABLED=true
|
||||
@@ -240,8 +252,13 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||
DISABLE_TOPUP_BUTTONS=false
|
||||
|
||||
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
|
||||
# Интервал (в минутах) между автоматическими проверками пополнений
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
PAYMENT_SERVICE_NAME=Интернет-сервис
|
||||
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
|
||||
@@ -383,6 +400,8 @@ SERVER_STATUS_ITEMS_PER_PAGE=10
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MONITORING_ENABLED=true
|
||||
MAINTENANCE_RETRY_ATTEMPTS=1
|
||||
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.5.6-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.7-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.6-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.7-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.6-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.7-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.5.6-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.7-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.6-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.7-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.6-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.7-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.5.6"
|
||||
ARG VERSION="v2.5.7"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -471,7 +471,9 @@ REMNAWAVE_AUTO_SYNC_TIMES=03:00,15:00
|
||||
# Автоматический режим тех. работ
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MONITORING_ENABLED=true
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_RETRY_ATTEMPTS=1
|
||||
|
||||
# Интервал проверки состояния панели (секунды)
|
||||
MONITORING_INTERVAL=60
|
||||
@@ -538,6 +540,8 @@ REMNAWAVE_AUTO_SYNC_TIMES=03:00,15:00
|
||||
|
||||
# Шаблон описания пользователя
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
# Шаблон имени пользователя в панели
|
||||
REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
|
||||
REMNAWAVE_USER_DELETE_MODE=delete
|
||||
|
||||
# ===== ПОДПИСКИ =====
|
||||
@@ -569,6 +573,9 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,0:20000:true"
|
||||
PRICE_PER_DEVICE=5000
|
||||
DEVICES_SELECTION_ENABLED=true
|
||||
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT=0
|
||||
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_PROGRAM_ENABLED=true
|
||||
@@ -658,6 +665,8 @@ SERVER_STATUS_ITEMS_PER_PAGE=10
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MONITORING_ENABLED=true
|
||||
MAINTENANCE_RETRY_ATTEMPTS=1
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
DEFAULT_LANGUAGE=ru
|
||||
@@ -782,7 +791,7 @@ LOG_FILE=logs/bot.log
|
||||
- 📊 **Расширенная фильтрация** пользователей (баланс, траты, активность)
|
||||
|
||||
👥 **Управление пользователями**
|
||||
- 🔍 Поиск, фильтры и детальные карточки
|
||||
- 🔍 Поиск по ID, имени, юзернейму, Telegram ID и фильтры
|
||||
- 💰 Ручное изменение баланса
|
||||
- 📱 Изменение лимитов устройств, трафика, серверов
|
||||
- 🔄 Сброс HWID и перегенерация подписки
|
||||
|
||||
+8
-5
@@ -182,11 +182,14 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
logger.info("⚡ Зарегистрированы обработчики простой покупки")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой подписки")
|
||||
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
if settings.is_maintenance_monitoring_enabled():
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
else:
|
||||
logger.info("Мониторинг техработ отключен настройками")
|
||||
|
||||
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
|
||||
logger.info("Бот успешно настроен")
|
||||
|
||||
+89
-35
@@ -7,6 +7,7 @@ import html
|
||||
from collections import defaultdict
|
||||
from datetime import time
|
||||
from typing import List, Optional, Union, Dict
|
||||
from zoneinfo import ZoneInfo
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
from pathlib import Path
|
||||
@@ -60,6 +61,8 @@ class Settings(BaseSettings):
|
||||
|
||||
SQLITE_PATH: str = "./data/bot.db"
|
||||
LOCALES_PATH: str = "./locales"
|
||||
|
||||
TIMEZONE: str = Field(default_factory=lambda: os.getenv("TZ", "UTC"))
|
||||
|
||||
DATABASE_MODE: str = "auto"
|
||||
|
||||
@@ -73,6 +76,7 @@ class Settings(BaseSettings):
|
||||
REMNAWAVE_PASSWORD: Optional[str] = None
|
||||
REMNAWAVE_AUTH_TYPE: str = "api_key"
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
|
||||
REMNAWAVE_USER_USERNAME_TEMPLATE: str = "user_{telegram_id}"
|
||||
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
|
||||
REMNAWAVE_AUTO_SYNC_ENABLED: bool = False
|
||||
REMNAWAVE_AUTO_SYNC_TIMES: str = "03:00"
|
||||
@@ -123,10 +127,12 @@ class Settings(BaseSettings):
|
||||
PRICE_TRAFFIC_500GB: int = 19000
|
||||
PRICE_TRAFFIC_1000GB: int = 19500
|
||||
PRICE_TRAFFIC_UNLIMITED: int = 20000
|
||||
|
||||
|
||||
TRAFFIC_PACKAGES_CONFIG: str = ""
|
||||
|
||||
PRICE_PER_DEVICE: int = 5000
|
||||
DEVICES_SELECTION_ENABLED: bool = True
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None
|
||||
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
|
||||
@@ -142,7 +148,6 @@ class Settings(BaseSettings):
|
||||
REFERRAL_PROGRAM_ENABLED: bool = True
|
||||
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
|
||||
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
|
||||
REFERRED_USER_REWARD: int = 0
|
||||
|
||||
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||
|
||||
@@ -154,8 +159,10 @@ class Settings(BaseSettings):
|
||||
INACTIVE_USER_DELETE_MONTHS: int = 3
|
||||
|
||||
MAINTENANCE_MODE: bool = False
|
||||
MAINTENANCE_CHECK_INTERVAL: int = 30
|
||||
MAINTENANCE_AUTO_ENABLE: bool = True
|
||||
MAINTENANCE_CHECK_INTERVAL: int = 30
|
||||
MAINTENANCE_AUTO_ENABLE: bool = True
|
||||
MAINTENANCE_MONITORING_ENABLED: bool = True
|
||||
MAINTENANCE_RETRY_ATTEMPTS: int = 1
|
||||
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
|
||||
|
||||
TELEGRAM_STARS_ENABLED: bool = True
|
||||
@@ -550,6 +557,34 @@ class Settings(BaseSettings):
|
||||
description = re.sub(r'\s+', ' ', description).strip()
|
||||
return description
|
||||
|
||||
def format_remnawave_username(
|
||||
self,
|
||||
*,
|
||||
full_name: str,
|
||||
username: Optional[str],
|
||||
telegram_id: int
|
||||
) -> str:
|
||||
template = self.REMNAWAVE_USER_USERNAME_TEMPLATE or "user_{telegram_id}"
|
||||
|
||||
username_clean = (username or "").lstrip("@")
|
||||
full_name_value = full_name or ""
|
||||
|
||||
values = defaultdict(str, {
|
||||
"full_name": full_name_value,
|
||||
"username": username_clean,
|
||||
"username_clean": username_clean,
|
||||
"telegram_id": str(telegram_id),
|
||||
})
|
||||
|
||||
raw_username = template.format_map(values).strip()
|
||||
sanitized_username = re.sub(r"[^0-9A-Za-z._-]+", "_", raw_username)
|
||||
sanitized_username = re.sub(r"_+", "_", sanitized_username).strip("._-")
|
||||
|
||||
if not sanitized_username:
|
||||
sanitized_username = f"user_{telegram_id}"
|
||||
|
||||
return sanitized_username[:64]
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: Optional[str]) -> List[time]:
|
||||
if not raw_value:
|
||||
@@ -797,9 +832,35 @@ class Settings(BaseSettings):
|
||||
|
||||
def is_traffic_fixed(self) -> bool:
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
|
||||
|
||||
|
||||
def get_fixed_traffic_limit(self) -> int:
|
||||
return self.FIXED_TRAFFIC_LIMIT_GB
|
||||
|
||||
def is_devices_selection_enabled(self) -> bool:
|
||||
return self.DEVICES_SELECTION_ENABLED
|
||||
|
||||
def get_devices_selection_disabled_amount(self) -> Optional[int]:
|
||||
raw_value = self.DEVICES_SELECTION_DISABLED_AMOUNT
|
||||
|
||||
if raw_value in (None, ""):
|
||||
return None
|
||||
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s",
|
||||
raw_value,
|
||||
)
|
||||
return None
|
||||
|
||||
if value < 0:
|
||||
return 0
|
||||
|
||||
return value
|
||||
|
||||
def get_disabled_mode_device_limit(self) -> Optional[int]:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_yookassa_enabled(self) -> bool:
|
||||
return (self.YOOKASSA_ENABLED and
|
||||
@@ -954,6 +1015,13 @@ class Settings(BaseSettings):
|
||||
def get_maintenance_check_interval(self) -> int:
|
||||
return self.MAINTENANCE_CHECK_INTERVAL
|
||||
|
||||
def get_maintenance_retry_attempts(self) -> int:
|
||||
try:
|
||||
attempts = int(self.MAINTENANCE_RETRY_ATTEMPTS)
|
||||
except (TypeError, ValueError):
|
||||
attempts = 1
|
||||
return max(1, attempts)
|
||||
|
||||
def is_base_promo_group_period_discount_enabled(self) -> bool:
|
||||
return self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED
|
||||
|
||||
@@ -996,6 +1064,9 @@ class Settings(BaseSettings):
|
||||
def is_maintenance_auto_enable(self) -> bool:
|
||||
return self.MAINTENANCE_AUTO_ENABLE
|
||||
|
||||
def is_maintenance_monitoring_enabled(self) -> bool:
|
||||
return self.MAINTENANCE_MONITORING_ENABLED
|
||||
|
||||
def get_available_subscription_periods(self) -> List[int]:
|
||||
try:
|
||||
periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS
|
||||
@@ -1087,34 +1158,7 @@ class Settings(BaseSettings):
|
||||
return (self.BACKUP_SEND_ENABLED and
|
||||
self.get_backup_send_chat_id() is not None)
|
||||
|
||||
def get_referred_user_reward_kopeks(self) -> int:
|
||||
"""Return the referred user reward normalized to kopeks.
|
||||
|
||||
Historically the value was stored in kopeks, however some
|
||||
installations provide it in rubles. To keep backward compatibility we
|
||||
treat any value greater than or equal to one thousand as already being
|
||||
in kopeks (≥ 10 ₽). Smaller positive values are assumed to be provided
|
||||
in rubles and therefore converted to kopeks.
|
||||
"""
|
||||
|
||||
raw_value = getattr(self, "REFERRED_USER_REWARD", 0)
|
||||
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
if value <= 0:
|
||||
return 0
|
||||
|
||||
if value >= 1000:
|
||||
return value
|
||||
|
||||
return value * 100
|
||||
|
||||
def get_referral_settings(self) -> Dict:
|
||||
referred_reward_kopeks = self.get_referred_user_reward_kopeks()
|
||||
|
||||
return {
|
||||
"program_enabled": self.is_referral_program_enabled(),
|
||||
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
|
||||
@@ -1122,8 +1166,6 @@ class Settings(BaseSettings):
|
||||
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
|
||||
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
|
||||
"referred_user_reward": referred_reward_kopeks,
|
||||
"referred_user_reward_kopeks": referred_reward_kopeks,
|
||||
}
|
||||
|
||||
def is_referral_program_enabled(self) -> bool:
|
||||
@@ -1385,11 +1427,23 @@ class Settings(BaseSettings):
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8",
|
||||
"extra": "ignore"
|
||||
"extra": "ignore"
|
||||
}
|
||||
|
||||
@field_validator("TIMEZONE")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: str) -> str:
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except Exception as exc: # pragma: no cover - defensive validation
|
||||
raise ValueError(
|
||||
f"Некорректный идентификатор часового пояса: {value}"
|
||||
) from exc
|
||||
return value
|
||||
|
||||
|
||||
settings = Settings()
|
||||
ENV_OVERRIDE_KEYS = set(settings.model_fields_set)
|
||||
|
||||
_PERIOD_PRICE_FIELDS: Dict[int, str] = {
|
||||
14: "PRICE_14_DAYS",
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.database.models import (
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.config import settings
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,13 +42,14 @@ async def create_trial_subscription(
|
||||
user_id: int,
|
||||
duration_days: int = None,
|
||||
traffic_limit_gb: int = None,
|
||||
device_limit: int = None,
|
||||
device_limit: Optional[int] = None,
|
||||
squad_uuid: str = None
|
||||
) -> Subscription:
|
||||
|
||||
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
|
||||
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
|
||||
if device_limit is None:
|
||||
device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
if not squad_uuid:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
@@ -126,13 +128,16 @@ async def create_paid_subscription(
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
device_limit: Optional[int] = None,
|
||||
connected_squads: List[str] = None,
|
||||
update_server_counters: bool = False,
|
||||
) -> Subscription:
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -1127,7 +1132,13 @@ async def check_and_update_subscription_status(
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
logger.info(f"🔍 Проверка статуса подписки {subscription.id}, текущий статус: {subscription.status}, дата окончания: {subscription.end_date}, текущее время: {current_time}")
|
||||
logger.info(
|
||||
"🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s",
|
||||
subscription.id,
|
||||
subscription.status,
|
||||
format_local_datetime(subscription.end_date),
|
||||
format_local_datetime(current_time),
|
||||
)
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
@@ -62,10 +62,34 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if user and user.subscription:
|
||||
_ = user.subscription.is_active
|
||||
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]:
|
||||
if not username:
|
||||
return None
|
||||
|
||||
normalized = username.lower()
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(func.lower(User.username) == normalized)
|
||||
)
|
||||
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -515,6 +539,7 @@ async def get_users_list(
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
conditions.append(User.id == int(search)) # Add support for searching by internal user ID
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
@@ -612,6 +637,7 @@ async def get_users_count(
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
conditions.append(User.id == int(search)) # Add support for searching by internal user ID
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
|
||||
@@ -42,7 +42,16 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"title": "🤖 Основные",
|
||||
"description": "Базовые настройки бота, обязательные каналы и ключевые сервисы.",
|
||||
"icon": "🤖",
|
||||
"categories": ("CORE", "CHANNEL"),
|
||||
"categories": (
|
||||
"CORE",
|
||||
"CHANNEL",
|
||||
"TIMEZONE",
|
||||
"DATABASE",
|
||||
"POSTGRES",
|
||||
"SQLITE",
|
||||
"REDIS",
|
||||
"REMNAWAVE",
|
||||
),
|
||||
},
|
||||
"support": {
|
||||
"title": "💬 Поддержка",
|
||||
@@ -56,6 +65,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"icon": "💳",
|
||||
"categories": (
|
||||
"PAYMENT",
|
||||
"PAYMENT_VERIFICATION",
|
||||
"YOOKASSA",
|
||||
"CRYPTOBOT",
|
||||
"HELEKET",
|
||||
@@ -114,18 +124,6 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"ADDITIONAL",
|
||||
),
|
||||
},
|
||||
"database": {
|
||||
"title": "💾 База данных",
|
||||
"description": "Режим базы, параметры PostgreSQL, SQLite и Redis.",
|
||||
"icon": "💾",
|
||||
"categories": ("DATABASE", "POSTGRES", "SQLITE", "REDIS"),
|
||||
},
|
||||
"remnawave": {
|
||||
"title": "🌐 RemnaWave API",
|
||||
"description": "Интеграция с RemnaWave: URL, ключи и способы авторизации.",
|
||||
"icon": "🌐",
|
||||
"categories": ("REMNAWAVE",),
|
||||
},
|
||||
"server": {
|
||||
"title": "📊 Статус серверов",
|
||||
"description": "Мониторинг серверов, SLA и внешние метрики.",
|
||||
@@ -142,13 +140,14 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"title": "⚡ Расширенные",
|
||||
"description": "Web API, webhook, логирование, модерация и режим отладки.",
|
||||
"icon": "⚡",
|
||||
"categories": ("WEB_API", "WEBHOOK", "LOG", "MODERATION", "DEBUG"),
|
||||
},
|
||||
"external_admin": {
|
||||
"title": "🛡️ Внешняя админка",
|
||||
"description": "Токен, по которому внешняя админка проверяет запросы.",
|
||||
"icon": "🛡️",
|
||||
"categories": ("EXTERNAL_ADMIN",),
|
||||
"categories": (
|
||||
"WEB_API",
|
||||
"WEBHOOK",
|
||||
"LOG",
|
||||
"MODERATION",
|
||||
"DEBUG",
|
||||
"EXTERNAL_ADMIN",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -161,12 +160,9 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
|
||||
"referral",
|
||||
"notifications",
|
||||
"interface",
|
||||
"database",
|
||||
"remnawave",
|
||||
"server",
|
||||
"maintenance",
|
||||
"advanced",
|
||||
"external_admin",
|
||||
)
|
||||
|
||||
CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple(
|
||||
@@ -313,7 +309,6 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
|
||||
settings.REFERRAL_COMMISSION_PERCENT
|
||||
or settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS
|
||||
or settings.REFERRAL_INVITER_BONUS_KOPEKS
|
||||
or settings.get_referred_user_reward_kopeks()
|
||||
)
|
||||
return ("🟢", "Программа активна") if active else ("⚪", "Бонусы не заданы")
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ def _format_campaign_summary(campaign, texts) -> str:
|
||||
bonus_info = f"💰 Бонус на баланс: <b>{bonus_text}</b>"
|
||||
else:
|
||||
traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0)
|
||||
device_limit = campaign.subscription_device_limit
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
bonus_info = (
|
||||
"📱 Подписка: <b>{days} д.</b>\n"
|
||||
"🌐 Трафик: <b>{traffic}</b>\n"
|
||||
@@ -53,7 +56,7 @@ def _format_campaign_summary(campaign, texts) -> str:
|
||||
).format(
|
||||
days=campaign.subscription_duration_days or 0,
|
||||
traffic=traffic_text,
|
||||
devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
devices=device_limit,
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -935,7 +938,9 @@ async def start_edit_campaign_subscription_devices(
|
||||
campaign_edit_message_is_caption=is_caption,
|
||||
)
|
||||
|
||||
current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
current_devices = campaign.subscription_device_limit
|
||||
if current_devices is None:
|
||||
current_devices = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
await callback.message.edit_text(
|
||||
(
|
||||
|
||||
@@ -74,6 +74,7 @@ async def show_maintenance_panel(
|
||||
{status_emoji} <b>Режим техработ:</b> {status_text}
|
||||
{api_emoji} <b>API Remnawave:</b> {api_text}
|
||||
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
|
||||
🛠️ <b>Автозапуск мониторинга:</b> {'Включен' if status_info['monitoring_configured'] else 'Отключен'}
|
||||
⏱️ <b>Интервал проверки:</b> {status_info['check_interval']}с
|
||||
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
|
||||
{panel_info}
|
||||
@@ -233,7 +234,11 @@ async def check_panel_status(
|
||||
f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}",
|
||||
f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}"
|
||||
]
|
||||
|
||||
|
||||
attempts_used = status_data.get("attempts_used")
|
||||
if attempts_used:
|
||||
message_parts.append(f"🔁 Попыток проверки: {attempts_used}")
|
||||
|
||||
if status_data.get("api_error"):
|
||||
message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}")
|
||||
|
||||
|
||||
@@ -68,7 +68,6 @@ async def show_referral_statistics(
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
|
||||
|
||||
@@ -105,7 +104,6 @@ async def show_referral_statistics(
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
<i>🕐 Время: {current_time}</i>
|
||||
@@ -190,7 +188,6 @@ async def show_referral_settings(
|
||||
• Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
• Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
• Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
• Бонус новому пользователю при регистрации: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
|
||||
<b>Комиссионные:</b>
|
||||
• Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
+460
-13
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Tuple
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -10,7 +11,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id,
|
||||
get_user_by_telegram_id,
|
||||
get_user_by_username,
|
||||
get_referrals,
|
||||
)
|
||||
from app.database.crud.campaign import (
|
||||
get_campaign_registration_by_user,
|
||||
get_campaign_statistics,
|
||||
@@ -35,6 +41,9 @@ from app.database.crud.server_squad import (
|
||||
get_server_ids_by_uuids,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1486,6 +1495,395 @@ async def show_user_management(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _build_user_referrals_view(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
user_id: int,
|
||||
limit: int = 30,
|
||||
) -> Optional[Tuple[str, InlineKeyboardMarkup]]:
|
||||
texts = get_texts(language)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
referrals = await get_referrals(db, user_id)
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_USER_REFERRALS_TITLE",
|
||||
"🤝 <b>Рефералы пользователя</b>",
|
||||
)
|
||||
summary = texts.t(
|
||||
"ADMIN_USER_REFERRALS_SUMMARY",
|
||||
"👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
|
||||
).format(
|
||||
name=user.full_name,
|
||||
telegram_id=user.telegram_id,
|
||||
count=len(referrals),
|
||||
)
|
||||
|
||||
lines: List[str] = [header, summary]
|
||||
|
||||
if referrals:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_LIST_HEADER",
|
||||
"<b>Список рефералов:</b>",
|
||||
)
|
||||
)
|
||||
items = []
|
||||
for referral in referrals[:limit]:
|
||||
username_part = (
|
||||
f", @{referral.username}"
|
||||
if referral.username
|
||||
else ""
|
||||
)
|
||||
items.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM",
|
||||
"• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||
).format(
|
||||
name=referral.full_name,
|
||||
telegram_id=referral.telegram_id,
|
||||
username_part=username_part,
|
||||
)
|
||||
)
|
||||
|
||||
lines.append("\n".join(items))
|
||||
|
||||
if len(referrals) > limit:
|
||||
remaining = len(referrals) - limit
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED",
|
||||
"• … и ещё {count} рефералов",
|
||||
).format(count=remaining)
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_EMPTY",
|
||||
"Рефералов пока нет.",
|
||||
)
|
||||
)
|
||||
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_EDIT_HINT",
|
||||
"✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_USER_REFERRALS_EDIT_BUTTON",
|
||||
"✏️ Редактировать",
|
||||
),
|
||||
callback_data=f"admin_user_referrals_edit_{user_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_user_manage_{user_id}",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
return "\n\n".join(lines), keyboard
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_referrals(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
current_state = await state.get_state()
|
||||
if current_state == AdminStates.editing_user_referrals:
|
||||
data = await state.get_data()
|
||||
preserved_data = {
|
||||
key: value
|
||||
for key, value in data.items()
|
||||
if key not in {"editing_referrals_user_id", "referrals_message_id"}
|
||||
}
|
||||
await state.clear()
|
||||
if preserved_data:
|
||||
await state.update_data(**preserved_data)
|
||||
|
||||
view = await _build_user_referrals_view(db, db_user.language, user_id)
|
||||
if not view:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
text, keyboard = view
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_user_referrals(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
prompt = texts.t(
|
||||
"ADMIN_USER_REFERRALS_EDIT_PROMPT",
|
||||
(
|
||||
"✏️ <b>Редактирование рефералов</b>\n\n"
|
||||
"Отправьте список рефералов для пользователя <b>{name}</b> (ID: <code>{telegram_id}</code>):\n"
|
||||
"• Используйте TG ID или @username\n"
|
||||
"• Значения можно указывать через запятую, пробел или с новой строки\n"
|
||||
"• Чтобы очистить список, отправьте 0 или слово 'нет'\n\n"
|
||||
"Или нажмите кнопку ниже, чтобы отменить."
|
||||
),
|
||||
).format(
|
||||
name=user.full_name,
|
||||
telegram_id=user.telegram_id,
|
||||
)
|
||||
|
||||
await state.update_data(
|
||||
editing_referrals_user_id=user_id,
|
||||
referrals_message_id=callback.message.message_id,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
prompt,
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_user_referrals_{user_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_user_referrals)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_user_referrals(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
|
||||
user_id = data.get("editing_referrals_user_id")
|
||||
if not user_id:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_STATE_LOST",
|
||||
"❌ Не удалось определить пользователя. Попробуйте начать сначала.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
raw_text = message.text.strip()
|
||||
lower_text = raw_text.lower()
|
||||
clear_keywords = {"0", "нет", "none", "пусто", "clear"}
|
||||
clear_requested = lower_text in clear_keywords
|
||||
|
||||
tokens: List[str] = []
|
||||
if not clear_requested:
|
||||
parts = re.split(r"[,\n]+", raw_text)
|
||||
for part in parts:
|
||||
for token in part.split():
|
||||
cleaned = token.strip()
|
||||
if cleaned and cleaned not in tokens:
|
||||
tokens.append(cleaned)
|
||||
|
||||
found_users: List[User] = []
|
||||
not_found: List[str] = []
|
||||
skipped_self: List[str] = []
|
||||
duplicate_tokens: List[str] = []
|
||||
|
||||
seen_ids = set()
|
||||
|
||||
for token in tokens:
|
||||
normalized = token.strip()
|
||||
if not normalized:
|
||||
continue
|
||||
|
||||
if normalized.startswith("@"):
|
||||
normalized = normalized[1:]
|
||||
|
||||
user = None
|
||||
if normalized.isdigit():
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, int(normalized))
|
||||
except ValueError:
|
||||
user = None
|
||||
else:
|
||||
user = await get_user_by_username(db, normalized)
|
||||
|
||||
if not user:
|
||||
not_found.append(token)
|
||||
continue
|
||||
|
||||
if user.id == user_id:
|
||||
skipped_self.append(token)
|
||||
continue
|
||||
|
||||
if user.id in seen_ids:
|
||||
duplicate_tokens.append(token)
|
||||
continue
|
||||
|
||||
seen_ids.add(user.id)
|
||||
found_users.append(user)
|
||||
|
||||
if not found_users and not clear_requested:
|
||||
error_lines = [
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_NO_VALID",
|
||||
"❌ Не удалось найти ни одного пользователя по введённым данным.",
|
||||
)
|
||||
]
|
||||
if not_found:
|
||||
error_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_INVALID_ENTRIES",
|
||||
"Не найдены: {values}",
|
||||
).format(values=", ".join(not_found))
|
||||
)
|
||||
if skipped_self:
|
||||
error_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_SELF_SKIPPED",
|
||||
"Пропущены значения пользователя: {values}",
|
||||
).format(values=", ".join(skipped_self))
|
||||
)
|
||||
await message.answer("\n".join(error_lines))
|
||||
return
|
||||
|
||||
user_service = UserService()
|
||||
|
||||
new_referral_ids = [user.id for user in found_users] if not clear_requested else []
|
||||
|
||||
success, details = await user_service.update_user_referrals(
|
||||
db,
|
||||
user_id,
|
||||
new_referral_ids,
|
||||
db_user.id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATE_ERROR",
|
||||
"❌ Не удалось обновить рефералов. Попробуйте позже.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
response_lines = [
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED",
|
||||
"✅ Список рефералов обновлён.",
|
||||
)
|
||||
]
|
||||
|
||||
total_referrals = details.get("total", len(new_referral_ids))
|
||||
added = details.get("added", 0)
|
||||
removed = details.get("removed", 0)
|
||||
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED_TOTAL",
|
||||
"• Текущий список: {total}",
|
||||
).format(total=total_referrals)
|
||||
)
|
||||
|
||||
if added > 0:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED_ADDED",
|
||||
"• Добавлено: {count}",
|
||||
).format(count=added)
|
||||
)
|
||||
|
||||
if removed > 0:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_UPDATED_REMOVED",
|
||||
"• Удалено: {count}",
|
||||
).format(count=removed)
|
||||
)
|
||||
|
||||
if not_found:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_INVALID_ENTRIES",
|
||||
"Не найдены: {values}",
|
||||
).format(values=", ".join(not_found))
|
||||
)
|
||||
|
||||
if skipped_self:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_SELF_SKIPPED",
|
||||
"Пропущены значения пользователя: {values}",
|
||||
).format(values=", ".join(skipped_self))
|
||||
)
|
||||
|
||||
if duplicate_tokens:
|
||||
response_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_DUPLICATES",
|
||||
"Игнорированы дубли: {values}",
|
||||
).format(values=", ".join(duplicate_tokens))
|
||||
)
|
||||
|
||||
view = await _build_user_referrals_view(db, db_user.language, user_id)
|
||||
message_id = data.get("referrals_message_id")
|
||||
|
||||
if view and message_id:
|
||||
try:
|
||||
await message.bot.edit_message_text(
|
||||
view[0],
|
||||
chat_id=message.chat.id,
|
||||
message_id=message_id,
|
||||
reply_markup=view[1],
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await message.answer(view[0], reply_markup=view[1])
|
||||
elif view:
|
||||
await message.answer(view[0], reply_markup=view[1])
|
||||
|
||||
await message.answer("\n".join(response_lines))
|
||||
await state.clear()
|
||||
|
||||
async def _render_user_promo_group(
|
||||
message: types.Message,
|
||||
language: str,
|
||||
@@ -3338,7 +3736,15 @@ async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: in
|
||||
logger.error(f"У пользователя {user_id} уже есть подписка")
|
||||
return False
|
||||
|
||||
subscription = await create_trial_subscription(db, user_id)
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
user_id,
|
||||
device_limit=forced_devices,
|
||||
)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
@@ -3382,12 +3788,20 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
|
||||
if getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
if forced_devices is not None:
|
||||
device_limit = forced_devices
|
||||
|
||||
subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=days,
|
||||
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.DEFAULT_DEVICE_LIMIT,
|
||||
device_limit=device_limit,
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
@@ -3594,7 +4008,9 @@ async def admin_buy_subscription(
|
||||
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
|
||||
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
|
||||
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_limit = subscription.device_limit
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
servers_count = len(subscription.connected_squads or [])
|
||||
text += f"📶 Трафик: {traffic_text}\n"
|
||||
text += f"📱 Устройства: {devices_limit}\n"
|
||||
@@ -3685,7 +4101,9 @@ async def admin_buy_subscription_confirm(
|
||||
text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
|
||||
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
|
||||
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
|
||||
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_limit = subscription.device_limit
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
servers_count = len(subscription.connected_squads or [])
|
||||
text += f"📶 Трафик: {traffic_text}\n"
|
||||
text += f"📱 Устройства: {devices_limit}\n"
|
||||
@@ -3832,40 +4250,54 @@ async def admin_buy_subscription_execute(
|
||||
from app.external.remnawave_api import UserStatus, TrafficLimitStrategy
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
if target_user.remnawave_uuid:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
remnawave_user = await api.update_user(
|
||||
update_kwargs = dict(
|
||||
uuid=target_user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
remnawave_user = await api.update_user(**update_kwargs)
|
||||
else:
|
||||
username = f"user_{target_user.telegram_id}"
|
||||
username = settings.format_remnawave_username(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id,
|
||||
)
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
remnawave_user = await api.create_user(
|
||||
create_kwargs = dict(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=target_user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
create_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
remnawave_user = await api.create_user(**create_kwargs)
|
||||
|
||||
if remnawave_user and hasattr(remnawave_user, 'uuid'):
|
||||
target_user.remnawave_uuid = remnawave_user.uuid
|
||||
@@ -4122,6 +4554,21 @@ def register_handlers(dp: Dispatcher):
|
||||
AdminStates.editing_user_balance
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_referrals,
|
||||
F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_edit_user_referrals,
|
||||
F.data.startswith("admin_user_referrals_edit_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_edit_user_referrals,
|
||||
AdminStates.editing_user_referrals
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_send_user_message,
|
||||
F.data.startswith("admin_user_send_message_")
|
||||
|
||||
@@ -38,6 +38,7 @@ from app.utils.promo_offer import (
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.services.faq_service import FaqService
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -952,7 +953,8 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
actual_status = (subscription.actual_status or "").lower()
|
||||
end_date_text = subscription.end_date.strftime("%d.%m.%Y")
|
||||
end_date = getattr(subscription, "end_date", None)
|
||||
end_date_text = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
|
||||
days_left = 0
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
@@ -968,10 +970,10 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=end_date_text)
|
||||
).format(end_date=end_date_text or "—")
|
||||
|
||||
if actual_status == "trial":
|
||||
if days_left > 1:
|
||||
if days_left > 1 and end_date_text:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
@@ -990,7 +992,7 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
)
|
||||
|
||||
if actual_status == "active":
|
||||
if days_left > 7:
|
||||
if days_left > 7 and end_date_text:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
|
||||
@@ -16,7 +16,10 @@ from app.services.payment_service import PaymentService
|
||||
from app.services.subscription_purchase_service import SubscriptionPurchaseService
|
||||
from app.utils.decorators import error_handler
|
||||
from app.states import SubscriptionStates
|
||||
from app.utils.subscription_utils import get_display_subscription_link
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
resolve_simple_subscription_device_limit,
|
||||
)
|
||||
from app.utils.pricing_utils import compute_simple_subscription_price
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -35,15 +38,17 @@ async def start_simple_subscription_purchase(
|
||||
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
# Проверяем, есть ли у пользователя подписка (информируем, но не блокируем покупку)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
current_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
|
||||
device_limit = resolve_simple_subscription_device_limit()
|
||||
|
||||
# Подготовим параметры простой подписки
|
||||
subscription_params = {
|
||||
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
|
||||
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
|
||||
"device_limit": device_limit,
|
||||
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
|
||||
}
|
||||
@@ -111,20 +116,32 @@ async def start_simple_subscription_purchase(
|
||||
subscription_params,
|
||||
resolved_squad_uuid,
|
||||
)
|
||||
message_text = (
|
||||
f"⚡ <b>Простая покупка подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
|
||||
f"🌍 Сервер: {server_label}\n\n"
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
|
||||
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
|
||||
+ (
|
||||
show_devices = settings.is_devices_selection_enabled()
|
||||
|
||||
message_lines = [
|
||||
"⚡ <b>Простая покупка подписки</b>",
|
||||
"",
|
||||
f"📅 Период: {subscription_params['period_days']} дней",
|
||||
]
|
||||
|
||||
if show_devices:
|
||||
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
message_lines.extend([
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
||||
f"🌍 Сервер: {server_label}",
|
||||
"",
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
|
||||
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}",
|
||||
"",
|
||||
(
|
||||
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
|
||||
if can_pay_from_balance
|
||||
else "Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:"
|
||||
)
|
||||
)
|
||||
),
|
||||
])
|
||||
|
||||
message_text = "\n".join(message_lines)
|
||||
|
||||
if trial_notice:
|
||||
message_text = f"{trial_notice}\n\n{message_text}"
|
||||
@@ -433,16 +450,28 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
subscription_params,
|
||||
resolved_squad_uuid,
|
||||
)
|
||||
success_message = (
|
||||
f"✅ <b>Подписка успешно активирована!</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
|
||||
f"🌍 Сервер: {server_label}\n\n"
|
||||
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n"
|
||||
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}\n\n"
|
||||
f"🔗 Для подключения перейдите в раздел 'Подключиться'"
|
||||
)
|
||||
show_devices = settings.is_devices_selection_enabled()
|
||||
|
||||
success_lines = [
|
||||
"✅ <b>Подписка успешно активирована!</b>",
|
||||
"",
|
||||
f"📅 Период: {subscription_params['period_days']} дней",
|
||||
]
|
||||
|
||||
if show_devices:
|
||||
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
success_lines.extend([
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
||||
f"🌍 Сервер: {server_label}",
|
||||
"",
|
||||
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
|
||||
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}",
|
||||
"",
|
||||
"🔗 Для подключения перейдите в раздел 'Подключиться'",
|
||||
])
|
||||
|
||||
success_message = "\n".join(success_lines)
|
||||
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
@@ -619,19 +648,31 @@ async def handle_simple_subscription_other_payment_methods(
|
||||
subscription_params,
|
||||
resolved_squad_uuid,
|
||||
)
|
||||
message_text = (
|
||||
f"💳 <b>Оплата подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
|
||||
f"🌍 Сервер: {server_label}\n\n"
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n"
|
||||
+ (
|
||||
show_devices = settings.is_devices_selection_enabled()
|
||||
|
||||
message_lines = [
|
||||
"💳 <b>Оплата подписки</b>",
|
||||
"",
|
||||
f"📅 Период: {subscription_params['period_days']} дней",
|
||||
]
|
||||
|
||||
if show_devices:
|
||||
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
message_lines.extend([
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
||||
f"🌍 Сервер: {server_label}",
|
||||
"",
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
|
||||
"",
|
||||
(
|
||||
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты:"
|
||||
if can_pay_from_balance
|
||||
else "Выберите подходящий способ оплаты:"
|
||||
)
|
||||
)
|
||||
),
|
||||
])
|
||||
|
||||
message_text = "\n".join(message_lines)
|
||||
|
||||
base_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
|
||||
keyboard_rows = []
|
||||
@@ -854,14 +895,25 @@ async def handle_simple_subscription_payment_method(
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
|
||||
# Подготавливаем текст сообщения
|
||||
message_text = (
|
||||
f"💳 <b>Оплата подписки через YooKassa</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
|
||||
f"💰 Сумма: {settings.format_price(price_kopeks)}\n"
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||
)
|
||||
show_devices = settings.is_devices_selection_enabled()
|
||||
|
||||
message_lines = [
|
||||
"💳 <b>Оплата подписки через YooKassa</b>",
|
||||
"",
|
||||
f"📅 Период: {subscription_params['period_days']} дней",
|
||||
]
|
||||
|
||||
if show_devices:
|
||||
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
message_lines.extend([
|
||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
||||
f"💰 Сумма: {settings.format_price(price_kopeks)}",
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...",
|
||||
"",
|
||||
])
|
||||
|
||||
message_text = "\n".join(message_lines)
|
||||
|
||||
# Добавляем инструкции в зависимости от доступных способов оплаты
|
||||
if not confirmation_url:
|
||||
|
||||
+59
-39
@@ -37,6 +37,7 @@ from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.database.crud.subscription import decrement_subscription_server_counts
|
||||
|
||||
@@ -44,6 +45,17 @@ from app.database.crud.subscription import decrement_subscription_server_counts
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _calculate_subscription_flags(subscription):
|
||||
if not subscription:
|
||||
return False, False
|
||||
|
||||
actual_status = getattr(subscription, "actual_status", None)
|
||||
has_active_subscription = actual_status in {"active", "trial"}
|
||||
subscription_is_active = bool(getattr(subscription, "is_active", False))
|
||||
|
||||
return has_active_subscription, subscription_is_active
|
||||
|
||||
|
||||
async def _apply_campaign_bonus_if_needed(
|
||||
db: AsyncSession,
|
||||
user,
|
||||
@@ -224,7 +236,11 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
|
||||
|
||||
data = await state.get_data() or {}
|
||||
had_pending_payload = "pending_start_payload" in data
|
||||
pending_start_payload = data.pop("pending_start_payload", None)
|
||||
had_campaign_notification_flag = "campaign_notification_sent" in data
|
||||
campaign_notification_sent = data.pop("campaign_notification_sent", False)
|
||||
state_needs_update = had_pending_payload or had_campaign_notification_flag
|
||||
|
||||
referral_code = None
|
||||
campaign = None
|
||||
@@ -240,7 +256,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
pending_start_payload,
|
||||
)
|
||||
|
||||
if pending_start_payload is not None:
|
||||
if state_needs_update:
|
||||
await state.set_data(data)
|
||||
|
||||
if start_parameter:
|
||||
@@ -266,7 +282,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
|
||||
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if campaign:
|
||||
if campaign and not campaign_notification_sent:
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_campaign_link_visit_notification(
|
||||
@@ -338,11 +354,9 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
f"Ошибка отправки уведомления о рекламной кампании: {e}"
|
||||
)
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -761,11 +775,9 @@ async def complete_registration_from_callback(
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if existing_user.subscription:
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
existing_user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
@@ -943,11 +955,9 @@ async def complete_registration_from_callback(
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = bool(getattr(user, "subscription", None))
|
||||
subscription_is_active = False
|
||||
|
||||
if getattr(user, "subscription", None):
|
||||
subscription_is_active = user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
getattr(user, "subscription", None)
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -1019,11 +1029,9 @@ async def complete_registration(
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if existing_user.subscription:
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
existing_user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
@@ -1201,11 +1209,9 @@ async def complete_registration(
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = bool(getattr(user, "subscription", None))
|
||||
subscription_is_active = False
|
||||
|
||||
if getattr(user, "subscription", None):
|
||||
subscription_is_active = user.subscription.is_active
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
getattr(user, "subscription", None)
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
@@ -1258,30 +1264,43 @@ def _get_subscription_status(user, texts):
|
||||
return texts.t("SUBSCRIPTION_NONE", "Нет активной подписки")
|
||||
|
||||
subscription = user.subscription
|
||||
actual_status = getattr(subscription, "actual_status", None)
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
end_date = getattr(subscription, "end_date", None)
|
||||
end_date_display = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if end_date and end_date <= current_time:
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'))
|
||||
if actual_status == "disabled":
|
||||
return texts.t("SUB_STATUS_DISABLED", "⚫ Отключена")
|
||||
|
||||
if actual_status == "pending":
|
||||
return texts.t("SUB_STATUS_PENDING", "⏳ Ожидает активации")
|
||||
|
||||
if actual_status == "expired" or (end_date and end_date <= current_time):
|
||||
if end_date_display:
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=end_date_display)
|
||||
return texts.t("SUBSCRIPTION_STATUS_EXPIRED", "🔴 Истекла")
|
||||
|
||||
if not end_date:
|
||||
return texts.t("SUBSCRIPTION_ACTIVE", "✅ Активна")
|
||||
|
||||
days_left = (end_date - current_time).days
|
||||
is_trial = getattr(subscription, "is_trial", False)
|
||||
is_trial = actual_status == "trial" or getattr(subscription, "is_trial", False)
|
||||
|
||||
if actual_status not in {"active", "trial", None} and not is_trial:
|
||||
return texts.t("SUBSCRIPTION_STATUS_UNKNOWN", "❓ Статус неизвестен")
|
||||
|
||||
if is_trial:
|
||||
if days_left > 1:
|
||||
if days_left > 1 and end_date_display:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
|
||||
).format(end_date=end_date_display, days=days_left)
|
||||
if days_left == 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TOMORROW",
|
||||
@@ -1292,11 +1311,11 @@ def _get_subscription_status(user, texts):
|
||||
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
if days_left > 7:
|
||||
if days_left > 7 and end_date_display:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
|
||||
).format(end_date=end_date_display, days=days_left)
|
||||
if days_left > 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS",
|
||||
@@ -1521,8 +1540,9 @@ async def required_sub_channel_check(
|
||||
logger.warning(f"Не удалось удалить сообщение: {e}")
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
has_active_subscription = bool(user.subscription)
|
||||
subscription_is_active = bool(user.subscription and user.subscription.is_active)
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
user.subscription
|
||||
)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
|
||||
@@ -208,39 +208,20 @@ async def handle_subscription_config_back(
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
elif current_state == SubscriptionStates.selecting_devices.state:
|
||||
if await _should_show_countries_management(db_user):
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
data = await state.get_data()
|
||||
selected_countries = data.get('countries', [])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
elif settings.is_traffic_selectable():
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_TRAFFIC,
|
||||
reply_markup=get_traffic_packages_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
await _show_previous_configuration_step(callback, state, db_user, texts, db)
|
||||
|
||||
elif current_state == SubscriptionStates.confirming_purchase.state:
|
||||
data = await state.get_data()
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
if settings.is_devices_selection_enabled():
|
||||
data = await state.get_data()
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_DEVICES,
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_DEVICES,
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
else:
|
||||
await _show_previous_configuration_step(callback, state, db_user, texts, db)
|
||||
|
||||
else:
|
||||
from app.handlers.menu import show_main_menu
|
||||
@@ -267,3 +248,37 @@ async def handle_subscription_cancel(
|
||||
await show_main_menu(callback, db_user, db)
|
||||
|
||||
await callback.answer("❌ Покупка отменена")
|
||||
async def _show_previous_configuration_step(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
texts,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if await _should_show_countries_management(db_user):
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
data = await state.get_data()
|
||||
selected_countries = data.get('countries', [])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
return
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_TRAFFIC,
|
||||
reply_markup=get_traffic_packages_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ from app.utils.promo_offer import (
|
||||
)
|
||||
|
||||
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger
|
||||
from .summary import present_subscription_summary
|
||||
|
||||
async def handle_add_countries(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -588,6 +589,11 @@ async def countries_continue(
|
||||
await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True)
|
||||
return
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
if await present_subscription_summary(callback, state, db_user, texts):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
|
||||
@@ -183,6 +183,13 @@ async def handle_change_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer(
|
||||
texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
|
||||
@@ -233,6 +240,13 @@ async def confirm_change_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
if new_devices_count == current_devices:
|
||||
@@ -379,6 +393,13 @@ async def execute_change_devices(
|
||||
subscription = db_user.subscription
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if price > 0:
|
||||
success = await subtract_user_balance(
|
||||
@@ -863,6 +884,13 @@ async def confirm_add_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
resume_callback = None
|
||||
|
||||
new_total_devices = subscription.device_limit + devices_count
|
||||
|
||||
@@ -77,6 +77,7 @@ from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger
|
||||
from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback
|
||||
@@ -158,7 +159,18 @@ async def _prepare_subscription_summary(
|
||||
total_servers_discount += total_discount_for_server
|
||||
selected_server_prices.append(total_price_for_server)
|
||||
|
||||
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_selection_enabled = settings.is_devices_selection_enabled()
|
||||
forced_disabled_limit: Optional[int] = None
|
||||
if devices_selection_enabled:
|
||||
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
else:
|
||||
forced_disabled_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_disabled_limit is None:
|
||||
devices_selected = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
devices_selected = forced_disabled_limit
|
||||
|
||||
summary_data['devices'] = devices_selected
|
||||
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
@@ -275,7 +287,7 @@ async def _prepare_subscription_summary(
|
||||
f" -{texts.format_price(total_servers_discount)})"
|
||||
)
|
||||
details_lines.append(servers_line)
|
||||
if total_devices_price > 0:
|
||||
if devices_selection_enabled and total_devices_price > 0:
|
||||
devices_line = (
|
||||
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}"
|
||||
f" = {texts.format_price(total_devices_price)}"
|
||||
@@ -300,17 +312,28 @@ async def _prepare_subscription_summary(
|
||||
|
||||
details_text = "\n".join(details_lines)
|
||||
|
||||
summary_text = (
|
||||
"📋 <b>Сводка заказа</b>\n\n"
|
||||
f"📅 <b>Период:</b> {period_display}\n"
|
||||
f"📊 <b>Трафик:</b> {traffic_display}\n"
|
||||
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}\n"
|
||||
f"📱 <b>Устройства:</b> {devices_selected}\n\n"
|
||||
"💰 <b>Детализация стоимости:</b>\n"
|
||||
f"{details_text}\n\n"
|
||||
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}\n\n"
|
||||
"Подтверждаете покупку?"
|
||||
)
|
||||
summary_lines = [
|
||||
"📋 <b>Сводка заказа</b>",
|
||||
"",
|
||||
f"📅 <b>Период:</b> {period_display}",
|
||||
f"📊 <b>Трафик:</b> {traffic_display}",
|
||||
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}",
|
||||
]
|
||||
|
||||
if devices_selection_enabled:
|
||||
summary_lines.append(f"📱 <b>Устройства:</b> {devices_selected}")
|
||||
|
||||
summary_lines.extend([
|
||||
"",
|
||||
"💰 <b>Детализация стоимости:</b>",
|
||||
details_text,
|
||||
"",
|
||||
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}",
|
||||
"",
|
||||
"Подтверждаете покупку?",
|
||||
])
|
||||
|
||||
summary_text = "\n".join(summary_lines)
|
||||
|
||||
return summary_text, summary_data
|
||||
|
||||
@@ -382,7 +405,18 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
)
|
||||
|
||||
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
|
||||
|
||||
@@ -410,7 +444,12 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
return 0
|
||||
|
||||
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
devices_selection_enabled = settings.is_devices_selection_enabled()
|
||||
|
||||
if devices_selection_enabled:
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
else:
|
||||
devices_used = 0
|
||||
countries_info = await _get_countries_info(subscription.connected_squads)
|
||||
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
|
||||
|
||||
@@ -439,10 +478,21 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
|
||||
subscription_cost = await get_subscription_cost(subscription, db)
|
||||
|
||||
info_text = texts.SUBSCRIPTION_INFO.format(
|
||||
info_template = texts.SUBSCRIPTION_INFO
|
||||
|
||||
if not devices_selection_enabled:
|
||||
info_template = info_template.replace(
|
||||
"\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}",
|
||||
"",
|
||||
).replace(
|
||||
"\n📱 <b>Devices:</b> {devices_used} / {devices_limit}",
|
||||
"",
|
||||
)
|
||||
|
||||
info_text = info_template.format(
|
||||
status=status_text,
|
||||
type=type_text,
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
days_left=max(0, subscription.days_left),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
|
||||
traffic_limit=traffic_text,
|
||||
|
||||
@@ -72,10 +72,12 @@ from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
resolve_simple_subscription_device_limit,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
@@ -140,6 +142,7 @@ from .traffic import (
|
||||
handle_switch_traffic,
|
||||
select_traffic,
|
||||
)
|
||||
from .summary import present_subscription_summary
|
||||
|
||||
async def show_subscription_info(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -237,26 +240,32 @@ async def show_subscription_info(
|
||||
devices_list = []
|
||||
devices_count = 0
|
||||
|
||||
try:
|
||||
if db_user.remnawave_uuid:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
service = RemnaWaveService()
|
||||
show_devices = settings.is_devices_selection_enabled()
|
||||
devices_used_str = ""
|
||||
devices_list: List[Dict[str, Any]] = []
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
|
||||
if show_devices:
|
||||
try:
|
||||
if db_user.remnawave_uuid:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
service = RemnaWaveService()
|
||||
|
||||
if response and 'response' in response:
|
||||
devices_info = response['response']
|
||||
devices_count = devices_info.get('total', 0)
|
||||
devices_list = devices_info.get('devices', [])
|
||||
devices_used_str = str(devices_count)
|
||||
logger.info(f"Найдено {devices_count} устройств для пользователя {db_user.telegram_id}")
|
||||
else:
|
||||
logger.warning(f"Не удалось получить информацию об устройствах для {db_user.telegram_id}")
|
||||
async with service.get_api_client() as api:
|
||||
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения устройств для отображения: {e}")
|
||||
devices_used_str = await get_current_devices_count(db_user)
|
||||
if response and 'response' in response:
|
||||
devices_info = response['response']
|
||||
devices_count = devices_info.get('total', 0)
|
||||
devices_list = devices_info.get('devices', [])
|
||||
devices_used_str = str(devices_count)
|
||||
logger.info(f"Найдено {devices_count} устройств для пользователя {db_user.telegram_id}")
|
||||
else:
|
||||
logger.warning(f"Не удалось получить информацию об устройствах для {db_user.telegram_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения устройств для отображения: {e}")
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
devices_used_str = str(devices_used)
|
||||
|
||||
servers_names = await get_servers_display_names(subscription.connected_squads)
|
||||
servers_display = (
|
||||
@@ -265,7 +274,7 @@ async def show_subscription_info(
|
||||
else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов")
|
||||
)
|
||||
|
||||
message = texts.t(
|
||||
message_template = texts.t(
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE",
|
||||
"""👤 {full_name}
|
||||
💰 Баланс: {balance}
|
||||
@@ -278,14 +287,22 @@ async def show_subscription_info(
|
||||
📈 Трафик: {traffic}
|
||||
🌍 Серверы: {servers}
|
||||
📱 Устройства: {devices_used} / {device_limit}""",
|
||||
).format(
|
||||
)
|
||||
|
||||
if not show_devices:
|
||||
message_template = message_template.replace(
|
||||
"\n📱 Устройства: {devices_used} / {device_limit}",
|
||||
"",
|
||||
)
|
||||
|
||||
message = message_template.format(
|
||||
full_name=db_user.full_name,
|
||||
balance=settings.format_price(db_user.balance_kopeks),
|
||||
status_emoji=status_emoji,
|
||||
status_display=status_display,
|
||||
warning=warning_text,
|
||||
subscription_type=subscription_type,
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
time_left=time_left_text,
|
||||
traffic=traffic_used_display,
|
||||
servers=servers_display,
|
||||
@@ -293,7 +310,7 @@ async def show_subscription_info(
|
||||
device_limit=subscription.device_limit,
|
||||
)
|
||||
|
||||
if devices_list and len(devices_list) > 0:
|
||||
if show_devices and devices_list:
|
||||
message += "\n\n" + texts.t(
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE",
|
||||
"<blockquote>📱 <b>Подключенные устройства:</b>\n",
|
||||
@@ -384,10 +401,20 @@ async def show_trial_offer(
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения триального сервера: {e}")
|
||||
|
||||
devices_line = ""
|
||||
if settings.is_devices_selection_enabled():
|
||||
devices_line_template = texts.t(
|
||||
"TRIAL_AVAILABLE_DEVICES_LINE",
|
||||
"\n📱 <b>Устройства:</b> {devices} шт.",
|
||||
)
|
||||
devices_line = devices_line_template.format(
|
||||
devices=settings.TRIAL_DEVICE_LIMIT,
|
||||
)
|
||||
|
||||
trial_text = texts.TRIAL_AVAILABLE.format(
|
||||
days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic=settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||
devices=settings.TRIAL_DEVICE_LIMIT,
|
||||
devices_line=devices_line,
|
||||
server_name=trial_server_name
|
||||
)
|
||||
|
||||
@@ -415,7 +442,15 @@ async def activate_trial(
|
||||
return
|
||||
|
||||
try:
|
||||
subscription = await create_trial_subscription(db, db_user.id)
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
db_user.id,
|
||||
device_limit=forced_devices,
|
||||
)
|
||||
|
||||
await db.refresh(db_user)
|
||||
|
||||
@@ -589,10 +624,17 @@ async def start_subscription_purchase(
|
||||
)
|
||||
|
||||
subscription = getattr(db_user, 'subscription', None)
|
||||
initial_devices = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
if subscription and getattr(subscription, 'device_limit', None):
|
||||
initial_devices = max(settings.DEFAULT_DEVICE_LIMIT, subscription.device_limit)
|
||||
if settings.is_devices_selection_enabled():
|
||||
initial_devices = settings.DEFAULT_DEVICE_LIMIT
|
||||
if subscription and getattr(subscription, 'device_limit', None) is not None:
|
||||
initial_devices = max(settings.DEFAULT_DEVICE_LIMIT, subscription.device_limit)
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
initial_devices = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
initial_devices = forced_limit
|
||||
|
||||
initial_data = {
|
||||
'period_days': None,
|
||||
@@ -698,7 +740,63 @@ async def return_to_saved_cart(
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
total_price = cart_data.get('total_price', 0)
|
||||
|
||||
preserved_metadata_keys = {
|
||||
'saved_cart',
|
||||
'missing_amount',
|
||||
'return_to_cart',
|
||||
'user_id',
|
||||
}
|
||||
preserved_metadata = {
|
||||
key: cart_data[key]
|
||||
for key in preserved_metadata_keys
|
||||
if key in cart_data
|
||||
}
|
||||
|
||||
prepared_cart_data = dict(cart_data)
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
try:
|
||||
from .pricing import _prepare_subscription_summary
|
||||
|
||||
_, recalculated_data = await _prepare_subscription_summary(
|
||||
db_user,
|
||||
prepared_cart_data,
|
||||
texts,
|
||||
)
|
||||
except ValueError as recalculation_error:
|
||||
logger.error(
|
||||
"Не удалось пересчитать сохраненную корзину пользователя %s: %s",
|
||||
db_user.telegram_id,
|
||||
recalculation_error,
|
||||
)
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
forced_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
prepared_cart_data['devices'] = forced_limit
|
||||
removed_devices_total = prepared_cart_data.pop('total_devices_price', 0) or 0
|
||||
if removed_devices_total:
|
||||
prepared_cart_data['total_price'] = max(
|
||||
0,
|
||||
prepared_cart_data.get('total_price', 0) - removed_devices_total,
|
||||
)
|
||||
prepared_cart_data.pop('devices_discount_percent', None)
|
||||
prepared_cart_data.pop('devices_discount_total', None)
|
||||
prepared_cart_data.pop('devices_discounted_price_per_month', None)
|
||||
prepared_cart_data.pop('devices_price_per_month', None)
|
||||
else:
|
||||
normalized_cart_data = dict(prepared_cart_data)
|
||||
normalized_cart_data.update(recalculated_data)
|
||||
|
||||
for key, value in preserved_metadata.items():
|
||||
normalized_cart_data[key] = value
|
||||
|
||||
prepared_cart_data = normalized_cart_data
|
||||
|
||||
if prepared_cart_data != cart_data:
|
||||
await user_cart_service.save_user_cart(db_user.id, prepared_cart_data)
|
||||
|
||||
total_price = prepared_cart_data.get('total_price', 0)
|
||||
|
||||
if db_user.balance_kopeks < total_price:
|
||||
missing_amount = total_price - db_user.balance_kopeks
|
||||
@@ -717,30 +815,45 @@ async def return_to_saved_cart(
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
selected_countries_names = []
|
||||
|
||||
months_in_period = calculate_months_from_days(cart_data['period_days'])
|
||||
period_display = format_period_description(cart_data['period_days'], db_user.language)
|
||||
period_display = format_period_description(prepared_cart_data['period_days'], db_user.language)
|
||||
|
||||
for country in countries:
|
||||
if country['uuid'] in cart_data['countries']:
|
||||
if country['uuid'] in prepared_cart_data['countries']:
|
||||
selected_countries_names.append(country['name'])
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_display = "Безлимитный" if cart_data['traffic_gb'] == 0 else f"{cart_data['traffic_gb']} ГБ"
|
||||
traffic_value = prepared_cart_data.get('traffic_gb')
|
||||
if traffic_value is None:
|
||||
traffic_value = settings.get_fixed_traffic_limit()
|
||||
traffic_display = "Безлимитный" if traffic_value == 0 else f"{traffic_value} ГБ"
|
||||
else:
|
||||
traffic_display = "Безлимитный" if cart_data['traffic_gb'] == 0 else f"{cart_data['traffic_gb']} ГБ"
|
||||
traffic_value = prepared_cart_data.get('traffic_gb', 0) or 0
|
||||
traffic_display = "Безлимитный" if traffic_value == 0 else f"{traffic_value} ГБ"
|
||||
|
||||
summary_text = (
|
||||
"🛒 Восстановленная корзина\n\n"
|
||||
f"📅 Период: {period_display}\n"
|
||||
f"📊 Трафик: {traffic_display}\n"
|
||||
f"🌍 Страны: {', '.join(selected_countries_names)}\n"
|
||||
f"📱 Устройства: {cart_data['devices']}\n\n"
|
||||
f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n"
|
||||
"Подтверждаете покупку?"
|
||||
)
|
||||
summary_lines = [
|
||||
"🛒 Восстановленная корзина",
|
||||
"",
|
||||
f"📅 Период: {period_display}",
|
||||
f"📊 Трафик: {traffic_display}",
|
||||
f"🌍 Страны: {', '.join(selected_countries_names)}",
|
||||
]
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
devices_value = prepared_cart_data.get('devices')
|
||||
if devices_value is not None:
|
||||
summary_lines.append(f"📱 Устройства: {devices_value}")
|
||||
|
||||
summary_lines.extend([
|
||||
"",
|
||||
f"💎 Общая стоимость: {texts.format_price(total_price)}",
|
||||
"",
|
||||
"Подтверждаете покупку?",
|
||||
])
|
||||
|
||||
summary_text = "\n".join(summary_lines)
|
||||
|
||||
# Устанавливаем данные в FSM для продолжения процесса
|
||||
await state.set_data(cart_data)
|
||||
await state.set_data(prepared_cart_data)
|
||||
await state.set_state(SubscriptionStates.confirming_purchase)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -793,7 +906,18 @@ async def handle_extend_subscription(
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
total_servers_price = (servers_price_per_month - servers_discount_per_month) * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
"devices",
|
||||
@@ -872,16 +996,27 @@ async def handle_extend_subscription(
|
||||
texts=texts,
|
||||
)
|
||||
|
||||
message_text = (
|
||||
"⏰ Продление подписки\n\n"
|
||||
f"Осталось дней: {subscription.days_left}\n\n"
|
||||
f"<b>Ваша текущая конфигурация:</b>\n"
|
||||
f"🌍 Серверов: {len(subscription.connected_squads)}\n"
|
||||
f"📊 Трафик: {texts.format_traffic(subscription.traffic_limit_gb)}\n"
|
||||
f"📱 Устройств: {subscription.device_limit}\n\n"
|
||||
f"<b>Выберите период продления:</b>\n"
|
||||
f"{prices_text.rstrip()}\n\n"
|
||||
)
|
||||
renewal_lines = [
|
||||
"⏰ Продление подписки",
|
||||
"",
|
||||
f"Осталось дней: {subscription.days_left}",
|
||||
"",
|
||||
"<b>Ваша текущая конфигурация:</b>",
|
||||
f"🌍 Серверов: {len(subscription.connected_squads)}",
|
||||
f"📊 Трафик: {texts.format_traffic(subscription.traffic_limit_gb)}",
|
||||
]
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
renewal_lines.append(f"📱 Устройств: {subscription.device_limit}")
|
||||
|
||||
renewal_lines.extend([
|
||||
"",
|
||||
"<b>Выберите период продления:</b>",
|
||||
prices_text.rstrip(),
|
||||
"",
|
||||
])
|
||||
|
||||
message_text = "\n".join(renewal_lines)
|
||||
|
||||
if promo_discounts_text:
|
||||
message_text += f"{promo_discounts_text}\n\n"
|
||||
@@ -958,7 +1093,18 @@ async def confirm_extend_subscription(
|
||||
servers_price_per_month * servers_discount_percent // 100
|
||||
)
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
"devices",
|
||||
@@ -1188,7 +1334,7 @@ async def confirm_extend_subscription(
|
||||
success_message = (
|
||||
"✅ Подписка успешно продлена!\n\n"
|
||||
f"⏰ Добавлено: {days} дней\n"
|
||||
f"Действует до: {refreshed_end_date.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"Действует до: {format_local_datetime(refreshed_end_date, '%d.%m.%Y %H:%M')}\n\n"
|
||||
f"💰 Списано: {texts.format_price(price)}"
|
||||
)
|
||||
|
||||
@@ -1248,43 +1394,60 @@ async def select_period(
|
||||
reply_markup=get_traffic_packages_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
else:
|
||||
if await _should_show_countries_management(db_user):
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, [], db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
else:
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
available_countries = [c for c in countries if c.get('is_available', True)]
|
||||
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
|
||||
await state.set_data(data)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
if await _should_show_countries_management(db_user):
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, [], db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_DEVICES,
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
available_countries = [c for c in countries if c.get('is_available', True)]
|
||||
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
|
||||
await state.set_data(data)
|
||||
|
||||
await callback.answer()
|
||||
if settings.is_devices_selection_enabled():
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_DEVICES,
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if await present_subscription_summary(callback, state, db_user, texts):
|
||||
await callback.answer()
|
||||
|
||||
async def select_devices(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Выбор количества устройств недоступен"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not callback.data.startswith("devices_") or callback.data == "devices_continue":
|
||||
await callback.answer("❌ Некорректный запрос", show_alert=True)
|
||||
await callback.answer(texts.t("DEVICES_INVALID_REQUEST", "❌ Некорректный запрос"), show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
devices = int(callback.data.split('_')[1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Некорректное количество устройств", show_alert=True)
|
||||
await callback.answer(texts.t("DEVICES_INVALID_COUNT", "❌ Некорректное количество устройств"), show_alert=True)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
@@ -1321,27 +1484,8 @@ async def devices_continue(
|
||||
await callback.answer("⚠️ Некорректный запрос", show_alert=True)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts)
|
||||
except ValueError:
|
||||
logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}")
|
||||
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data(prepared_data)
|
||||
await save_subscription_checkout_draft(db_user.id, prepared_data)
|
||||
|
||||
await callback.message.edit_text(
|
||||
summary_text,
|
||||
reply_markup=get_subscription_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(SubscriptionStates.confirming_purchase)
|
||||
await callback.answer()
|
||||
if await present_subscription_summary(callback, state, db_user):
|
||||
await callback.answer()
|
||||
|
||||
async def confirm_purchase(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -1436,30 +1580,48 @@ async def confirm_purchase(
|
||||
total_servers_discount = data.get('servers_discount_total', 0)
|
||||
servers_discount_percent = data.get('servers_discount_percent', 0)
|
||||
|
||||
additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_selection_enabled = settings.is_devices_selection_enabled()
|
||||
forced_disabled_limit: Optional[int] = None
|
||||
if devices_selection_enabled:
|
||||
devices_selected = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
else:
|
||||
forced_disabled_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_disabled_limit is None:
|
||||
devices_selected = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
devices_selected = forced_disabled_limit
|
||||
|
||||
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = data.get(
|
||||
'devices_price_per_month', additional_devices * settings.PRICE_PER_DEVICE
|
||||
)
|
||||
if 'devices_discount_percent' in data:
|
||||
devices_discount_percent = data.get('devices_discount_percent', 0)
|
||||
discounted_devices_price_per_month = data.get(
|
||||
'devices_discounted_price_per_month', devices_price_per_month
|
||||
)
|
||||
devices_discount_total = data.get('devices_discount_total', 0)
|
||||
total_devices_price = data.get(
|
||||
'total_devices_price', discounted_devices_price_per_month * months_in_period
|
||||
)
|
||||
else:
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
"devices",
|
||||
data['period_days'],
|
||||
)
|
||||
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
devices_discount_total = discount_per_month * months_in_period
|
||||
total_devices_price = discounted_devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = 0
|
||||
discounted_devices_price_per_month = 0
|
||||
devices_discount_total = 0
|
||||
total_devices_price = 0
|
||||
|
||||
if devices_selection_enabled and additional_devices > 0:
|
||||
if 'devices_discount_percent' in data:
|
||||
devices_discount_percent = data.get('devices_discount_percent', 0)
|
||||
discounted_devices_price_per_month = data.get(
|
||||
'devices_discounted_price_per_month', devices_price_per_month
|
||||
)
|
||||
devices_discount_total = data.get('devices_discount_total', 0)
|
||||
total_devices_price = data.get(
|
||||
'total_devices_price', discounted_devices_price_per_month * months_in_period
|
||||
)
|
||||
else:
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
"devices",
|
||||
data['period_days'],
|
||||
)
|
||||
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
devices_discount_total = discount_per_month * months_in_period
|
||||
total_devices_price = discounted_devices_price_per_month * months_in_period
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
final_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
@@ -1666,6 +1828,13 @@ async def confirm_purchase(
|
||||
return
|
||||
|
||||
existing_subscription = db_user.subscription
|
||||
if devices_selection_enabled:
|
||||
selected_devices = devices_selected
|
||||
else:
|
||||
selected_devices = forced_disabled_limit
|
||||
|
||||
should_update_devices = selected_devices is not None
|
||||
|
||||
was_trial_conversion = False
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
@@ -1708,7 +1877,8 @@ async def confirm_purchase(
|
||||
existing_subscription.is_trial = False
|
||||
existing_subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
existing_subscription.traffic_limit_gb = final_traffic_gb
|
||||
existing_subscription.device_limit = data['devices']
|
||||
if should_update_devices:
|
||||
existing_subscription.device_limit = selected_devices
|
||||
existing_subscription.connected_squads = data['countries']
|
||||
|
||||
existing_subscription.start_date = current_time
|
||||
@@ -1723,11 +1893,26 @@ async def confirm_purchase(
|
||||
|
||||
else:
|
||||
logger.info(f"Создаем новую подписку для пользователя {db_user.telegram_id}")
|
||||
default_device_limit = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1)
|
||||
resolved_device_limit = selected_devices
|
||||
|
||||
if resolved_device_limit is None:
|
||||
if devices_selection_enabled:
|
||||
resolved_device_limit = default_device_limit
|
||||
else:
|
||||
if forced_disabled_limit is not None:
|
||||
resolved_device_limit = forced_disabled_limit
|
||||
else:
|
||||
resolved_device_limit = default_device_limit
|
||||
|
||||
if resolved_device_limit is None and devices_selection_enabled:
|
||||
resolved_device_limit = default_device_limit
|
||||
|
||||
subscription = await create_paid_subscription_with_traffic_mode(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
duration_days=data['period_days'],
|
||||
device_limit=data['devices'],
|
||||
device_limit=resolved_device_limit,
|
||||
connected_squads=data['countries'],
|
||||
traffic_gb=final_traffic_gb
|
||||
)
|
||||
@@ -1745,11 +1930,11 @@ async def confirm_purchase(
|
||||
await add_user_to_servers(db, server_ids)
|
||||
|
||||
logger.info(f"Сохранены цены серверов за весь период: {server_prices}")
|
||||
|
||||
|
||||
await db.refresh(db_user)
|
||||
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
|
||||
if db_user.remnawave_uuid:
|
||||
remnawave_user = await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
@@ -1764,7 +1949,7 @@ async def confirm_purchase(
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="покупка подписки",
|
||||
)
|
||||
|
||||
|
||||
if not remnawave_user:
|
||||
logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}")
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
@@ -1773,7 +1958,7 @@ async def confirm_purchase(
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="покупка подписки (повторная попытка)",
|
||||
)
|
||||
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
@@ -1781,7 +1966,7 @@ async def confirm_purchase(
|
||||
amount_kopeks=final_price,
|
||||
description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
@@ -1988,7 +2173,7 @@ async def create_paid_subscription_with_traffic_mode(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
device_limit: int,
|
||||
device_limit: Optional[int],
|
||||
connected_squads: List[str],
|
||||
traffic_gb: Optional[int] = None
|
||||
):
|
||||
@@ -2002,16 +2187,20 @@ async def create_paid_subscription_with_traffic_mode(
|
||||
else:
|
||||
traffic_limit_gb = traffic_gb
|
||||
|
||||
subscription = await create_paid_subscription(
|
||||
create_kwargs = dict(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=duration_days,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
update_server_counters=False,
|
||||
)
|
||||
|
||||
if device_limit is not None:
|
||||
create_kwargs['device_limit'] = device_limit
|
||||
|
||||
subscription = await create_paid_subscription(**create_kwargs)
|
||||
|
||||
logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})")
|
||||
|
||||
return subscription
|
||||
@@ -2034,9 +2223,14 @@ async def handle_subscription_settings(
|
||||
)
|
||||
return
|
||||
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
show_devices = settings.is_devices_selection_enabled()
|
||||
|
||||
settings_text = texts.t(
|
||||
if show_devices:
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
else:
|
||||
devices_used = 0
|
||||
|
||||
settings_template = texts.t(
|
||||
"SUBSCRIPTION_SETTINGS_OVERVIEW",
|
||||
(
|
||||
"⚙️ <b>Настройки подписки</b>\n\n"
|
||||
@@ -2046,7 +2240,15 @@ async def handle_subscription_settings(
|
||||
"📱 Устройства: {devices_used} / {devices_limit}\n\n"
|
||||
"Выберите что хотите изменить:"
|
||||
),
|
||||
).format(
|
||||
)
|
||||
|
||||
if not show_devices:
|
||||
settings_template = settings_template.replace(
|
||||
"\n📱 Устройства: {devices_used} / {devices_limit}",
|
||||
"",
|
||||
)
|
||||
|
||||
settings_text = settings_template.format(
|
||||
countries_count=len(subscription.connected_squads),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
|
||||
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb),
|
||||
@@ -2384,10 +2586,13 @@ async def handle_simple_subscription_purchase(
|
||||
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Определяем ограничение по устройствам для текущего режима
|
||||
simple_device_limit = resolve_simple_subscription_device_limit()
|
||||
|
||||
# Проверяем, есть ли у пользователя активная подписка
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
current_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
|
||||
# Если у пользователя уже есть активная подписка, продлеваем её
|
||||
if current_subscription and current_subscription.is_active:
|
||||
# Продлеваем существующую подписку
|
||||
@@ -2397,16 +2602,16 @@ async def handle_simple_subscription_purchase(
|
||||
db=db,
|
||||
current_subscription=current_subscription,
|
||||
period_days=settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
|
||||
device_limit=settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
|
||||
device_limit=simple_device_limit,
|
||||
traffic_limit_gb=settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
squad_uuid=settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
# Подготовим параметры простой подписки
|
||||
subscription_params = {
|
||||
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
|
||||
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
|
||||
"device_limit": simple_device_limit,
|
||||
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
|
||||
}
|
||||
@@ -2441,16 +2646,26 @@ async def handle_simple_subscription_purchase(
|
||||
|
||||
if user_balance_kopeks >= price_kopeks:
|
||||
# Если баланс достаточный, предлагаем оплатить с баланса
|
||||
message_text = (
|
||||
f"⚡ <b>Простая покупка подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {traffic_text}\n"
|
||||
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
|
||||
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
|
||||
f"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
|
||||
)
|
||||
simple_lines = [
|
||||
"⚡ <b>Простая покупка подписки</b>",
|
||||
"",
|
||||
f"📅 Период: {subscription_params['period_days']} дней",
|
||||
]
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
simple_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
simple_lines.extend([
|
||||
f"📊 Трафик: {traffic_text}",
|
||||
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}",
|
||||
"",
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
|
||||
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}",
|
||||
"",
|
||||
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты.",
|
||||
])
|
||||
|
||||
message_text = "\n".join(simple_lines)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✅ Оплатить с баланса", callback_data="simple_subscription_pay_with_balance")],
|
||||
@@ -2459,16 +2674,26 @@ async def handle_simple_subscription_purchase(
|
||||
])
|
||||
else:
|
||||
# Если баланс недостаточный, предлагаем внешние способы оплаты
|
||||
message_text = (
|
||||
f"⚡ <b>Простая покупка подписки</b>\n\n"
|
||||
f"📅 Период: {subscription_params['period_days']} дней\n"
|
||||
f"📱 Устройства: {subscription_params['device_limit']}\n"
|
||||
f"📊 Трафик: {traffic_text}\n"
|
||||
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
|
||||
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
|
||||
f"Выберите способ оплаты:"
|
||||
)
|
||||
simple_lines = [
|
||||
"⚡ <b>Простая покупка подписки</b>",
|
||||
"",
|
||||
f"📅 Период: {subscription_params['period_days']} дней",
|
||||
]
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
simple_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||
|
||||
simple_lines.extend([
|
||||
f"📊 Трафик: {traffic_text}",
|
||||
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}",
|
||||
"",
|
||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
|
||||
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}",
|
||||
"",
|
||||
"Выберите способ оплаты:",
|
||||
])
|
||||
|
||||
message_text = "\n".join(simple_lines)
|
||||
|
||||
keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
|
||||
|
||||
@@ -2759,7 +2984,7 @@ async def _extend_existing_subscription(
|
||||
success_message = (
|
||||
"✅ Подписка успешно продлена!\n\n"
|
||||
f"⏰ Добавлено: {period_days} дней\n"
|
||||
f"Действует до: {new_end_date.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"Действует до: {format_local_datetime(new_end_date, '%d.%m.%Y %H:%M')}\n\n"
|
||||
f"💰 Списано: {texts.format_price(price_kopeks)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import logging
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.subscription_checkout_service import save_subscription_checkout_draft
|
||||
from app.states import SubscriptionStates
|
||||
from app.keyboards.inline import get_subscription_confirm_keyboard
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - only for type checking
|
||||
from .pricing import _prepare_subscription_summary
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def present_subscription_summary(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
texts: Optional = None,
|
||||
) -> bool:
|
||||
"""Render the subscription purchase summary and switch to the confirmation state.
|
||||
|
||||
Returns ``True`` when the summary is shown successfully and ``False`` if
|
||||
calculation failed (an error is shown to the user in this case).
|
||||
"""
|
||||
|
||||
if texts is None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
|
||||
from .pricing import _prepare_subscription_summary
|
||||
|
||||
try:
|
||||
summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts)
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Ошибка в расчете цены подписки для пользователя %s: %s",
|
||||
db_user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
|
||||
return False
|
||||
|
||||
await state.set_data(prepared_data)
|
||||
await save_subscription_checkout_draft(db_user.id, prepared_data)
|
||||
|
||||
await callback.message.edit_text(
|
||||
summary_text,
|
||||
reply_markup=get_subscription_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(SubscriptionStates.confirming_purchase)
|
||||
return True
|
||||
@@ -80,6 +80,7 @@ from app.utils.promo_offer import (
|
||||
|
||||
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
|
||||
from .countries import _get_available_countries, _should_show_countries_management
|
||||
from .summary import present_subscription_summary
|
||||
|
||||
async def handle_add_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -352,12 +353,15 @@ async def select_traffic(
|
||||
reply_markup=get_countries_keyboard(countries, [], db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
else:
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
available_countries = [c for c in countries if c.get('is_available', True)]
|
||||
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
|
||||
await state.set_data(data)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
available_countries = [c for c in countries if c.get('is_available', True)]
|
||||
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
|
||||
await state.set_data(data)
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -365,8 +369,11 @@ async def select_traffic(
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
if await present_subscription_summary(callback, state, db_user, texts):
|
||||
await callback.answer()
|
||||
|
||||
async def add_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
|
||||
@@ -760,6 +760,12 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
callback_data=f"admin_user_promo_group_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_REFERRALS_BUTTON", "🤝 Рефералы"),
|
||||
callback_data=f"admin_user_referrals_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"),
|
||||
|
||||
+22
-16
@@ -2085,33 +2085,39 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
|
||||
if show_countries_management:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries")
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), callback_data="subscription_change_devices")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), callback_data="subscription_manage_devices")
|
||||
]
|
||||
])
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
|
||||
])
|
||||
keyboard.insert(-2, [
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
|
||||
])
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"),
|
||||
callback_data="subscription_change_devices"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"),
|
||||
callback_data="subscription_manage_devices"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
|
||||
@@ -856,6 +856,9 @@
|
||||
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
|
||||
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Payments are not refunded",
|
||||
"DEVICE_CHANGE_RESULT_LINE": "📱 Was: {old} → Now: {new}\n",
|
||||
"DEVICES_INVALID_REQUEST": "❌ Invalid request",
|
||||
"DEVICES_INVALID_COUNT": "❌ Invalid device count",
|
||||
"DEVICES_SELECTION_DISABLED": "⚠️ Device selection is unavailable",
|
||||
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
|
||||
"DEVICE_FETCH_ERROR": "❌ Failed to load devices",
|
||||
"DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information",
|
||||
@@ -1305,6 +1308,8 @@
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
|
||||
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!",
|
||||
"SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}",
|
||||
"SUB_STATUS_DISABLED": "⚫ Disabled",
|
||||
"SUB_STATUS_PENDING": "⏳ Pending activation",
|
||||
"SUB_STATUS_NONE": "❌ Not available",
|
||||
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)",
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!",
|
||||
@@ -1378,7 +1383,8 @@
|
||||
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
|
||||
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Devices:</b> {devices} pcs",
|
||||
"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",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
|
||||
|
||||
@@ -713,6 +713,26 @@
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
|
||||
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы",
|
||||
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Рефералы пользователя</b>",
|
||||
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
|
||||
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералов:</b>",
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов",
|
||||
"ADMIN_USER_REFERRALS_EMPTY": "Рефералов пока нет.",
|
||||
"ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
|
||||
"ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Редактировать",
|
||||
"ADMIN_USER_REFERRALS_EDIT_PROMPT": "✏️ <b>Редактирование рефералов</b>\n\nОтправьте список рефералов для пользователя <b>{name}</b> (ID: <code>{telegram_id}</code>):\n• Используйте TG ID или @username\n• Значения можно указывать через запятую, пробел или с новой строки\n• Чтобы очистить список, отправьте 0 или слово 'нет'\n\nИли нажмите кнопку ниже, чтобы отменить.",
|
||||
"ADMIN_USER_REFERRALS_STATE_LOST": "❌ Не удалось определить пользователя. Попробуйте начать сначала.",
|
||||
"ADMIN_USER_REFERRALS_NO_VALID": "❌ Не удалось найти ни одного пользователя по введённым данным.",
|
||||
"ADMIN_USER_REFERRALS_INVALID_ENTRIES": "Не найдены: {values}",
|
||||
"ADMIN_USER_REFERRALS_SELF_SKIPPED": "Пропущены значения пользователя: {values}",
|
||||
"ADMIN_USER_REFERRALS_DUPLICATES": "Игнорированы дубли: {values}",
|
||||
"ADMIN_USER_REFERRALS_UPDATE_ERROR": "❌ Не удалось обновить рефералов. Попробуйте позже.",
|
||||
"ADMIN_USER_REFERRALS_UPDATED": "✅ Список рефералов обновлён.",
|
||||
"ADMIN_USER_REFERRALS_UPDATED_TOTAL": "• Текущий список: {total}",
|
||||
"ADMIN_USER_REFERRALS_UPDATED_ADDED": "• Добавлено: {count}",
|
||||
"ADMIN_USER_REFERRALS_UPDATED_REMOVED": "• Удалено: {count}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
@@ -856,6 +876,9 @@
|
||||
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
|
||||
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Возврат средств не производится",
|
||||
"DEVICE_CHANGE_RESULT_LINE": "📱 Было: {old} → Стало: {new}\n",
|
||||
"DEVICES_INVALID_REQUEST": "❌ Некорректный запрос",
|
||||
"DEVICES_INVALID_COUNT": "❌ Некорректное количество устройств",
|
||||
"DEVICES_SELECTION_DISABLED": "⚠️ Выбор количества устройств недоступен",
|
||||
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
|
||||
"DEVICE_FETCH_ERROR": "❌ Ошибка получения устройств",
|
||||
"DEVICE_FETCH_INFO_ERROR": "❌ Ошибка получения информации об устройствах",
|
||||
@@ -1305,6 +1328,8 @@
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!",
|
||||
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!",
|
||||
"SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}",
|
||||
"SUB_STATUS_DISABLED": "⚫ Отключена",
|
||||
"SUB_STATUS_PENDING": "⏳ Ожидает активации",
|
||||
"SUB_STATUS_NONE": "❌ Отсутствует",
|
||||
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
@@ -1378,7 +1403,8 @@
|
||||
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Устройства:</b> {devices} шт.",
|
||||
"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",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from aiogram import BaseMiddleware, Bot, types
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
@@ -8,6 +8,7 @@ from aiogram.enums import ChatMemberStatus
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.campaign import get_campaign_by_start_parameter
|
||||
from app.database.crud.subscription import deactivate_subscription
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
@@ -16,6 +17,7 @@ from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -104,7 +106,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
if telegram_id:
|
||||
await self._deactivate_trial_subscription(telegram_id)
|
||||
|
||||
await self._capture_start_payload(state, event)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
|
||||
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
|
||||
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
||||
@@ -113,12 +115,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
else:
|
||||
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
|
||||
await self._capture_start_payload(state, event)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
|
||||
except TelegramForbiddenError as e:
|
||||
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
|
||||
await self._capture_start_payload(state, event)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except TelegramBadRequest as e:
|
||||
if "chat not found" in str(e).lower():
|
||||
@@ -127,13 +129,18 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
|
||||
await self._capture_start_payload(state, event)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
|
||||
return await handler(event, data)
|
||||
|
||||
async def _capture_start_payload(self, state: Optional[FSMContext], event: TelegramObject) -> None:
|
||||
async def _capture_start_payload(
|
||||
self,
|
||||
state: Optional[FSMContext],
|
||||
event: TelegramObject,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> None:
|
||||
if not state:
|
||||
return
|
||||
|
||||
@@ -159,12 +166,69 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
payload = parts[1]
|
||||
|
||||
data = await state.get_data() or {}
|
||||
if data.get("pending_start_payload") == payload:
|
||||
if data.get("pending_start_payload") != payload:
|
||||
data["pending_start_payload"] = payload
|
||||
await state.set_data(data)
|
||||
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
|
||||
|
||||
if bot and message.from_user:
|
||||
await self._try_send_campaign_visit_notification(
|
||||
bot,
|
||||
message.from_user,
|
||||
state,
|
||||
payload,
|
||||
)
|
||||
|
||||
async def _try_send_campaign_visit_notification(
|
||||
self,
|
||||
bot: Bot,
|
||||
telegram_user: types.User,
|
||||
state: FSMContext,
|
||||
payload: str,
|
||||
) -> None:
|
||||
try:
|
||||
data = await state.get_data() or {}
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"❌ Не удалось получить данные состояния для уведомления по кампании %s: %s",
|
||||
payload,
|
||||
error,
|
||||
)
|
||||
return
|
||||
|
||||
data["pending_start_payload"] = payload
|
||||
await state.set_data(data)
|
||||
logger.debug("💾 Сохранен start payload %s для последующей обработки", payload)
|
||||
if data.get("campaign_notification_sent"):
|
||||
return
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
payload,
|
||||
only_active=True,
|
||||
)
|
||||
if not campaign:
|
||||
break
|
||||
|
||||
user = await get_user_by_telegram_id(db, telegram_user.id)
|
||||
|
||||
notification_service = AdminNotificationService(bot)
|
||||
sent = await notification_service.send_campaign_link_visit_notification(
|
||||
db,
|
||||
telegram_user,
|
||||
campaign,
|
||||
user,
|
||||
)
|
||||
if sent:
|
||||
await state.update_data(campaign_notification_sent=True)
|
||||
break
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"❌ Ошибка отправки уведомления о переходе по кампании %s: %s",
|
||||
payload,
|
||||
error,
|
||||
)
|
||||
finally:
|
||||
break
|
||||
|
||||
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
|
||||
async for db in get_db():
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.database.models import (
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -77,6 +78,20 @@ class AdminNotificationService:
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_user_display(self, user: User) -> str:
|
||||
first_name = getattr(user, "first_name", "") or ""
|
||||
if first_name:
|
||||
return first_name
|
||||
|
||||
username = getattr(user, "username", "") or ""
|
||||
if username:
|
||||
return username
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
return "IDUnknown"
|
||||
return f"ID{telegram_id}"
|
||||
|
||||
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
|
||||
discount_lines: List[str] = []
|
||||
|
||||
@@ -185,12 +200,21 @@ class AdminNotificationService:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
trial_device_limit = subscription.device_limit
|
||||
if trial_device_limit is None:
|
||||
fallback_forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if fallback_forced_limit is not None:
|
||||
trial_device_limit = fallback_forced_limit
|
||||
else:
|
||||
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
@@ -198,13 +222,13 @@ class AdminNotificationService:
|
||||
⏰ <b>Параметры триала:</b>
|
||||
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
|
||||
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
|
||||
📱 Устройства: {settings.TRIAL_DEVICE_LIMIT}
|
||||
📱 Устройства: {trial_device_limit}
|
||||
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
|
||||
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -227,28 +251,29 @@ class AdminNotificationService:
|
||||
|
||||
try:
|
||||
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
|
||||
|
||||
|
||||
if was_trial_conversion:
|
||||
user_status = "🎯 Конверсия из триала"
|
||||
elif user.has_had_paid_subscription:
|
||||
user_status = "🔄 Продление/Обновление"
|
||||
else:
|
||||
user_status = "🆕 Первая покупка"
|
||||
|
||||
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс"
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
|
||||
transaction_id = transaction.id if transaction else "—"
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
@@ -264,11 +289,11 @@ class AdminNotificationService:
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -315,7 +340,7 @@ class AdminNotificationService:
|
||||
|
||||
ℹ️ Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
|
||||
|
||||
⚙️ <i>Автоматическая проверка обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⚙️ <i>Автоматическая проверка обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -340,7 +365,7 @@ class AdminNotificationService:
|
||||
🔄 Следующая попытка через час.
|
||||
⚙️ Проверьте доступность GitHub API и настройки сети.
|
||||
|
||||
⚙️ <i>Система автоматических обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⚙️ <i>Система автоматических обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -363,13 +388,14 @@ class AdminNotificationService:
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
subscription_status = self._get_subscription_status(subscription)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
timestamp = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
|
||||
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
💳 <b>Статус:</b> {topup_status}
|
||||
|
||||
{promo_block}
|
||||
@@ -540,15 +566,16 @@ class AdminNotificationService:
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
current_end_date = new_end_date or subscription.end_date
|
||||
current_balance = balance_after if balance_after is not None else user.balance_kopeks
|
||||
|
||||
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
|
||||
{promo_block}
|
||||
|
||||
@@ -559,8 +586,8 @@ class AdminNotificationService:
|
||||
|
||||
📅 <b>Продление:</b>
|
||||
➕ Добавлено дней: {extended_days}
|
||||
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Было до: {format_local_datetime(old_end_date, '%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {format_local_datetime(current_end_date, '%d.%m.%Y %H:%M')}
|
||||
|
||||
📱 <b>Текущие параметры:</b>
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
@@ -569,7 +596,7 @@ class AdminNotificationService:
|
||||
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(current_balance)}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -592,13 +619,14 @@ class AdminNotificationService:
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
type_display = self._get_promocode_type_display(promocode_data.get("type"))
|
||||
usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}"
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
message_lines = [
|
||||
"🎫 <b>АКТИВАЦИЯ ПРОМОКОДА</b>",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
@@ -621,7 +649,7 @@ class AdminNotificationService:
|
||||
valid_until = promocode_data.get("valid_until")
|
||||
if valid_until:
|
||||
message_lines.append(
|
||||
f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
|
||||
f"⏳ Действует до: {format_local_datetime(valid_until, '%d.%m.%Y %H:%M')}"
|
||||
if isinstance(valid_until, datetime)
|
||||
else f"⏳ Действует до: {valid_until}"
|
||||
)
|
||||
@@ -632,7 +660,7 @@ class AdminNotificationService:
|
||||
"📝 <b>Эффект:</b>",
|
||||
effect_description.strip() or "✅ Промокод активирован",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -686,7 +714,7 @@ class AdminNotificationService:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -719,13 +747,14 @@ class AdminNotificationService:
|
||||
)
|
||||
elif automatic:
|
||||
initiator_line = "🤖 Автоматическое назначение"
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"),
|
||||
]
|
||||
@@ -750,7 +779,7 @@ class AdminNotificationService:
|
||||
[
|
||||
"",
|
||||
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -828,9 +857,9 @@ class AdminNotificationService:
|
||||
return "❌ Нет подписки"
|
||||
|
||||
if subscription.is_trial:
|
||||
return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
|
||||
return f"🎯 Триал (до {format_local_datetime(subscription.end_date, '%d.%m')})"
|
||||
elif subscription.is_active:
|
||||
return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
|
||||
return f"✅ Активна (до {format_local_datetime(subscription.end_date, '%d.%m')})"
|
||||
else:
|
||||
return "❌ Неактивна"
|
||||
|
||||
@@ -901,7 +930,9 @@ class AdminNotificationService:
|
||||
if isinstance(enabled_at, str):
|
||||
from datetime import datetime
|
||||
enabled_at = datetime.fromisoformat(enabled_at)
|
||||
message_parts.append(f"🕐 <b>Время включения:</b> {enabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
message_parts.append(
|
||||
f"🕐 <b>Время включения:</b> {format_local_datetime(enabled_at, '%d.%m.%Y %H:%M:%S')}"
|
||||
)
|
||||
|
||||
message_parts.append(f"🤖 <b>Автоматически:</b> {'Да' if details.get('auto_enabled', False) else 'Нет'}")
|
||||
message_parts.append("")
|
||||
@@ -913,7 +944,9 @@ class AdminNotificationService:
|
||||
if isinstance(disabled_at, str):
|
||||
from datetime import datetime
|
||||
disabled_at = datetime.fromisoformat(disabled_at)
|
||||
message_parts.append(f"🕐 <b>Время отключения:</b> {disabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
message_parts.append(
|
||||
f"🕐 <b>Время отключения:</b> {format_local_datetime(disabled_at, '%d.%m.%Y %H:%M:%S')}"
|
||||
)
|
||||
|
||||
if details.get("duration"):
|
||||
duration = details["duration"]
|
||||
@@ -972,9 +1005,10 @@ class AdminNotificationService:
|
||||
else:
|
||||
message_parts.append("Автоматический мониторинг API остановлен.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
message_parts.append(
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"
|
||||
)
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
@@ -1020,7 +1054,9 @@ class AdminNotificationService:
|
||||
if isinstance(last_check, str):
|
||||
from datetime import datetime
|
||||
last_check = datetime.fromisoformat(last_check)
|
||||
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
|
||||
message_parts.append(
|
||||
f"🕐 <b>Последняя проверка:</b> {format_local_datetime(last_check, '%H:%M:%S')}"
|
||||
)
|
||||
|
||||
if status == "online":
|
||||
if details.get("uptime"):
|
||||
@@ -1066,9 +1102,10 @@ class AdminNotificationService:
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель временно недоступна для обслуживания.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
message_parts.append(
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"
|
||||
)
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
@@ -1095,6 +1132,7 @@ class AdminNotificationService:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
update_types = {
|
||||
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
@@ -1107,9 +1145,9 @@ class AdminNotificationService:
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"👤 <b>Пользователь:</b> {user_display}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
f"📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
@@ -1142,11 +1180,11 @@ class AdminNotificationService:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
|
||||
f"📅 <b>Подписка действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}",
|
||||
f"💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}",
|
||||
f"🔗 <b>Рефер:</b> {referrer_info}",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -120,9 +120,9 @@ class AdvertisingCampaignService:
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
traffic_limit = campaign.subscription_traffic_gb
|
||||
device_limit = (
|
||||
campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
)
|
||||
device_limit = campaign.subscription_device_limit
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
if not squads:
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
from app.config import settings
|
||||
from app.external.remnawave_api import RemnaWaveAPI, test_api_connection
|
||||
from app.utils.cache import cache
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,6 +46,9 @@ class MaintenanceService:
|
||||
|
||||
def get_maintenance_message(self) -> str:
|
||||
if self._status.auto_enabled:
|
||||
last_check_display = format_local_datetime(
|
||||
self._status.last_check, "%H:%M:%S", "неизвестно"
|
||||
)
|
||||
return f"""
|
||||
🔧 Технические работы!
|
||||
|
||||
@@ -52,7 +56,7 @@ class MaintenanceService:
|
||||
|
||||
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
|
||||
|
||||
🔄 Последняя проверка: {self._status.last_check.strftime('%H:%M:%S') if self._status.last_check else 'неизвестно'}
|
||||
🔄 Последняя проверка: {last_check_display}
|
||||
"""
|
||||
else:
|
||||
return settings.get_maintenance_message()
|
||||
@@ -79,7 +83,12 @@ class MaintenanceService:
|
||||
}
|
||||
emoji = emoji_map.get(alert_type, "ℹ️")
|
||||
|
||||
formatted_message = f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
|
||||
timestamp = format_local_datetime(
|
||||
datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z"
|
||||
)
|
||||
formatted_message = (
|
||||
f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{timestamp}</i>"
|
||||
)
|
||||
|
||||
return await notification_service._send_message(formatted_message)
|
||||
|
||||
@@ -152,11 +161,14 @@ class MaintenanceService:
|
||||
|
||||
await self._save_status_to_cache()
|
||||
|
||||
enabled_time = format_local_datetime(
|
||||
self._status.enabled_at, "%d.%m.%Y %H:%M:%S %Z"
|
||||
)
|
||||
notification_msg = f"""Режим технических работ ВКЛЮЧЕН
|
||||
|
||||
📋 <b>Причина:</b> {self._status.reason}
|
||||
🤖 <b>Автоматически:</b> {'Да' if auto else 'Нет'}
|
||||
🕐 <b>Время:</b> {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')}
|
||||
🕐 <b>Время:</b> {enabled_time}
|
||||
|
||||
Обычные пользователи временно не смогут использовать бота."""
|
||||
|
||||
@@ -197,10 +209,13 @@ class MaintenanceService:
|
||||
else:
|
||||
duration_str = f"\n⏱️ <b>Длительность:</b> {minutes}мин"
|
||||
|
||||
notification_time = format_local_datetime(
|
||||
datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z"
|
||||
)
|
||||
notification_msg = f"""Режим технических работ ВЫКЛЮЧЕН
|
||||
|
||||
🤖 <b>Автоматически:</b> {'Да' if was_auto else 'Нет'}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')}
|
||||
🕐 <b>Время:</b> {notification_time}
|
||||
{duration_str}
|
||||
|
||||
Сервис снова доступен для пользователей."""
|
||||
@@ -223,15 +238,23 @@ class MaintenanceService:
|
||||
await self._load_status_from_cache()
|
||||
|
||||
self._check_task = asyncio.create_task(self._monitoring_loop())
|
||||
logger.info(f"🔄 Запущен мониторинг API Remnawave (интервал: {settings.get_maintenance_check_interval()}с)")
|
||||
|
||||
await self._notify_admins(f"""Мониторинг технических работ запущен
|
||||
logger.info(
|
||||
"🔄 Запущен мониторинг API Remnawave (интервал: %sс, попыток: %s)",
|
||||
settings.get_maintenance_check_interval(),
|
||||
settings.get_maintenance_retry_attempts(),
|
||||
)
|
||||
|
||||
await self._notify_admins(
|
||||
f"""Мониторинг технических работ запущен
|
||||
|
||||
🔄 <b>Интервал проверки:</b> {settings.get_maintenance_check_interval()} секунд
|
||||
🤖 <b>Автовключение:</b> {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
|
||||
🎯 <b>Порог ошибок:</b> {self._max_consecutive_failures}
|
||||
🔁 <b>Повторных попыток:</b> {settings.get_maintenance_retry_attempts()}
|
||||
|
||||
Система будет следить за доступностью API.""", "info")
|
||||
Система будет следить за доступностью API.""",
|
||||
"info",
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -260,10 +283,10 @@ class MaintenanceService:
|
||||
try:
|
||||
if self._is_checking:
|
||||
return self._status.api_status
|
||||
|
||||
|
||||
self._is_checking = True
|
||||
self._status.last_check = datetime.utcnow()
|
||||
|
||||
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
api = RemnaWaveAPI(
|
||||
base_url=auth_params["base_url"],
|
||||
@@ -272,63 +295,100 @@ class MaintenanceService:
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
|
||||
|
||||
attempts = settings.get_maintenance_retry_attempts()
|
||||
|
||||
async with api:
|
||||
is_connected = await test_api_connection(api)
|
||||
|
||||
if is_connected:
|
||||
if not self._status.api_status:
|
||||
await self._notify_admins(f"""API Remnawave восстановлено!
|
||||
for attempt in range(1, attempts + 1):
|
||||
is_connected = await test_api_connection(api)
|
||||
|
||||
if is_connected:
|
||||
if attempt > 1:
|
||||
logger.info(
|
||||
"API Remnawave ответило с %s попытки", attempt
|
||||
)
|
||||
|
||||
if not self._status.api_status:
|
||||
recovery_time = format_local_datetime(
|
||||
self._status.last_check, "%H:%M:%S %Z"
|
||||
)
|
||||
await self._notify_admins(
|
||||
f"""API Remnawave восстановлено!
|
||||
|
||||
✅ <b>Статус:</b> Доступно
|
||||
🕐 <b>Время восстановления:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🕐 <b>Время восстановления:</b> {recovery_time}
|
||||
🔄 <b>Неудачных попыток было:</b> {self._status.consecutive_failures}
|
||||
|
||||
API снова отвечает на запросы.""", "success")
|
||||
|
||||
self._status.api_status = True
|
||||
self._status.consecutive_failures = 0
|
||||
|
||||
if self._status.is_active and self._status.auto_enabled:
|
||||
await self.disable_maintenance()
|
||||
logger.info("✅ API восстановился, режим техработ автоматически отключен")
|
||||
|
||||
return True
|
||||
else:
|
||||
was_available = self._status.api_status
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
|
||||
if was_available:
|
||||
await self._notify_admins(f"""API Remnawave недоступно!
|
||||
API снова отвечает на запросы.""",
|
||||
"success",
|
||||
)
|
||||
|
||||
self._status.api_status = True
|
||||
self._status.consecutive_failures = 0
|
||||
|
||||
if self._status.is_active and self._status.auto_enabled:
|
||||
await self.disable_maintenance()
|
||||
logger.info("✅ API восстановился, режим техработ автоматически отключен")
|
||||
|
||||
return True
|
||||
|
||||
if attempt < attempts:
|
||||
logger.warning(
|
||||
"API Remnawave недоступно (попытка %s/%s)",
|
||||
attempt,
|
||||
attempts,
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
was_available = self._status.api_status
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
|
||||
if was_available:
|
||||
detection_time = format_local_datetime(
|
||||
self._status.last_check, "%H:%M:%S %Z"
|
||||
)
|
||||
await self._notify_admins(
|
||||
f"""API Remnawave недоступно!
|
||||
|
||||
❌ <b>Статус:</b> Недоступно
|
||||
🕐 <b>Время обнаружения:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🕐 <b>Время обнаружения:</b> {detection_time}
|
||||
🔄 <b>Попытка:</b> {self._status.consecutive_failures}
|
||||
|
||||
Началась серия неудачных проверок API.""", "error")
|
||||
|
||||
if (self._status.consecutive_failures >= self._max_consecutive_failures and
|
||||
not self._status.is_active and
|
||||
settings.is_maintenance_auto_enable()):
|
||||
|
||||
await self.enable_maintenance(
|
||||
reason=f"Автоматическое включение после {self._status.consecutive_failures} неудачных проверок API",
|
||||
auto=True
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
Началась серия неудачных проверок API.""",
|
||||
"error",
|
||||
)
|
||||
|
||||
if (
|
||||
self._status.consecutive_failures >= self._max_consecutive_failures
|
||||
and not self._status.is_active
|
||||
and settings.is_maintenance_auto_enable()
|
||||
):
|
||||
|
||||
await self.enable_maintenance(
|
||||
reason=(
|
||||
f"Автоматическое включение после {self._status.consecutive_failures} "
|
||||
"неудачных проверок API"
|
||||
),
|
||||
auto=True
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки API: {e}")
|
||||
|
||||
if self._status.api_status:
|
||||
await self._notify_admins(f"""Ошибка при проверке API Remnawave
|
||||
error_time = format_local_datetime(datetime.utcnow(), "%H:%M:%S %Z")
|
||||
await self._notify_admins(
|
||||
f"""Ошибка при проверке API Remnawave
|
||||
|
||||
❌ <b>Ошибка:</b> {str(e)}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%H:%M:%S')}
|
||||
🕐 <b>Время:</b> {error_time}
|
||||
|
||||
Не удалось выполнить проверку доступности API.""", "error")
|
||||
Не удалось выполнить проверку доступности API.""",
|
||||
"error",
|
||||
)
|
||||
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
@@ -398,6 +458,7 @@ API снова отвечает на запросы.""", "success")
|
||||
"api_status": self._status.api_status,
|
||||
"consecutive_failures": self._status.consecutive_failures,
|
||||
"monitoring_active": self._check_task is not None and not self._check_task.done(),
|
||||
"monitoring_configured": settings.is_maintenance_monitoring_enabled(),
|
||||
"auto_enable_configured": settings.is_maintenance_auto_enable(),
|
||||
"check_interval": settings.get_maintenance_check_interval(),
|
||||
"bot_connected": self._bot is not None
|
||||
|
||||
@@ -38,6 +38,10 @@ from app.database.crud.user import (
|
||||
subtract_user_balance,
|
||||
cleanup_expired_promo_offer_discounts,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.notification_settings_service import NotificationSettingsService
|
||||
@@ -283,20 +287,26 @@ class MonitoringService:
|
||||
logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'")
|
||||
|
||||
async with self.api as api:
|
||||
updated_user = await api.update_user(
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
update_kwargs = dict(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
updated_user = await api.update_user(**update_kwargs)
|
||||
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
@@ -1026,7 +1036,7 @@ class MonitoringService:
|
||||
message = f"""
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
|
||||
Ваша платная подписка истекает {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
@@ -1142,7 +1152,7 @@ class MonitoringService:
|
||||
|
||||
message = template.format(
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -1258,7 +1268,7 @@ class MonitoringService:
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
)
|
||||
|
||||
@@ -1335,7 +1345,7 @@ class MonitoringService:
|
||||
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
|
||||
expires_at=format_local_datetime(expires_at, "%d.%m.%Y %H:%M"),
|
||||
trigger_days=trigger_days or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -83,6 +83,12 @@ class Pal24Service:
|
||||
logger.debug("Запрашиваем статус Pal24 платежа %s", payment_id)
|
||||
return await self.client.get_payment_status(payment_id)
|
||||
|
||||
async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]:
|
||||
"""Возвращает список платежей, связанных со счетом."""
|
||||
|
||||
logger.debug("Запрашиваем платежи Pal24 счёта %s", bill_id)
|
||||
return await self.client.get_bill_payments(bill_id)
|
||||
|
||||
@staticmethod
|
||||
def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
|
||||
|
||||
@@ -247,6 +247,7 @@ class MulenPayPaymentMixin:
|
||||
user,
|
||||
payment.amount_kopeks,
|
||||
f"Пополнение {display_name}: {payment.amount_kopeks // 100}₽",
|
||||
create_transaction=False,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -405,7 +405,10 @@ class YooKassaPaymentMixin:
|
||||
# Используем обновленные данные или исходные, если не удалось обновить
|
||||
subscription = full_user.subscription if full_user else getattr(user, "subscription", None)
|
||||
promo_group = full_user.promo_group if full_user else getattr(user, "promo_group", None)
|
||||
referrer_info = format_referrer_info(full_user if full_user else user)
|
||||
|
||||
# Используем full_user для форматирования реферальной информации, чтобы избежать проблем с ленивой загрузкой
|
||||
user_for_referrer = full_user if full_user else user
|
||||
referrer_info = format_referrer_info(user_for_referrer)
|
||||
topup_status = (
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
)
|
||||
|
||||
@@ -131,12 +131,20 @@ class PromoCodeService:
|
||||
if getattr(settings, 'TRIAL_SQUAD_UUID', None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
if forced_devices is not None:
|
||||
device_limit = forced_devices
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=promocode.subscription_days,
|
||||
traffic_limit_gb=0,
|
||||
device_limit=1,
|
||||
device_limit=device_limit,
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
@@ -155,10 +163,15 @@ class PromoCodeService:
|
||||
if not subscription:
|
||||
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
trial_subscription = await create_trial_subscription(
|
||||
db,
|
||||
user.id,
|
||||
duration_days=trial_days
|
||||
duration_days=trial_days,
|
||||
device_limit=forced_devices,
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, trial_subscription)
|
||||
|
||||
@@ -89,16 +89,64 @@ async def process_referral_topup(
|
||||
logger.info(f"Пользователь {user_id} не является рефералом")
|
||||
return True
|
||||
|
||||
if topup_amount_kopeks < settings.REFERRAL_MINIMUM_TOPUP_KOPEKS:
|
||||
logger.info(f"Пополнение {user_id} на {topup_amount_kopeks/100}₽ меньше минимума")
|
||||
return True
|
||||
|
||||
referrer = await get_user_by_id(db, user.referred_by_id)
|
||||
if not referrer:
|
||||
logger.error(f"Реферер {user.referred_by_id} не найден")
|
||||
return False
|
||||
|
||||
|
||||
qualifies_for_first_bonus = (
|
||||
topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS
|
||||
)
|
||||
commission_amount = 0
|
||||
if settings.REFERRAL_COMMISSION_PERCENT > 0:
|
||||
commission_amount = int(
|
||||
topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100
|
||||
)
|
||||
|
||||
if not user.has_made_first_topup:
|
||||
if not qualifies_for_first_bonus:
|
||||
logger.info(
|
||||
"Пополнение %s на %s₽ меньше минимума для первого бонуса, но комиссия будет начислена",
|
||||
user_id,
|
||||
topup_amount_kopeks / 100,
|
||||
)
|
||||
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db,
|
||||
referrer,
|
||||
commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot,
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"💰 Комиссия с пополнения: %s получил %s₽ (до первого бонуса)",
|
||||
referrer.telegram_id,
|
||||
commission_amount / 100,
|
||||
)
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
|
||||
return True
|
||||
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
@@ -161,36 +209,33 @@ async def process_referral_topup(
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
|
||||
|
||||
else:
|
||||
if settings.REFERRAL_COMMISSION_PERCENT > 0:
|
||||
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
|
||||
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup"
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup"
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
@@ -38,6 +38,10 @@ from app.database.models import (
|
||||
SubscriptionStatus,
|
||||
ServerSquad,
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
)
|
||||
from app.utils.timezone import get_local_timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,15 +59,7 @@ class RemnaWaveService:
|
||||
|
||||
self._config_error: Optional[str] = None
|
||||
|
||||
tz_name = os.getenv("TZ", "UTC")
|
||||
try:
|
||||
self._panel_timezone = ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось загрузить временную зону '%s'. Используется UTC.",
|
||||
tz_name,
|
||||
)
|
||||
self._panel_timezone = ZoneInfo("UTC")
|
||||
self._panel_timezone = get_local_timezone()
|
||||
|
||||
if not base_url:
|
||||
self._config_error = "REMNAWAVE_API_URL не настроен"
|
||||
@@ -1216,44 +1212,57 @@ class RemnaWaveService:
|
||||
for user in users:
|
||||
if not user.subscription:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
subscription = user.subscription
|
||||
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
if user.remnawave_uuid:
|
||||
await api.update_user(
|
||||
update_kwargs = dict(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
await api.update_user(**update_kwargs)
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
username = f"user_{user.telegram_id}"
|
||||
|
||||
new_user = await api.create_user(
|
||||
username = settings.format_remnawave_username(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
)
|
||||
|
||||
create_kwargs = dict(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
create_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
new_user = await api.create_user(**create_kwargs)
|
||||
|
||||
await update_user(db, user, remnawave_uuid=new_user.uuid)
|
||||
subscription.remnawave_short_uuid = new_user.short_uuid
|
||||
@@ -1982,67 +1991,110 @@ class RemnaWaveService:
|
||||
}
|
||||
|
||||
async def check_panel_health(self) -> Dict[str, Any]:
|
||||
try:
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
try:
|
||||
system_stats = await api.get_system_stats()
|
||||
api_available = True
|
||||
api_error = None
|
||||
except Exception as e:
|
||||
api_available = False
|
||||
api_error = str(e)
|
||||
system_stats = {}
|
||||
|
||||
try:
|
||||
nodes = await api.get_all_nodes()
|
||||
nodes_online = sum(1 for node in nodes if node.is_connected and node.is_node_online)
|
||||
total_nodes = len(nodes)
|
||||
nodes_health = "healthy" if nodes_online > 0 else "unhealthy"
|
||||
except Exception:
|
||||
nodes_online = 0
|
||||
total_nodes = 0
|
||||
nodes_health = "unknown"
|
||||
|
||||
end_time = datetime.utcnow()
|
||||
response_time = (end_time - start_time).total_seconds()
|
||||
|
||||
if not api_available:
|
||||
status = "offline"
|
||||
elif response_time > 10:
|
||||
status = "degraded"
|
||||
elif nodes_health == "unhealthy":
|
||||
status = "degraded"
|
||||
else:
|
||||
status = "online"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"api_available": api_available,
|
||||
"api_error": api_error,
|
||||
"response_time": round(response_time, 2),
|
||||
"nodes_online": nodes_online,
|
||||
"total_nodes": total_nodes,
|
||||
"nodes_health": nodes_health,
|
||||
"users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0),
|
||||
"total_users": system_stats.get('users', {}).get('totalUsers', 0),
|
||||
"last_check": end_time,
|
||||
"api_url": settings.REMNAWAVE_API_URL
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки здоровья панели: {e}")
|
||||
return {
|
||||
"status": "offline",
|
||||
"api_available": False,
|
||||
"api_error": str(e),
|
||||
"response_time": 0,
|
||||
"nodes_online": 0,
|
||||
"total_nodes": 0,
|
||||
"nodes_health": "unknown",
|
||||
"last_check": datetime.utcnow(),
|
||||
"api_url": settings.REMNAWAVE_API_URL
|
||||
}
|
||||
attempts = settings.get_maintenance_retry_attempts()
|
||||
attempts = max(1, attempts)
|
||||
|
||||
last_result: Optional[Dict[str, Any]] = None
|
||||
last_error: Optional[Exception] = None
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
try:
|
||||
system_stats = await api.get_system_stats()
|
||||
api_available = True
|
||||
api_error = None
|
||||
except Exception as e:
|
||||
api_available = False
|
||||
api_error = str(e)
|
||||
system_stats = {}
|
||||
|
||||
try:
|
||||
nodes = await api.get_all_nodes()
|
||||
nodes_online = sum(
|
||||
1 for node in nodes if node.is_connected and node.is_node_online
|
||||
)
|
||||
total_nodes = len(nodes)
|
||||
nodes_health = "healthy" if nodes_online > 0 else "unhealthy"
|
||||
except Exception:
|
||||
nodes_online = 0
|
||||
total_nodes = 0
|
||||
nodes_health = "unknown"
|
||||
|
||||
end_time = datetime.utcnow()
|
||||
response_time = (end_time - start_time).total_seconds()
|
||||
|
||||
if not api_available:
|
||||
status = "offline"
|
||||
elif response_time > 10:
|
||||
status = "degraded"
|
||||
elif nodes_health == "unhealthy":
|
||||
status = "degraded"
|
||||
else:
|
||||
status = "online"
|
||||
|
||||
result = {
|
||||
"status": status,
|
||||
"api_available": api_available,
|
||||
"api_error": api_error,
|
||||
"response_time": round(response_time, 2),
|
||||
"nodes_online": nodes_online,
|
||||
"total_nodes": total_nodes,
|
||||
"nodes_health": nodes_health,
|
||||
"users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0),
|
||||
"total_users": system_stats.get('users', {}).get('totalUsers', 0),
|
||||
"last_check": end_time,
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
"attempts_used": attempt,
|
||||
}
|
||||
|
||||
if result["api_available"]:
|
||||
if attempt > 1:
|
||||
logger.info("Панель Remnawave ответила с %s попытки", attempt)
|
||||
return result
|
||||
|
||||
last_result = result
|
||||
|
||||
if attempt < attempts:
|
||||
logger.warning(
|
||||
"Панель Remnawave недоступна (попытка %s/%s): %s",
|
||||
attempt,
|
||||
attempts,
|
||||
result.get("api_error") or "неизвестная ошибка",
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as error:
|
||||
last_error = error
|
||||
if attempt < attempts:
|
||||
logger.warning(
|
||||
"Ошибка проверки здоровья панели (попытка %s/%s): %s",
|
||||
attempt,
|
||||
attempts,
|
||||
error,
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
logger.error(f"Ошибка проверки здоровья панели: {error}")
|
||||
|
||||
if last_result is not None:
|
||||
return last_result
|
||||
|
||||
error_message = str(last_error) if last_error else "Неизвестная ошибка"
|
||||
return {
|
||||
"status": "offline",
|
||||
"api_available": False,
|
||||
"api_error": error_message,
|
||||
"response_time": 0,
|
||||
"nodes_online": 0,
|
||||
"total_nodes": 0,
|
||||
"nodes_health": "unknown",
|
||||
"last_check": datetime.utcnow(),
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
"attempts_used": attempts,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.services.subscription_purchase_service import (
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -333,7 +334,7 @@ async def _auto_extend_subscription(
|
||||
getattr(user, "language", "ru"),
|
||||
)
|
||||
new_end_date = updated_subscription.end_date
|
||||
end_date_label = new_end_date.strftime("%d.%m.%Y %H:%M")
|
||||
end_date_label = format_local_datetime(new_end_date, "%d.%m.%Y %H:%M")
|
||||
|
||||
if bot:
|
||||
try:
|
||||
|
||||
@@ -17,6 +17,9 @@ from app.utils.pricing_utils import (
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation
|
||||
)
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -172,6 +175,7 @@ class SubscriptionService:
|
||||
return None
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if existing_users:
|
||||
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
|
||||
@@ -183,20 +187,24 @@ class SubscriptionService:
|
||||
except Exception as hwid_error:
|
||||
logger.warning(f"⚠️ Не удалось сбросить HWID: {hwid_error}")
|
||||
|
||||
updated_user = await api.update_user(
|
||||
update_kwargs = dict(
|
||||
uuid=remnawave_user.uuid,
|
||||
status=UserStatus.ACTIVE,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(),
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
updated_user = await api.update_user(**update_kwargs)
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
@@ -208,23 +216,31 @@ class SubscriptionService:
|
||||
|
||||
else:
|
||||
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
|
||||
username = f"user_{user.telegram_id}"
|
||||
updated_user = await api.create_user(
|
||||
username = settings.format_remnawave_username(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
)
|
||||
create_kwargs = dict(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(),
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
create_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
updated_user = await api.create_user(**create_kwargs)
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
api,
|
||||
@@ -282,20 +298,26 @@ class SubscriptionService:
|
||||
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
updated_user = await api.update_user(
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
update_kwargs = dict(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(),
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
updated_user = await api.update_user(**update_kwargs)
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
@@ -565,7 +587,18 @@ class SubscriptionService:
|
||||
servers_discount = servers_price * servers_discount_percent // 100
|
||||
discounted_servers_price = servers_price - servers_discount
|
||||
|
||||
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
@@ -617,7 +650,7 @@ class SubscriptionService:
|
||||
)
|
||||
logger.info(message)
|
||||
if devices_price > 0:
|
||||
message = f" 📱 Устройства ({subscription.device_limit}): {discounted_devices_price/100}₽"
|
||||
message = f" 📱 Устройства ({device_limit}): {discounted_devices_price/100}₽"
|
||||
if devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
|
||||
@@ -894,7 +927,18 @@ class SubscriptionService:
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
|
||||
@@ -9,7 +9,13 @@ from app.database.universal_migration import ensure_default_web_api_token
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import Settings, settings, refresh_period_prices, refresh_traffic_prices
|
||||
from app.config import (
|
||||
Settings,
|
||||
settings,
|
||||
refresh_period_prices,
|
||||
refresh_traffic_prices,
|
||||
ENV_OVERRIDE_KEYS,
|
||||
)
|
||||
from app.database.crud.system_setting import (
|
||||
delete_system_setting,
|
||||
upsert_system_setting,
|
||||
@@ -71,6 +77,7 @@ class BotConfigurationService:
|
||||
"SUPPORT": "💬 Поддержка и тикеты",
|
||||
"LOCALIZATION": "🌍 Языки интерфейса",
|
||||
"CHANNEL": "📣 Обязательная подписка",
|
||||
"TIMEZONE": "🗂 Timezone",
|
||||
"PAYMENT": "💳 Общие платежные настройки",
|
||||
"PAYMENT_VERIFICATION": "🕵️ Проверка платежей",
|
||||
"TELEGRAM": "⭐ Telegram Stars",
|
||||
@@ -124,6 +131,7 @@ class BotConfigurationService:
|
||||
"SUPPORT": "Контакты поддержки, SLA и режимы обработки обращений.",
|
||||
"LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
|
||||
"CHANNEL": "Настройки обязательной подписки на канал или группу.",
|
||||
"TIMEZONE": "Часовой пояс панели и отображение времени.",
|
||||
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
|
||||
"PAYMENT_VERIFICATION": "Автоматическая проверка пополнений и интервал выполнения.",
|
||||
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
|
||||
@@ -195,6 +203,8 @@ class BotConfigurationService:
|
||||
"DEFAULT_TRAFFIC_LIMIT_GB": "SUBSCRIPTIONS_CORE",
|
||||
"MAX_DEVICES_LIMIT": "SUBSCRIPTIONS_CORE",
|
||||
"PRICE_PER_DEVICE": "SUBSCRIPTIONS_CORE",
|
||||
"DEVICES_SELECTION_ENABLED": "SUBSCRIPTIONS_CORE",
|
||||
"DEVICES_SELECTION_DISABLED_AMOUNT": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE",
|
||||
"DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC",
|
||||
"RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC",
|
||||
@@ -211,7 +221,6 @@ class BotConfigurationService:
|
||||
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
|
||||
"REFERRED_USER_REWARD": "REFERRAL",
|
||||
"DEFAULT_AUTOPAY_ENABLED": "AUTOPAY",
|
||||
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
|
||||
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
|
||||
@@ -261,6 +270,7 @@ class BotConfigurationService:
|
||||
"MAINTENANCE_MESSAGE": "MAINTENANCE",
|
||||
"MAINTENANCE_CHECK_INTERVAL": "MAINTENANCE",
|
||||
"MAINTENANCE_AUTO_ENABLE": "MAINTENANCE",
|
||||
"MAINTENANCE_RETRY_ATTEMPTS": "MAINTENANCE",
|
||||
"WEBHOOK_URL": "WEBHOOK",
|
||||
"WEBHOOK_SECRET": "WEBHOOK",
|
||||
"VERSION_CHECK_ENABLED": "VERSION",
|
||||
@@ -268,6 +278,7 @@ class BotConfigurationService:
|
||||
"VERSION_CHECK_INTERVAL_HOURS": "VERSION",
|
||||
"TELEGRAM_STARS_RATE_RUB": "TELEGRAM",
|
||||
"REMNAWAVE_USER_DESCRIPTION_TEMPLATE": "REMNAWAVE",
|
||||
"REMNAWAVE_USER_USERNAME_TEMPLATE": "REMNAWAVE",
|
||||
"REMNAWAVE_AUTO_SYNC_ENABLED": "REMNAWAVE",
|
||||
"REMNAWAVE_AUTO_SYNC_TIMES": "REMNAWAVE",
|
||||
}
|
||||
@@ -450,6 +461,21 @@ class BotConfigurationService:
|
||||
"example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba",
|
||||
"warning": "Убедитесь, что выбранный сквад активен и доступен для подписки.",
|
||||
},
|
||||
"DEVICES_SELECTION_ENABLED": {
|
||||
"description": "Разрешает пользователям выбирать количество устройств при покупке и продлении подписки.",
|
||||
"format": "Булево значение.",
|
||||
"example": "false",
|
||||
"warning": "При отключении пользователи не смогут докупать устройства из интерфейса бота.",
|
||||
},
|
||||
"DEVICES_SELECTION_DISABLED_AMOUNT": {
|
||||
"description": (
|
||||
"Лимит устройств, который автоматически назначается, когда выбор количества устройств выключен. "
|
||||
"Значение 0 отключает назначение устройств."
|
||||
),
|
||||
"format": "Целое число от 0 и выше.",
|
||||
"example": "3",
|
||||
"warning": "При 0 RemnaWave не получит лимит устройств, пользователям не показываются цифры в интерфейсе.",
|
||||
},
|
||||
"CRYPTOBOT_ENABLED": {
|
||||
"description": "Разрешает принимать криптоплатежи через CryptoBot.",
|
||||
"format": "Булево значение.",
|
||||
@@ -499,6 +525,28 @@ class BotConfigurationService:
|
||||
"warning": "Не забудьте отключить после завершения работ, иначе бот останется недоступен.",
|
||||
"dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL",
|
||||
},
|
||||
"MAINTENANCE_MONITORING_ENABLED": {
|
||||
"description": (
|
||||
"Управляет автоматическим запуском мониторинга панели Remnawave при старте бота."
|
||||
),
|
||||
"format": "Булево значение.",
|
||||
"example": "false",
|
||||
"warning": (
|
||||
"При отключении мониторинг можно запустить вручную из панели администратора."
|
||||
),
|
||||
"dependencies": "MAINTENANCE_CHECK_INTERVAL",
|
||||
},
|
||||
"MAINTENANCE_RETRY_ATTEMPTS": {
|
||||
"description": (
|
||||
"Сколько раз повторять проверку панели Remnawave перед фиксацией недоступности."
|
||||
),
|
||||
"format": "Целое число не меньше 1.",
|
||||
"example": "3",
|
||||
"warning": (
|
||||
"Большие значения увеличивают время реакции на реальные сбои, но помогают избежать ложных срабатываний."
|
||||
),
|
||||
"dependencies": "MAINTENANCE_CHECK_INTERVAL",
|
||||
},
|
||||
"DISPLAY_NAME_BANNED_KEYWORDS": {
|
||||
"description": (
|
||||
"Список слов и фрагментов, при наличии которых в отображаемом имени "
|
||||
@@ -536,6 +584,31 @@ class BotConfigurationService:
|
||||
),
|
||||
"dependencies": "REMNAWAVE_AUTO_SYNC_ENABLED",
|
||||
},
|
||||
"REMNAWAVE_USER_DESCRIPTION_TEMPLATE": {
|
||||
"description": (
|
||||
"Шаблон текста, который бот передает в поле Description при создании "
|
||||
"или обновлении пользователя в панели RemnaWave."
|
||||
),
|
||||
"format": (
|
||||
"Доступные плейсхолдеры: {full_name}, {username}, {username_clean}, {telegram_id}."
|
||||
),
|
||||
"example": "Bot user: {full_name} {username}",
|
||||
"warning": "Плейсхолдер {username} автоматически очищается, если у пользователя нет @username.",
|
||||
},
|
||||
"REMNAWAVE_USER_USERNAME_TEMPLATE": {
|
||||
"description": (
|
||||
"Шаблон имени пользователя, которое создаётся в панели RemnaWave для "
|
||||
"телеграм-пользователя."
|
||||
),
|
||||
"format": (
|
||||
"Доступные плейсхолдеры: {full_name}, {username}, {username_clean}, {telegram_id}."
|
||||
),
|
||||
"example": "vpn_{username_clean}_{telegram_id}",
|
||||
"warning": (
|
||||
"Недопустимые символы автоматически заменяются на подчёркивания. "
|
||||
"Если результат пустой, используется user_{telegram_id}."
|
||||
),
|
||||
},
|
||||
"EXTERNAL_ADMIN_TOKEN": {
|
||||
"description": "Приватный токен, который использует внешняя админка для проверки запросов.",
|
||||
"format": "Значение генерируется автоматически из username бота и его токена и доступно только для чтения.",
|
||||
@@ -566,6 +639,10 @@ class BotConfigurationService:
|
||||
def is_read_only(cls, key: str) -> bool:
|
||||
return key in cls.READ_ONLY_KEYS
|
||||
|
||||
@classmethod
|
||||
def _is_env_override(cls, key: str) -> bool:
|
||||
return key in cls._env_override_keys
|
||||
|
||||
@classmethod
|
||||
def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]:
|
||||
if isinstance(value, bool):
|
||||
@@ -679,6 +756,7 @@ class BotConfigurationService:
|
||||
_definitions: Dict[str, SettingDefinition] = {}
|
||||
_original_values: Dict[str, Any] = settings.model_dump()
|
||||
_overrides_raw: Dict[str, Optional[str]] = {}
|
||||
_env_override_keys: set[str] = set(ENV_OVERRIDE_KEYS)
|
||||
_callback_tokens: Dict[str, str] = {}
|
||||
_token_to_key: Dict[str, str] = {}
|
||||
_choice_tokens: Dict[str, Dict[Any, str]] = {}
|
||||
@@ -802,6 +880,8 @@ class BotConfigurationService:
|
||||
|
||||
@classmethod
|
||||
def has_override(cls, key: str) -> bool:
|
||||
if cls._is_env_override(key):
|
||||
return False
|
||||
return key in cls._overrides_raw
|
||||
|
||||
@classmethod
|
||||
@@ -1097,6 +1177,12 @@ class BotConfigurationService:
|
||||
overrides[row.key] = row.value
|
||||
|
||||
for key, raw_value in overrides.items():
|
||||
if cls._is_env_override(key):
|
||||
logger.debug(
|
||||
"Пропускаем настройку %s из БД: используется значение из окружения",
|
||||
key,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
parsed_value = cls.deserialize_value(key, raw_value)
|
||||
except Exception as error:
|
||||
@@ -1216,8 +1302,15 @@ class BotConfigurationService:
|
||||
|
||||
raw_value = cls.serialize_value(key, value)
|
||||
await upsert_system_setting(db, key, raw_value)
|
||||
cls._overrides_raw[key] = raw_value
|
||||
cls._apply_to_settings(key, value)
|
||||
if cls._is_env_override(key):
|
||||
logger.info(
|
||||
"Настройка %s сохранена в БД, но не применена: значение задаётся через окружение",
|
||||
key,
|
||||
)
|
||||
cls._overrides_raw.pop(key, None)
|
||||
else:
|
||||
cls._overrides_raw[key] = raw_value
|
||||
cls._apply_to_settings(key, value)
|
||||
|
||||
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
|
||||
await cls._sync_default_web_api_token()
|
||||
@@ -1235,14 +1328,26 @@ class BotConfigurationService:
|
||||
|
||||
await delete_system_setting(db, key)
|
||||
cls._overrides_raw.pop(key, None)
|
||||
original = cls.get_original_value(key)
|
||||
cls._apply_to_settings(key, original)
|
||||
if cls._is_env_override(key):
|
||||
logger.info(
|
||||
"Настройка %s сброшена в БД, используется значение из окружения",
|
||||
key,
|
||||
)
|
||||
else:
|
||||
original = cls.get_original_value(key)
|
||||
cls._apply_to_settings(key, original)
|
||||
|
||||
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
|
||||
await cls._sync_default_web_api_token()
|
||||
|
||||
@classmethod
|
||||
def _apply_to_settings(cls, key: str, value: Any) -> None:
|
||||
if cls._is_env_override(key):
|
||||
logger.debug(
|
||||
"Пропуск применения настройки %s: значение задано через окружение",
|
||||
key,
|
||||
)
|
||||
return
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
if key in {
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
get_users_count, get_users_statistics, get_inactive_users,
|
||||
add_user_balance, subtract_user_balance, update_user, delete_user,
|
||||
get_users_spending_stats
|
||||
get_users_spending_stats, get_referrals
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.transaction import get_user_transactions_count
|
||||
@@ -21,7 +21,7 @@ from app.database.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment,
|
||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment,
|
||||
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
|
||||
TransactionType
|
||||
)
|
||||
@@ -411,6 +411,72 @@ class UserService:
|
||||
logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}")
|
||||
return False, None, None, None
|
||||
|
||||
async def update_user_referrals(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
referral_user_ids: List[int],
|
||||
admin_id: int,
|
||||
) -> Tuple[bool, Dict[str, int]]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False, {"error": "user_not_found"}
|
||||
|
||||
unique_ids: List[int] = []
|
||||
for referral_id in referral_user_ids:
|
||||
if referral_id == user_id:
|
||||
continue
|
||||
if referral_id not in unique_ids:
|
||||
unique_ids.append(referral_id)
|
||||
|
||||
current_referrals = await get_referrals(db, user_id)
|
||||
current_ids = {ref.id for ref in current_referrals}
|
||||
|
||||
to_assign = unique_ids
|
||||
to_remove = [rid for rid in current_ids if rid not in unique_ids]
|
||||
to_add = [rid for rid in unique_ids if rid not in current_ids]
|
||||
|
||||
if to_assign:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id.in_(to_assign))
|
||||
.values(referred_by_id=user_id)
|
||||
)
|
||||
|
||||
if to_remove:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id.in_(to_remove))
|
||||
.values(referred_by_id=None)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Админ %s обновил рефералов пользователя %s: добавлено %s, удалено %s, всего %s",
|
||||
admin_id,
|
||||
user_id,
|
||||
len(to_add),
|
||||
len(to_remove),
|
||||
len(unique_ids),
|
||||
)
|
||||
|
||||
return True, {
|
||||
"added": len(to_add),
|
||||
"removed": len(to_remove),
|
||||
"total": len(unique_ids),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"Ошибка обновления рефералов пользователя %s: %s",
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
return False, {"error": "update_failed"}
|
||||
|
||||
async def block_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -713,6 +779,29 @@ class UserService:
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления Pal24 платежей: {e}")
|
||||
|
||||
try:
|
||||
heleket_result = await db.execute(
|
||||
select(HeleketPayment).where(HeleketPayment.user_id == user_id)
|
||||
)
|
||||
heleket_payments = heleket_result.scalars().all()
|
||||
|
||||
if heleket_payments:
|
||||
logger.info(
|
||||
f"🔄 Удаляем {len(heleket_payments)} Heleket платежей"
|
||||
)
|
||||
await db.execute(
|
||||
update(HeleketPayment)
|
||||
.where(HeleketPayment.user_id == user_id)
|
||||
.values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
delete(HeleketPayment).where(HeleketPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления Heleket платежей: {e}")
|
||||
|
||||
try:
|
||||
transactions_result = await db.execute(
|
||||
select(Transaction).where(Transaction.user_id == user_id)
|
||||
|
||||
@@ -90,6 +90,7 @@ class AdminStates(StatesGroup):
|
||||
editing_device_price = State()
|
||||
editing_user_devices = State()
|
||||
editing_user_traffic = State()
|
||||
editing_user_referrals = State()
|
||||
|
||||
editing_rules_page = State()
|
||||
editing_privacy_policy = State()
|
||||
|
||||
@@ -174,3 +174,58 @@ def convert_subscription_link_to_happ_scheme(subscription_link: Optional[str]) -
|
||||
return subscription_link
|
||||
|
||||
return urlunparse(parsed_link._replace(scheme="happ"))
|
||||
|
||||
|
||||
def resolve_hwid_device_limit(subscription: Optional[Subscription]) -> Optional[int]:
|
||||
"""Return a device limit value for RemnaWave payloads when selection is enabled."""
|
||||
|
||||
if subscription is None:
|
||||
return None
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
return forced_limit
|
||||
|
||||
limit = getattr(subscription, "device_limit", None)
|
||||
if limit is None or limit <= 0:
|
||||
return None
|
||||
|
||||
return limit
|
||||
|
||||
|
||||
def resolve_hwid_device_limit_for_payload(
|
||||
subscription: Optional[Subscription],
|
||||
) -> Optional[int]:
|
||||
"""Return the device limit that should be sent to RemnaWave APIs.
|
||||
|
||||
When device selection is disabled and no explicit override is configured,
|
||||
RemnaWave should continue receiving the subscription's stored limit so the
|
||||
external panel stays aligned with the bot configuration.
|
||||
"""
|
||||
|
||||
resolved_limit = resolve_hwid_device_limit(subscription)
|
||||
|
||||
if resolved_limit is not None:
|
||||
return resolved_limit
|
||||
|
||||
if subscription is None:
|
||||
return None
|
||||
|
||||
fallback_limit = getattr(subscription, "device_limit", None)
|
||||
if fallback_limit is None or fallback_limit <= 0:
|
||||
return None
|
||||
|
||||
return fallback_limit
|
||||
|
||||
|
||||
def resolve_simple_subscription_device_limit() -> int:
|
||||
"""Return the effective device limit for simple subscription flows."""
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
return int(getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", 0) or 0)
|
||||
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is not None:
|
||||
return forced_limit
|
||||
|
||||
return int(getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", 0) or 0)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Timezone utilities for consistent local time handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_local_timezone() -> ZoneInfo:
|
||||
"""Return the configured local timezone.
|
||||
|
||||
Falls back to UTC if the configured timezone cannot be loaded. The
|
||||
fallback is logged once and cached for subsequent calls.
|
||||
"""
|
||||
|
||||
tz_name = settings.TIMEZONE
|
||||
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception as exc: # pragma: no cover - defensive branch
|
||||
logger.warning(
|
||||
"⚠️ Не удалось загрузить временную зону '%s': %s. Используем UTC.",
|
||||
tz_name,
|
||||
exc,
|
||||
)
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def to_local_datetime(dt: Optional[datetime]) -> Optional[datetime]:
|
||||
"""Convert a datetime value to the configured local timezone."""
|
||||
|
||||
if dt is None:
|
||||
return None
|
||||
|
||||
aware_dt = dt if dt.tzinfo is not None else dt.replace(tzinfo=dt_timezone.utc)
|
||||
return aware_dt.astimezone(get_local_timezone())
|
||||
|
||||
|
||||
def format_local_datetime(
|
||||
dt: Optional[datetime],
|
||||
fmt: str = "%Y-%m-%d %H:%M:%S %Z",
|
||||
na_placeholder: str = "N/A",
|
||||
) -> str:
|
||||
"""Format a datetime value in the configured local timezone."""
|
||||
|
||||
localized = to_local_datetime(dt)
|
||||
if localized is None:
|
||||
return na_placeholder
|
||||
return localized.strftime(fmt)
|
||||
|
||||
|
||||
class TimezoneAwareFormatter(logging.Formatter):
|
||||
"""Logging formatter that renders timestamps in the configured timezone."""
|
||||
|
||||
def __init__(self, *args, timezone_name: Optional[str] = None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if timezone_name:
|
||||
try:
|
||||
self._timezone = ZoneInfo(timezone_name)
|
||||
except Exception as exc: # pragma: no cover - defensive branch
|
||||
logger.warning(
|
||||
"⚠️ Не удалось загрузить временную зону '%s': %s. Используем UTC.",
|
||||
timezone_name,
|
||||
exc,
|
||||
)
|
||||
self._timezone = ZoneInfo("UTC")
|
||||
else:
|
||||
self._timezone = get_local_timezone()
|
||||
|
||||
def formatTime(self, record, datefmt=None): # noqa: N802 - inherited method name
|
||||
dt = datetime.fromtimestamp(record.created, tz=self._timezone)
|
||||
if datefmt:
|
||||
return dt.strftime(datefmt)
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S,%f")[:-3]
|
||||
+20
-9
@@ -20,15 +20,26 @@ def format_referrer_info(user: User) -> str:
|
||||
if not referred_by_id:
|
||||
return "Нет"
|
||||
|
||||
referrer = getattr(user, "referrer", None)
|
||||
|
||||
if not referrer:
|
||||
return f"ID {referred_by_id} (не найден)"
|
||||
|
||||
if referrer.username:
|
||||
return f"@{referrer.username} (ID: {referred_by_id})"
|
||||
|
||||
return f"ID {referrer.telegram_id}"
|
||||
try:
|
||||
# Проверяем, является ли referrer обычным объектом или InstrumentedList
|
||||
referrer = getattr(user, "referrer", None)
|
||||
|
||||
# Если referrer это InstrumentedList или None, то возвращаем информацию по ID
|
||||
if referrer is None:
|
||||
return f"ID {referred_by_id} (не найден)"
|
||||
|
||||
# Пытаемся получить атрибуты referrer, если они доступны
|
||||
referrer_username = getattr(referrer, "username", None)
|
||||
referrer_telegram_id = getattr(referrer, "telegram_id", None)
|
||||
|
||||
if referrer_username:
|
||||
return f"@{referrer_username} (ID: {referred_by_id})"
|
||||
|
||||
return f"ID {referrer_telegram_id or referred_by_id}"
|
||||
|
||||
except (AttributeError, TypeError):
|
||||
# Если возникла ошибка при обращении к атрибутам, просто возвращаем ID
|
||||
return f"ID {referred_by_id} (ошибка загрузки)"
|
||||
|
||||
|
||||
async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> str:
|
||||
|
||||
@@ -58,6 +58,7 @@ from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.faq_service import FaqService
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.services.remnawave_service import (
|
||||
RemnaWaveConfigurationError,
|
||||
RemnaWaveService,
|
||||
@@ -2408,23 +2409,6 @@ async def _build_referral_info(
|
||||
inviter_bonus_kopeks = int(referral_settings.get("inviter_bonus_kopeks") or 0)
|
||||
commission_percent = float(referral_settings.get("commission_percent") or 0)
|
||||
|
||||
referred_user_reward_kopeks = settings.get_referred_user_reward_kopeks()
|
||||
for key in ("referred_user_reward_kopeks", "referred_user_reward"):
|
||||
candidate = referral_settings.get(key)
|
||||
if candidate is None:
|
||||
continue
|
||||
try:
|
||||
value = int(candidate)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if value <= 0:
|
||||
referred_user_reward_kopeks = 0
|
||||
break
|
||||
if key == "referred_user_reward" and value < 1000:
|
||||
value *= 100
|
||||
referred_user_reward_kopeks = value
|
||||
break
|
||||
|
||||
terms = MiniAppReferralTerms(
|
||||
minimum_topup_kopeks=minimum_topup_kopeks,
|
||||
minimum_topup_label=settings.format_price(minimum_topup_kopeks),
|
||||
@@ -2433,8 +2417,6 @@ async def _build_referral_info(
|
||||
inviter_bonus_kopeks=inviter_bonus_kopeks,
|
||||
inviter_bonus_label=settings.format_price(inviter_bonus_kopeks),
|
||||
commission_percent=commission_percent,
|
||||
referred_user_reward_kopeks=referred_user_reward_kopeks,
|
||||
referred_user_reward_label=settings.format_price(referred_user_reward_kopeks),
|
||||
)
|
||||
|
||||
summary = await get_user_referral_summary(db, user.id)
|
||||
@@ -3121,8 +3103,16 @@ async def activate_subscription_trial_endpoint(
|
||||
},
|
||||
)
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
try:
|
||||
subscription = await create_trial_subscription(db, user.id)
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
user.id,
|
||||
device_limit=forced_devices,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Failed to activate trial subscription for user %s: %s",
|
||||
@@ -3638,7 +3628,9 @@ async def _calculate_subscription_renewal_pricing(
|
||||
if traffic_limit is None:
|
||||
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
|
||||
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
devices_limit = subscription.device_limit
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
total_cost, details = await calculate_subscription_total_cost(
|
||||
db,
|
||||
@@ -4437,7 +4429,7 @@ async def submit_subscription_renewal_endpoint(
|
||||
language_code = _normalize_language_code(user)
|
||||
amount_label = settings.format_price(final_total)
|
||||
date_label = (
|
||||
subscription.end_date.strftime("%d.%m.%Y %H:%M")
|
||||
format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")
|
||||
if subscription.end_date
|
||||
else ""
|
||||
)
|
||||
@@ -5044,7 +5036,12 @@ async def update_subscription_devices_endpoint(
|
||||
},
|
||||
)
|
||||
|
||||
current_devices = int(subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT or 1)
|
||||
current_devices_value = subscription.device_limit
|
||||
if current_devices_value is None:
|
||||
fallback_value = settings.DEFAULT_DEVICE_LIMIT or 1
|
||||
current_devices_value = fallback_value
|
||||
|
||||
current_devices = int(current_devices_value)
|
||||
old_devices = current_devices
|
||||
|
||||
if new_devices == current_devices:
|
||||
|
||||
@@ -52,12 +52,12 @@ def _serialize(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse:
|
||||
apply_discounts_to_addons=group.apply_discounts_to_addons,
|
||||
is_default=group.is_default,
|
||||
members_count=members_count,
|
||||
created_at=group.created_at,
|
||||
updated_at=group.updated_at,
|
||||
created_at=getattr(group, "created_at", None),
|
||||
updated_at=getattr(group, "updated_at", None),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=PromoGroupListResponse)
|
||||
@router.get("", response_model=PromoGroupListResponse, response_model_exclude_none=True)
|
||||
async def list_promo_groups(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
@@ -79,7 +79,7 @@ async def list_promo_groups(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{group_id}", response_model=PromoGroupResponse)
|
||||
@router.get("/{group_id}", response_model=PromoGroupResponse, response_model_exclude_none=True)
|
||||
async def get_promo_group(
|
||||
group_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
@@ -93,7 +93,12 @@ async def get_promo_group(
|
||||
return _serialize(group, members_count=members_count)
|
||||
|
||||
|
||||
@router.post("", response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
@router.post(
|
||||
"",
|
||||
response_model=PromoGroupResponse,
|
||||
response_model_exclude_none=True,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_promo_group_endpoint(
|
||||
payload: PromoGroupCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
@@ -120,7 +125,11 @@ async def create_promo_group_endpoint(
|
||||
return _serialize(group, members_count=0)
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=PromoGroupResponse)
|
||||
@router.patch(
|
||||
"/{group_id}",
|
||||
response_model=PromoGroupResponse,
|
||||
response_model_exclude_none=True,
|
||||
)
|
||||
async def update_promo_group_endpoint(
|
||||
group_id: int,
|
||||
payload: PromoGroupUpdateRequest,
|
||||
|
||||
@@ -112,24 +112,38 @@ async def create_subscription(
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription")
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
if payload.is_trial:
|
||||
trial_device_limit = payload.device_limit
|
||||
if trial_device_limit is None:
|
||||
trial_device_limit = forced_devices
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
duration_days=payload.duration_days,
|
||||
traffic_limit_gb=payload.traffic_limit_gb,
|
||||
device_limit=payload.device_limit,
|
||||
device_limit=trial_device_limit,
|
||||
squad_uuid=payload.squad_uuid,
|
||||
)
|
||||
else:
|
||||
if payload.duration_days is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions")
|
||||
device_limit = payload.device_limit
|
||||
if device_limit is None:
|
||||
if forced_devices is not None:
|
||||
device_limit = forced_devices
|
||||
else:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
subscription = await create_paid_subscription(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
duration_days=payload.duration_days,
|
||||
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
device_limit=device_limit,
|
||||
connected_squads=payload.connected_squads or [],
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
@@ -296,8 +296,6 @@ class MiniAppReferralTerms(BaseModel):
|
||||
inviter_bonus_kopeks: int = 0
|
||||
inviter_bonus_label: Optional[str] = None
|
||||
commission_percent: float = 0.0
|
||||
referred_user_reward_kopeks: int = 0
|
||||
referred_user_reward_label: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppReferralStats(BaseModel):
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, validator
|
||||
|
||||
|
||||
def _normalize_period_discounts(value: Optional[Dict[object, object]]) -> Optional[Dict[int, int]]:
|
||||
@@ -23,6 +23,8 @@ def _normalize_period_discounts(value: Optional[Dict[object, object]]) -> Option
|
||||
|
||||
|
||||
class PromoGroupResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
server_discount_percent: int
|
||||
@@ -33,8 +35,8 @@ class PromoGroupResponse(BaseModel):
|
||||
apply_discounts_to_addons: bool
|
||||
is_default: bool
|
||||
members_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class _PromoGroupBase(BaseModel):
|
||||
|
||||
@@ -36,6 +36,7 @@ from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.external_admin_service import ensure_external_admin_token
|
||||
from app.services.broadcast_service import broadcast_service
|
||||
from app.utils.startup_timeline import StartupTimeline
|
||||
from app.utils.timezone import TimezoneAwareFormatter
|
||||
|
||||
|
||||
class GracefulExit:
|
||||
@@ -49,13 +50,20 @@ class GracefulExit:
|
||||
|
||||
|
||||
async def main():
|
||||
formatter = TimezoneAwareFormatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
timezone_name=settings.TIMEZONE,
|
||||
)
|
||||
|
||||
file_handler = logging.FileHandler(settings.LOG_FILE, encoding='utf-8')
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
stream_handler = logging.StreamHandler(sys.stdout)
|
||||
stream_handler.setFormatter(formatter)
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.LOG_LEVEL),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(settings.LOG_FILE, encoding='utf-8'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
handlers=[file_handler, stream_handler],
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -403,9 +411,15 @@ async def main():
|
||||
"🛡️",
|
||||
success_message="Служба техработ запущена",
|
||||
) as stage:
|
||||
if not maintenance_service._check_task or maintenance_service._check_task.done():
|
||||
if not settings.is_maintenance_monitoring_enabled():
|
||||
maintenance_task = None
|
||||
stage.skip("Мониторинг техработ отключен настройками")
|
||||
elif not maintenance_service._check_task or maintenance_service._check_task.done():
|
||||
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
|
||||
stage.log(f"Интервал проверки: {settings.MAINTENANCE_CHECK_INTERVAL}с")
|
||||
stage.log(
|
||||
f"Повторных попыток проверки: {settings.get_maintenance_retry_attempts()}"
|
||||
)
|
||||
else:
|
||||
maintenance_task = None
|
||||
stage.skip("Служба техработ уже активна")
|
||||
|
||||
@@ -42,6 +42,9 @@ class StubPal24Client:
|
||||
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
|
||||
return {"id": payment_id, "status": "SUCCESS"}
|
||||
|
||||
async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]:
|
||||
return {"id": bill_id, "payments": [{"id": "PAY-1"}]}
|
||||
|
||||
|
||||
def _enable_pal24(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(type(settings), "is_pal24_enabled", lambda self: True, raising=False)
|
||||
@@ -96,6 +99,17 @@ async def test_create_bill_requires_configuration(monkeypatch: pytest.MonkeyPatc
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_get_bill_payments(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_pal24(monkeypatch)
|
||||
client = StubPal24Client()
|
||||
service = Pal24Service(client)
|
||||
|
||||
result = await service.get_bill_payments("BILL42")
|
||||
|
||||
assert result == {"id": "BILL42", "payments": [{"id": "PAY-1"}]}
|
||||
|
||||
|
||||
def test_parse_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_pal24(monkeypatch)
|
||||
sig = Pal24Client.calculate_signature("100.00", "INV1", api_token="sigsecret")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
@@ -25,6 +26,9 @@ class DummySession:
|
||||
async def commit(self) -> None: # pragma: no cover - метод вызывается, но без логики
|
||||
return None
|
||||
|
||||
async def refresh(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class DummyLocalPayment:
|
||||
def __init__(self, payment_id: int = 501) -> None:
|
||||
@@ -139,3 +143,163 @@ async def test_create_mulenpay_payment_returns_none_without_service() -> None:
|
||||
description="Пополнение",
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_mulenpay_callback_avoids_duplicate_transactions(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = _make_service(None)
|
||||
db = DummySession()
|
||||
|
||||
class DummyPayment:
|
||||
def __init__(self) -> None:
|
||||
self.user_id = 42
|
||||
self.amount_kopeks = 1500
|
||||
self.description = "Пополнение"
|
||||
self.uuid = "mulen_1_test"
|
||||
self.transaction_id: Optional[int] = None
|
||||
self.mulen_payment_id: Optional[int] = None
|
||||
self.status = "created"
|
||||
self.is_paid = False
|
||||
|
||||
payment = DummyPayment()
|
||||
|
||||
async def fake_get_mulenpay_payment_by_uuid(
|
||||
_db: DummySession, uuid: str
|
||||
) -> DummyPayment:
|
||||
assert uuid == payment.uuid
|
||||
return payment
|
||||
|
||||
async def fake_update_mulenpay_payment_status(
|
||||
_db: DummySession, **kwargs: Any
|
||||
) -> DummyPayment:
|
||||
payment.status = kwargs.get("status", payment.status)
|
||||
payment.mulen_payment_id = kwargs.get("mulen_payment_id", payment.mulen_payment_id)
|
||||
return payment
|
||||
|
||||
transaction_calls: list[Dict[str, Any]] = []
|
||||
|
||||
class DummyTransaction:
|
||||
def __init__(self, transaction_id: int = 555) -> None:
|
||||
self.id = transaction_id
|
||||
|
||||
async def fake_create_transaction(_db: DummySession, **kwargs: Any) -> DummyTransaction:
|
||||
transaction_calls.append(kwargs)
|
||||
return DummyTransaction()
|
||||
|
||||
async def fake_link_payment(
|
||||
db: DummySession, *, payment: DummyPayment, transaction_id: int
|
||||
) -> DummyPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
return payment
|
||||
|
||||
class DummyUser:
|
||||
def __init__(self) -> None:
|
||||
self.id = payment.user_id
|
||||
self.telegram_id = 99
|
||||
self.balance_kopeks = 0
|
||||
self.has_made_first_topup = False
|
||||
self.language = "ru"
|
||||
self.promo_group = None
|
||||
self.subscription = None
|
||||
|
||||
dummy_user = DummyUser()
|
||||
|
||||
async def fake_get_user_by_id(_db: DummySession, user_id: int) -> DummyUser:
|
||||
assert user_id == payment.user_id
|
||||
return dummy_user
|
||||
|
||||
balance_call: Dict[str, Any] = {}
|
||||
|
||||
async def fake_add_user_balance(
|
||||
_db: DummySession,
|
||||
user: DummyUser,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
*,
|
||||
create_transaction: bool = True,
|
||||
**_kwargs: Any,
|
||||
) -> bool:
|
||||
balance_call.update(
|
||||
{
|
||||
"create_transaction": create_transaction,
|
||||
"description": description,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
}
|
||||
)
|
||||
user.balance_kopeks += amount_kopeks
|
||||
return True
|
||||
|
||||
async def fake_process_referral_topup(*_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
async def fake_auto_purchase_saved_cart_after_topup(*_args: Any, **_kwargs: Any) -> bool:
|
||||
return False
|
||||
|
||||
async def fake_has_user_cart(*_args: Any, **_kwargs: Any) -> bool:
|
||||
return False
|
||||
|
||||
referral_module = ModuleType("app.services.referral_service")
|
||||
referral_module.process_referral_topup = fake_process_referral_topup # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_module)
|
||||
|
||||
auto_module = ModuleType("app.services.subscription_auto_purchase_service")
|
||||
auto_module.auto_purchase_saved_cart_after_topup = ( # type: ignore[attr-defined]
|
||||
fake_auto_purchase_saved_cart_after_topup
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "app.services.subscription_auto_purchase_service", auto_module)
|
||||
|
||||
user_cart_module = ModuleType("app.services.user_cart_service")
|
||||
user_cart_module.user_cart_service = SimpleNamespace( # type: ignore[attr-defined]
|
||||
has_user_cart=fake_has_user_cart
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_module)
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_mulenpay_payment_by_uuid",
|
||||
fake_get_mulenpay_payment_by_uuid,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"update_mulenpay_payment_status",
|
||||
fake_update_mulenpay_payment_status,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"create_transaction",
|
||||
fake_create_transaction,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"link_mulenpay_payment_to_transaction",
|
||||
fake_link_payment,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_user_by_id",
|
||||
fake_get_user_by_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"add_user_balance",
|
||||
fake_add_user_balance,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = await service.process_mulenpay_callback(
|
||||
db,
|
||||
{"uuid": payment.uuid, "payment_status": "success", "id": 123, "amount": 1500},
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert transaction_calls, "create_transaction should be called"
|
||||
assert balance_call["create_transaction"] is False
|
||||
assert dummy_user.balance_kopeks == payment.amount_kopeks
|
||||
assert payment.transaction_id is not None
|
||||
|
||||
@@ -30,11 +30,81 @@ class DummyBot:
|
||||
self.sent_messages.append({"args": args, "kwargs": kwargs})
|
||||
|
||||
|
||||
class FakeScalarResult:
|
||||
def __init__(self, items: list[Any]) -> None:
|
||||
self._items = list(items)
|
||||
|
||||
def all(self) -> list[Any]: # pragma: no cover - утилитарный метод
|
||||
return list(self._items)
|
||||
|
||||
def first(self) -> Any: # pragma: no cover - утилитарный метод
|
||||
return self._items[0] if self._items else None
|
||||
|
||||
def one(self) -> Any: # pragma: no cover - утилитарный метод
|
||||
if len(self._items) != 1:
|
||||
raise ValueError("Expected exactly one result")
|
||||
return self._items[0]
|
||||
|
||||
def one_or_none(self) -> Any: # pragma: no cover - утилитарный метод
|
||||
if not self._items:
|
||||
return None
|
||||
if len(self._items) > 1:
|
||||
raise ValueError("Expected zero or one result")
|
||||
return self._items[0]
|
||||
|
||||
def __iter__(self): # pragma: no cover - утилитарный метод
|
||||
return iter(self._items)
|
||||
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, value: Any = None) -> None:
|
||||
self._value = value
|
||||
|
||||
def _as_iterable(self) -> list[Any]:
|
||||
if isinstance(self._value, list):
|
||||
return self._value
|
||||
if self._value is None:
|
||||
return []
|
||||
return [self._value]
|
||||
|
||||
def scalar(self) -> Any:
|
||||
items = self._as_iterable()
|
||||
return items[0] if items else None
|
||||
|
||||
def scalar_one_or_none(self) -> Any:
|
||||
items = self._as_iterable()
|
||||
if not items:
|
||||
return None
|
||||
if len(items) > 1:
|
||||
raise ValueError("Expected zero or one result")
|
||||
return items[0]
|
||||
|
||||
def first(self) -> Any: # pragma: no cover - утилитарный метод
|
||||
items = self._as_iterable()
|
||||
return items[0] if items else None
|
||||
|
||||
def all(self) -> list[Any]: # pragma: no cover - утилитарный метод
|
||||
return list(self._as_iterable())
|
||||
|
||||
def one_or_none(self) -> Any: # pragma: no cover - утилитарный метод
|
||||
items = self._as_iterable()
|
||||
if not items:
|
||||
return None
|
||||
if len(items) > 1:
|
||||
raise ValueError("Expected zero or one result")
|
||||
return items[0]
|
||||
|
||||
def scalars(self) -> FakeScalarResult: # pragma: no cover - утилитарный метод
|
||||
return FakeScalarResult(self._as_iterable())
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.commits = 0
|
||||
self.refreshed: list[Any] = []
|
||||
self.added: list[Any] = []
|
||||
self.execute_statements: list[Any] = []
|
||||
self.execute_results: list[Any] = []
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
@@ -48,6 +118,20 @@ class FakeSession:
|
||||
def add(self, obj: Any) -> None: # pragma: no cover - используется при создании транзакций
|
||||
self.added.append(obj)
|
||||
|
||||
async def execute(self, statement: Any, *args: Any, **kwargs: Any) -> FakeResult:
|
||||
self.execute_statements.append(statement)
|
||||
if self.execute_results:
|
||||
result = self.execute_results.pop(0)
|
||||
if callable(result): # pragma: no cover - гибкость для будущих тестов
|
||||
result = result(statement, *args, **kwargs)
|
||||
else:
|
||||
result = None
|
||||
|
||||
if isinstance(result, FakeResult):
|
||||
return result
|
||||
|
||||
return FakeResult(result)
|
||||
|
||||
|
||||
def _make_service(bot: DummyBot) -> PaymentService:
|
||||
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from app.services import referral_service # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commission_accrues_before_minimum_first_topup(monkeypatch):
|
||||
user = SimpleNamespace(
|
||||
id=1,
|
||||
telegram_id=101,
|
||||
full_name="Test User",
|
||||
referred_by_id=2,
|
||||
has_made_first_topup=False,
|
||||
)
|
||||
referrer = SimpleNamespace(
|
||||
id=2,
|
||||
telegram_id=202,
|
||||
full_name="Referrer",
|
||||
)
|
||||
|
||||
db = SimpleNamespace(
|
||||
commit=AsyncMock(),
|
||||
execute=AsyncMock(),
|
||||
)
|
||||
|
||||
get_user_mock = AsyncMock(side_effect=[user, referrer])
|
||||
monkeypatch.setattr(referral_service, "get_user_by_id", get_user_mock)
|
||||
add_user_balance_mock = AsyncMock()
|
||||
monkeypatch.setattr(referral_service, "add_user_balance", add_user_balance_mock)
|
||||
create_referral_earning_mock = AsyncMock()
|
||||
monkeypatch.setattr(referral_service, "create_referral_earning", create_referral_earning_mock)
|
||||
|
||||
monkeypatch.setattr(referral_service.settings, "REFERRAL_MINIMUM_TOPUP_KOPEKS", 20000)
|
||||
monkeypatch.setattr(referral_service.settings, "REFERRAL_FIRST_TOPUP_BONUS_KOPEKS", 5000)
|
||||
monkeypatch.setattr(referral_service.settings, "REFERRAL_INVITER_BONUS_KOPEKS", 10000)
|
||||
monkeypatch.setattr(referral_service.settings, "REFERRAL_COMMISSION_PERCENT", 25)
|
||||
|
||||
topup_amount = 15000
|
||||
|
||||
result = await referral_service.process_referral_topup(db, user.id, topup_amount)
|
||||
|
||||
assert result is True
|
||||
assert user.has_made_first_topup is False
|
||||
|
||||
add_user_balance_mock.assert_awaited_once()
|
||||
add_call = add_user_balance_mock.await_args
|
||||
assert add_call.args[1] is referrer
|
||||
assert add_call.args[2] == 3750
|
||||
assert "Комиссия" in add_call.args[3]
|
||||
assert add_call.kwargs.get("bot") is None
|
||||
|
||||
create_referral_earning_mock.assert_awaited_once()
|
||||
earning_call = create_referral_earning_mock.await_args
|
||||
assert earning_call.kwargs["amount_kopeks"] == 3750
|
||||
assert earning_call.kwargs["reason"] == "referral_commission_topup"
|
||||
|
||||
db.commit.assert_not_awaited()
|
||||
db.execute.assert_not_awaited()
|
||||
@@ -0,0 +1,163 @@
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from app.config import settings
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_override_prevents_set_value(monkeypatch):
|
||||
bot_configuration_service.initialize_definitions()
|
||||
|
||||
env_value = "env_support"
|
||||
monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value)
|
||||
original_values = dict(bot_configuration_service._original_values)
|
||||
original_values["SUPPORT_USERNAME"] = env_value
|
||||
monkeypatch.setattr(bot_configuration_service, "_original_values", original_values)
|
||||
|
||||
env_keys = set(bot_configuration_service._env_override_keys)
|
||||
env_keys.add("SUPPORT_USERNAME")
|
||||
monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys)
|
||||
monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {})
|
||||
|
||||
async def fake_upsert(db, key, value, description=None): # noqa: ANN001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.system_settings_service.upsert_system_setting",
|
||||
fake_upsert,
|
||||
)
|
||||
|
||||
await bot_configuration_service.set_value(
|
||||
object(),
|
||||
"SUPPORT_USERNAME",
|
||||
"db_support",
|
||||
)
|
||||
|
||||
assert settings.SUPPORT_USERNAME == env_value
|
||||
assert not bot_configuration_service.has_override("SUPPORT_USERNAME")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_override_prevents_reset_value(monkeypatch):
|
||||
bot_configuration_service.initialize_definitions()
|
||||
|
||||
env_value = "env_support"
|
||||
monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value)
|
||||
original_values = dict(bot_configuration_service._original_values)
|
||||
original_values["SUPPORT_USERNAME"] = env_value
|
||||
monkeypatch.setattr(bot_configuration_service, "_original_values", original_values)
|
||||
|
||||
env_keys = set(bot_configuration_service._env_override_keys)
|
||||
env_keys.add("SUPPORT_USERNAME")
|
||||
monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys)
|
||||
monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {"SUPPORT_USERNAME": "db"})
|
||||
|
||||
async def fake_delete(db, key): # noqa: ANN001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.system_settings_service.delete_system_setting",
|
||||
fake_delete,
|
||||
)
|
||||
|
||||
await bot_configuration_service.reset_value(
|
||||
object(),
|
||||
"SUPPORT_USERNAME",
|
||||
)
|
||||
|
||||
assert settings.SUPPORT_USERNAME == env_value
|
||||
assert not bot_configuration_service.has_override("SUPPORT_USERNAME")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_skips_db_value_for_env_override(monkeypatch):
|
||||
bot_configuration_service.initialize_definitions()
|
||||
|
||||
env_value = "env_support"
|
||||
monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value)
|
||||
original_values = dict(bot_configuration_service._original_values)
|
||||
original_values["SUPPORT_USERNAME"] = env_value
|
||||
monkeypatch.setattr(bot_configuration_service, "_original_values", original_values)
|
||||
|
||||
env_keys = set(bot_configuration_service._env_override_keys)
|
||||
env_keys.add("SUPPORT_USERNAME")
|
||||
monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys)
|
||||
monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {})
|
||||
|
||||
class DummyResult:
|
||||
def scalars(self):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return [SimpleNamespace(key="SUPPORT_USERNAME", value="db_support")]
|
||||
|
||||
class DummySession:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb): # noqa: ANN001
|
||||
return False
|
||||
|
||||
async def execute(self, query): # noqa: ANN001
|
||||
return DummyResult()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.system_settings_service.AsyncSessionLocal",
|
||||
lambda: DummySession(),
|
||||
)
|
||||
|
||||
async def fake_sync():
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.system_settings_service.ensure_default_web_api_token",
|
||||
fake_sync,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
await bot_configuration_service.initialize()
|
||||
|
||||
assert settings.SUPPORT_USERNAME == env_value
|
||||
assert "SUPPORT_USERNAME" not in bot_configuration_service._overrides_raw
|
||||
assert not bot_configuration_service.has_override("SUPPORT_USERNAME")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_value_applies_without_env_override(monkeypatch):
|
||||
bot_configuration_service.initialize_definitions()
|
||||
|
||||
monkeypatch.setattr(bot_configuration_service, "_env_override_keys", set())
|
||||
monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {})
|
||||
|
||||
initial_value = True
|
||||
target_value = False
|
||||
|
||||
monkeypatch.setattr(settings, "SUPPORT_MENU_ENABLED", initial_value)
|
||||
original_values = dict(bot_configuration_service._original_values)
|
||||
original_values["SUPPORT_MENU_ENABLED"] = initial_value
|
||||
monkeypatch.setattr(bot_configuration_service, "_original_values", original_values)
|
||||
|
||||
async def fake_upsert(db, key, value, description=None): # noqa: ANN001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.system_settings_service.upsert_system_setting",
|
||||
fake_upsert,
|
||||
)
|
||||
|
||||
await bot_configuration_service.set_value(
|
||||
object(),
|
||||
"SUPPORT_MENU_ENABLED",
|
||||
target_value,
|
||||
)
|
||||
|
||||
assert settings.SUPPORT_MENU_ENABLED is target_value
|
||||
assert bot_configuration_service.has_override("SUPPORT_MENU_ENABLED")
|
||||
@@ -0,0 +1,167 @@
|
||||
import pytest
|
||||
|
||||
from app.utils import subscription_utils
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit,
|
||||
resolve_simple_subscription_device_limit,
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
)
|
||||
|
||||
|
||||
class DummySubscription:
|
||||
def __init__(self, device_limit=None):
|
||||
self.device_limit = device_limit
|
||||
|
||||
|
||||
class StubSettings:
|
||||
def __init__(
|
||||
self,
|
||||
enabled: bool,
|
||||
disabled_amount,
|
||||
*,
|
||||
simple_limit: int = 3,
|
||||
disabled_selection_amount=None,
|
||||
):
|
||||
self._enabled = enabled
|
||||
self._disabled_amount = disabled_amount
|
||||
self._disabled_selection_amount = disabled_selection_amount
|
||||
self.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT = simple_limit
|
||||
|
||||
def is_devices_selection_enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def get_disabled_mode_device_limit(self):
|
||||
return self._disabled_amount
|
||||
|
||||
def get_devices_selection_disabled_amount(self):
|
||||
return self._disabled_selection_amount
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"forced_amount, expected",
|
||||
[
|
||||
(None, None),
|
||||
(0, 0),
|
||||
(5, 5),
|
||||
],
|
||||
)
|
||||
def test_resolve_hwid_device_limit_disabled_mode(monkeypatch, forced_amount, expected):
|
||||
subscription = DummySubscription(device_limit=42)
|
||||
|
||||
monkeypatch.setattr(
|
||||
subscription_utils,
|
||||
"settings",
|
||||
StubSettings(
|
||||
enabled=False,
|
||||
disabled_amount=forced_amount,
|
||||
disabled_selection_amount=forced_amount,
|
||||
),
|
||||
)
|
||||
|
||||
assert resolve_hwid_device_limit(subscription) == expected
|
||||
|
||||
|
||||
def test_resolve_hwid_device_limit_enabled_mode(monkeypatch):
|
||||
subscription = DummySubscription(device_limit=4)
|
||||
|
||||
monkeypatch.setattr(
|
||||
subscription_utils,
|
||||
"settings",
|
||||
StubSettings(enabled=True, disabled_amount=None),
|
||||
)
|
||||
|
||||
assert resolve_hwid_device_limit(subscription) == 4
|
||||
|
||||
|
||||
def test_resolve_hwid_device_limit_enabled_ignores_non_positive(monkeypatch):
|
||||
subscription = DummySubscription(device_limit=0)
|
||||
|
||||
monkeypatch.setattr(
|
||||
subscription_utils,
|
||||
"settings",
|
||||
StubSettings(enabled=True, disabled_amount=None),
|
||||
)
|
||||
|
||||
assert resolve_hwid_device_limit(subscription) is None
|
||||
|
||||
|
||||
def test_resolve_hwid_device_limit_for_payload_returns_subscription_limit(monkeypatch):
|
||||
subscription = DummySubscription(device_limit=42)
|
||||
|
||||
monkeypatch.setattr(
|
||||
subscription_utils,
|
||||
"settings",
|
||||
StubSettings(enabled=False, disabled_amount=None, disabled_selection_amount=None),
|
||||
)
|
||||
|
||||
assert resolve_hwid_device_limit(subscription) is None
|
||||
assert resolve_hwid_device_limit_for_payload(subscription) == 42
|
||||
|
||||
|
||||
def test_resolve_hwid_device_limit_for_payload_ignores_non_positive(monkeypatch):
|
||||
subscription = DummySubscription(device_limit=0)
|
||||
|
||||
monkeypatch.setattr(
|
||||
subscription_utils,
|
||||
"settings",
|
||||
StubSettings(enabled=False, disabled_amount=None, disabled_selection_amount=None),
|
||||
)
|
||||
|
||||
assert resolve_hwid_device_limit(subscription) is None
|
||||
assert resolve_hwid_device_limit_for_payload(subscription) is None
|
||||
|
||||
|
||||
def test_resolve_hwid_device_limit_for_payload_prefers_forced_limit(monkeypatch):
|
||||
subscription = DummySubscription(device_limit=42)
|
||||
|
||||
monkeypatch.setattr(
|
||||
subscription_utils,
|
||||
"settings",
|
||||
StubSettings(enabled=False, disabled_amount=7, disabled_selection_amount=7),
|
||||
)
|
||||
|
||||
assert resolve_hwid_device_limit_for_payload(subscription) == 7
|
||||
|
||||
|
||||
def test_resolve_hwid_device_limit_for_payload_handles_zero(monkeypatch):
|
||||
subscription = DummySubscription(device_limit=42)
|
||||
|
||||
monkeypatch.setattr(
|
||||
subscription_utils,
|
||||
"settings",
|
||||
StubSettings(enabled=False, disabled_amount=0, disabled_selection_amount=0),
|
||||
)
|
||||
|
||||
assert resolve_hwid_device_limit(subscription) == 0
|
||||
assert resolve_hwid_device_limit_for_payload(subscription) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"enabled, simple_limit, disabled_amount, disabled_selection_amount, expected",
|
||||
[
|
||||
(True, 4, None, None, 4),
|
||||
(False, 4, None, None, 4),
|
||||
(False, 4, 0, 0, 0),
|
||||
(False, 4, 7, 7, 7),
|
||||
],
|
||||
)
|
||||
def test_resolve_simple_subscription_device_limit(
|
||||
monkeypatch,
|
||||
enabled,
|
||||
simple_limit,
|
||||
disabled_amount,
|
||||
disabled_selection_amount,
|
||||
expected,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
subscription_utils,
|
||||
"settings",
|
||||
StubSettings(
|
||||
enabled=enabled,
|
||||
disabled_amount=disabled_amount,
|
||||
simple_limit=simple_limit,
|
||||
disabled_selection_amount=disabled_selection_amount,
|
||||
),
|
||||
)
|
||||
|
||||
assert resolve_simple_subscription_device_limit() == expected
|
||||
@@ -142,6 +142,80 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc
|
||||
# В успешном сценарии вызывается callback.answer()
|
||||
mock_callback_query.answer.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_to_saved_cart_normalizes_devices_when_disabled(
|
||||
mock_callback_query,
|
||||
mock_state,
|
||||
mock_user,
|
||||
mock_db,
|
||||
):
|
||||
cart_data = {
|
||||
'period_days': 30,
|
||||
'countries': ['ru', 'us'],
|
||||
'devices': 5,
|
||||
'traffic_gb': 20,
|
||||
'total_price': 45000,
|
||||
'total_devices_price': 15000,
|
||||
'saved_cart': True,
|
||||
'user_id': mock_user.id,
|
||||
}
|
||||
|
||||
sanitized_summary_data = {
|
||||
'period_days': 30,
|
||||
'countries': ['ru', 'us'],
|
||||
'devices': 3,
|
||||
'traffic_gb': 20,
|
||||
'total_price': 30000,
|
||||
'total_devices_price': 0,
|
||||
}
|
||||
|
||||
with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \
|
||||
patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, \
|
||||
patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, \
|
||||
patch('app.localization.texts.get_texts') as mock_get_texts, \
|
||||
patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, \
|
||||
patch('app.handlers.subscription.purchase.settings') as mock_settings, \
|
||||
patch('app.handlers.subscription.pricing._prepare_subscription_summary', new=AsyncMock(return_value=("ignored", sanitized_summary_data))):
|
||||
|
||||
mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data)
|
||||
mock_cart_service.save_user_cart = AsyncMock()
|
||||
mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}]
|
||||
mock_format_period.return_value = "30 дней"
|
||||
mock_keyboard = AsyncMock()
|
||||
mock_keyboard_func.return_value = mock_keyboard
|
||||
|
||||
mock_texts = AsyncMock()
|
||||
mock_texts.format_price = lambda x: f"{x/100} ₽"
|
||||
mock_texts.t = lambda key, default=None: default or ""
|
||||
mock_get_texts.return_value = mock_texts
|
||||
|
||||
mock_settings.is_devices_selection_enabled.return_value = False
|
||||
mock_settings.DEFAULT_DEVICE_LIMIT = 3
|
||||
mock_settings.is_traffic_fixed.return_value = False
|
||||
mock_settings.get_fixed_traffic_limit.return_value = 0
|
||||
|
||||
mock_user.balance_kopeks = 60000
|
||||
|
||||
await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db)
|
||||
|
||||
mock_cart_service.save_user_cart.assert_called_once()
|
||||
_, saved_payload = mock_cart_service.save_user_cart.call_args[0]
|
||||
assert saved_payload['devices'] == 3
|
||||
assert saved_payload['total_price'] == 30000
|
||||
assert saved_payload['saved_cart'] is True
|
||||
|
||||
mock_state.set_data.assert_called_once()
|
||||
normalized_data = mock_state.set_data.call_args[0][0]
|
||||
assert normalized_data['devices'] == 3
|
||||
assert normalized_data['total_price'] == 30000
|
||||
assert normalized_data['saved_cart'] is True
|
||||
|
||||
edited_text = mock_callback_query.message.edit_text.call_args[0][0]
|
||||
assert "📱" not in edited_text
|
||||
|
||||
mock_callback_query.answer.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock_state, mock_user, mock_db):
|
||||
"""Тест возврата к сохраненной корзине с недостаточным балансом"""
|
||||
|
||||
Reference in New Issue
Block a user