Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c05b36b9d | |||
| b398eecbeb | |||
| 5ab80b09a1 | |||
| 535b92d43b | |||
| cbaddd65e0 | |||
| 136cae68f1 | |||
| 38abf49af9 | |||
| 799243a988 | |||
| 6d024716c7 | |||
| 0892f494d9 | |||
| 42b67bdc47 | |||
| 6c41263511 | |||
| 1aab9550ce | |||
| d596b19d96 | |||
| 0863e3023d | |||
| 1ef2e1264f | |||
| c40806a5a3 | |||
| 841c288313 | |||
| cd1a4a4a6e | |||
| e9e61f4892 | |||
| 167cddcd2f | |||
| 5b4c597e9b | |||
| 0cb1c9b580 | |||
| ff4471a22a | |||
| 93d7bd74a3 | |||
| c770948c1f | |||
| 2e1a6faec1 | |||
| 0031c9e2e0 | |||
| 637d2a07e0 | |||
| 988ffbebdb | |||
| 5a795a2ae2 | |||
| 9b6cd74dbf | |||
| daa2f13ca3 | |||
| 386b9ae998 | |||
| e64854dc48 | |||
| 23eed94009 | |||
| f04ffa58e4 | |||
| fc45db6f3c | |||
| b8874772be | |||
| d8c7793a26 | |||
| 10d08d5b40 | |||
| b07a889826 | |||
| a24b4c72e9 | |||
| 5e1d00ad0d | |||
| d33ebb1453 | |||
| 7a2bcf3d2f | |||
| dbb68582e4 | |||
| 479b9bc384 | |||
| 73f3987481 | |||
| 8b38f1e37d | |||
| 6976129972 | |||
| 207e4673c0 | |||
| 160ff7ff9f | |||
| 6c7c57138d | |||
| 12b5a39194 | |||
| 7678150e6a | |||
| 99e35cae4c | |||
| af313a12ed | |||
| 71d18287fe | |||
| 6b24b69b53 | |||
| 9528457b89 | |||
| 69322640c5 | |||
| 3c0703b599 | |||
| d14aa5bd8a | |||
| baa5da243e | |||
| fff01d1ce3 | |||
| 52c8442423 | |||
| 150f9e741a | |||
| 4ae2234b7f | |||
| 71366a8133 | |||
| fb6cda2c63 | |||
| 2d4f9da9a7 | |||
| 7a39abe05c | |||
| 79659ec5fe | |||
| a3457d5853 | |||
| 0392aa5b45 | |||
| 2c68d9ded9 | |||
| a437ebd65b | |||
| e2323755de | |||
| 5e23920723 | |||
| 5e45fc1285 | |||
| ff1556ffcd | |||
| 549e8fa332 | |||
| 8a7fb598fa | |||
| 521c364fbb | |||
| db60f5c5ba | |||
| affc07985e | |||
| 2761255e65 |
@@ -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.8.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.0-$(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.8.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.0-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.8.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.0-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.8.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.0-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.8.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.0-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.8.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.0-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.8.0"
|
||||
ARG VERSION="v2.9.0"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -1186,6 +1186,7 @@ REDIS_URL=redis://redis:6379/0
|
||||
- ⏰ **Продление/сокращение срока** подписки (±365 дней)
|
||||
- 🚫 Блокировки с таймером и аудит действий
|
||||
- 🛡️ **Защита от запрещенных никнеймов** с настраиваемым список банвордов (автоблокировка подозрительных имен)
|
||||
- 💰 **Установка индивидуального реферального процента юзеру**
|
||||
|
||||
🎯 **Продажи, маркетинг и удержание**
|
||||
- 🎫 Промокоды
|
||||
@@ -1231,6 +1232,15 @@ REDIS_URL=redis://redis:6379/0
|
||||
- 📘 **Управление пакетами трафика** (включение/отключение)
|
||||
- 🧪 Тестовые платежи для каждого провайдера
|
||||
- 🪙 Управление вебхуками всех платёжных систем
|
||||
- ⚙️ **Управление настройками из бота** (с приоритетом в .env)
|
||||
|
||||
⚙️ **Remnawave**
|
||||
- Синхронизация юзеров из панели в бота (Ручная/автоматическая по таймеру)
|
||||
- Синхронизация юзеров из бота в панель
|
||||
- Синхронизация сквадов(серверов) из панели в бота
|
||||
- Управление нодами/сквадами прямо в боте
|
||||
- Детальная статистика по нодам/панели
|
||||
- Создание/Редактивание сквадов в боте
|
||||
|
||||
🗃️ **REST API для интеграций**
|
||||
- 🔌 **FastAPI Web API** с полной документацией
|
||||
|
||||
+43
-3
@@ -1,6 +1,7 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import html
|
||||
@@ -19,6 +20,8 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
|
||||
"joingroup",
|
||||
]
|
||||
|
||||
USER_TAG_PATTERN = re.compile(r"^[A-Z0-9_]{1,16}$")
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -88,6 +91,7 @@ class Settings(BaseSettings):
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
|
||||
TRIAL_PAYMENT_ENABLED: bool = False
|
||||
TRIAL_ACTIVATION_PRICE: int = 0
|
||||
TRIAL_USER_TAG: Optional[str] = None
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
@@ -119,6 +123,7 @@ class Settings(BaseSettings):
|
||||
PRICE_90_DAYS: int = 269000
|
||||
PRICE_180_DAYS: int = 499000
|
||||
PRICE_360_DAYS: int = 899000
|
||||
PAID_SUBSCRIPTION_USER_TAG: Optional[str] = None
|
||||
|
||||
PRICE_TRAFFIC_5GB: int = 2000
|
||||
PRICE_TRAFFIC_10GB: int = 3500
|
||||
@@ -776,13 +781,48 @@ class Settings(BaseSettings):
|
||||
|
||||
def kopeks_to_rubles(self, kopeks: int) -> float:
|
||||
return kopeks / 100
|
||||
|
||||
|
||||
def rubles_to_kopeks(self, rubles: float) -> int:
|
||||
return int(rubles * 100)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _normalize_user_tag(value: Optional[str], setting_name: str) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
cleaned = str(value).strip().upper()
|
||||
if not cleaned:
|
||||
return None
|
||||
|
||||
if len(cleaned) > 16:
|
||||
logger.warning(
|
||||
"Некорректная длина %s: максимум 16 символов, получено %s",
|
||||
setting_name,
|
||||
len(cleaned),
|
||||
)
|
||||
return None
|
||||
|
||||
if not USER_TAG_PATTERN.fullmatch(cleaned):
|
||||
logger.warning(
|
||||
"Некорректный формат %s: допустимы только A-Z, 0-9 и подчёркивание",
|
||||
setting_name,
|
||||
)
|
||||
return None
|
||||
|
||||
return cleaned
|
||||
|
||||
def get_trial_warning_hours(self) -> int:
|
||||
return self.TRIAL_WARNING_HOURS
|
||||
|
||||
def get_trial_user_tag(self) -> Optional[str]:
|
||||
return self._normalize_user_tag(self.TRIAL_USER_TAG, "TRIAL_USER_TAG")
|
||||
|
||||
def get_paid_subscription_user_tag(self) -> Optional[str]:
|
||||
return self._normalize_user_tag(
|
||||
self.PAID_SUBSCRIPTION_USER_TAG,
|
||||
"PAID_SUBSCRIPTION_USER_TAG",
|
||||
)
|
||||
|
||||
def get_bot_username(self) -> Optional[str]:
|
||||
username = getattr(self, "BOT_USERNAME", None)
|
||||
if not username:
|
||||
@@ -1284,7 +1324,7 @@ class Settings(BaseSettings):
|
||||
return stars * self.get_stars_rate()
|
||||
|
||||
def rubles_to_stars(self, rubles: float) -> int:
|
||||
return max(1, int(rubles / self.get_stars_rate()))
|
||||
return max(1, math.ceil(rubles / self.get_stars_rate()))
|
||||
|
||||
def get_admin_notifications_chat_id(self) -> Optional[int]:
|
||||
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
|
||||
|
||||
Vendored
+230
-29
@@ -27,14 +27,22 @@ class TrafficLimitStrategy(Enum):
|
||||
MONTH = "MONTH"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserTraffic:
|
||||
"""Данные о трафике пользователя (новая структура API)"""
|
||||
used_traffic_bytes: int
|
||||
lifetime_used_traffic_bytes: int
|
||||
online_at: Optional[datetime] = None
|
||||
first_connected_at: Optional[datetime] = None
|
||||
last_connected_node_uuid: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveUser:
|
||||
uuid: str
|
||||
short_uuid: str
|
||||
username: str
|
||||
status: UserStatus
|
||||
used_traffic_bytes: int
|
||||
lifetime_used_traffic_bytes: int
|
||||
traffic_limit_bytes: int
|
||||
traffic_limit_strategy: TrafficLimitStrategy
|
||||
expire_at: datetime
|
||||
@@ -47,18 +55,60 @@ class RemnaWaveUser:
|
||||
active_internal_squads: List[Dict[str, str]]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
user_traffic: Optional[UserTraffic] = None
|
||||
sub_last_user_agent: Optional[str] = None
|
||||
sub_last_opened_at: Optional[datetime] = None
|
||||
online_at: Optional[datetime] = None
|
||||
sub_revoked_at: Optional[datetime] = None
|
||||
last_traffic_reset_at: Optional[datetime] = None
|
||||
trojan_password: Optional[str] = None
|
||||
vless_uuid: Optional[str] = None
|
||||
ss_password: Optional[str] = None
|
||||
first_connected_at: Optional[datetime] = None
|
||||
last_triggered_threshold: int = 0
|
||||
happ_link: Optional[str] = None
|
||||
happ_crypto_link: Optional[str] = None
|
||||
external_squad_uuid: Optional[str] = None
|
||||
id: Optional[int] = None
|
||||
|
||||
@property
|
||||
def used_traffic_bytes(self) -> int:
|
||||
"""Обратная совместимость: получение used_traffic_bytes из user_traffic"""
|
||||
if self.user_traffic:
|
||||
return self.user_traffic.used_traffic_bytes
|
||||
return 0
|
||||
|
||||
@property
|
||||
def lifetime_used_traffic_bytes(self) -> int:
|
||||
"""Обратная совместимость: получение lifetime_used_traffic_bytes из user_traffic"""
|
||||
if self.user_traffic:
|
||||
return self.user_traffic.lifetime_used_traffic_bytes
|
||||
return 0
|
||||
|
||||
@property
|
||||
def online_at(self) -> Optional[datetime]:
|
||||
"""Обратная совместимость: получение online_at из user_traffic"""
|
||||
if self.user_traffic:
|
||||
return self.user_traffic.online_at
|
||||
return None
|
||||
|
||||
@property
|
||||
def first_connected_at(self) -> Optional[datetime]:
|
||||
"""Обратная совместимость: получение first_connected_at из user_traffic"""
|
||||
if self.user_traffic:
|
||||
return self.user_traffic.first_connected_at
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveInbound:
|
||||
"""Структура inbound для Internal Squad"""
|
||||
uuid: str
|
||||
profile_uuid: str
|
||||
tag: str
|
||||
type: str
|
||||
network: Optional[str] = None
|
||||
security: Optional[str] = None
|
||||
port: Optional[int] = None
|
||||
raw_inbound: Optional[Any] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -67,7 +117,21 @@ class RemnaWaveInternalSquad:
|
||||
name: str
|
||||
members_count: int
|
||||
inbounds_count: int
|
||||
inbounds: List[Dict[str, Any]]
|
||||
inbounds: List[RemnaWaveInbound]
|
||||
view_position: int = 0
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveAccessibleNode:
|
||||
"""Доступная нода для Internal Squad"""
|
||||
uuid: str
|
||||
node_name: str
|
||||
country_code: str
|
||||
config_profile_uuid: str
|
||||
config_profile_name: str
|
||||
active_inbounds: List[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -78,11 +142,39 @@ class RemnaWaveNode:
|
||||
country_code: str
|
||||
is_connected: bool
|
||||
is_disabled: bool
|
||||
is_node_online: bool
|
||||
is_xray_running: bool
|
||||
users_online: Optional[int]
|
||||
traffic_used_bytes: Optional[int]
|
||||
traffic_limit_bytes: Optional[int]
|
||||
port: Optional[int] = None
|
||||
is_connecting: bool = False
|
||||
xray_version: Optional[str] = None
|
||||
node_version: Optional[str] = None
|
||||
view_position: int = 0
|
||||
tags: Optional[List[str]] = None
|
||||
# Новые поля API
|
||||
last_status_change: Optional[datetime] = None
|
||||
last_status_message: Optional[str] = None
|
||||
xray_uptime: Optional[str] = None
|
||||
is_traffic_tracking_active: bool = False
|
||||
traffic_reset_day: Optional[int] = None
|
||||
notify_percent: Optional[int] = None
|
||||
consumption_multiplier: float = 1.0
|
||||
cpu_count: Optional[int] = None
|
||||
cpu_model: Optional[str] = None
|
||||
total_ram: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
provider_uuid: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_node_online(self) -> bool:
|
||||
"""Обратная совместимость: is_node_online = is_connected"""
|
||||
return self.is_connected
|
||||
|
||||
@property
|
||||
def is_xray_running(self) -> bool:
|
||||
"""Обратная совместимость: xray работает если нода подключена"""
|
||||
return self.is_connected
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -444,8 +536,38 @@ class RemnaWaveAPI:
|
||||
async def delete_internal_squad(self, uuid: str) -> bool:
|
||||
response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
|
||||
|
||||
async def get_internal_squad_accessible_nodes(self, uuid: str) -> List[RemnaWaveAccessibleNode]:
|
||||
"""Получает список доступных нод для Internal Squad"""
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/internal-squads/{uuid}/accessible-nodes')
|
||||
return [self._parse_accessible_node(node) for node in response['response']['accessibleNodes']]
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return []
|
||||
raise
|
||||
|
||||
async def add_users_to_internal_squad(self, uuid: str) -> bool:
|
||||
"""Добавляет всех пользователей в Internal Squad (bulk action)"""
|
||||
response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/add-users')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def remove_users_from_internal_squad(self, uuid: str) -> bool:
|
||||
"""Удаляет всех пользователей из Internal Squad (bulk action)"""
|
||||
response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/remove-users')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def reorder_internal_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveInternalSquad]:
|
||||
"""
|
||||
Изменяет порядок Internal Squads
|
||||
items: список словарей с uuid и viewPosition
|
||||
Пример: [{'uuid': '...', 'viewPosition': 0}, {'uuid': '...', 'viewPosition': 1}]
|
||||
"""
|
||||
data = {'items': items}
|
||||
response = await self._make_request('POST', '/api/internal-squads/actions/reorder', data)
|
||||
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
|
||||
|
||||
|
||||
async def get_all_nodes(self) -> List[RemnaWaveNode]:
|
||||
response = await self._make_request('GET', '/api/nodes')
|
||||
return [self._parse_node(node) for node in response['response']]
|
||||
@@ -586,42 +708,73 @@ class RemnaWaveAPI:
|
||||
return False
|
||||
|
||||
|
||||
def _parse_user_traffic(self, traffic_data: Optional[Dict]) -> Optional[UserTraffic]:
|
||||
"""Парсит данные трафика из нового формата API"""
|
||||
if not traffic_data:
|
||||
return None
|
||||
|
||||
return UserTraffic(
|
||||
used_traffic_bytes=int(traffic_data.get('usedTrafficBytes', 0)),
|
||||
lifetime_used_traffic_bytes=int(traffic_data.get('lifetimeUsedTrafficBytes', 0)),
|
||||
online_at=self._parse_optional_datetime(traffic_data.get('onlineAt')),
|
||||
first_connected_at=self._parse_optional_datetime(traffic_data.get('firstConnectedAt')),
|
||||
last_connected_node_uuid=traffic_data.get('lastConnectedNodeUuid')
|
||||
)
|
||||
|
||||
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
|
||||
happ_data = user_data.get('happ') or {}
|
||||
happ_link = happ_data.get('link') or happ_data.get('url')
|
||||
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
|
||||
|
||||
# Парсим userTraffic из нового формата API
|
||||
user_traffic = self._parse_user_traffic(user_data.get('userTraffic'))
|
||||
|
||||
# Получаем status с fallback на ACTIVE
|
||||
status_str = user_data.get('status') or 'ACTIVE'
|
||||
try:
|
||||
status = UserStatus(status_str)
|
||||
except ValueError:
|
||||
logger.warning(f"Неизвестный статус пользователя: {status_str}, используем ACTIVE")
|
||||
status = UserStatus.ACTIVE
|
||||
|
||||
# Получаем trafficLimitStrategy с fallback
|
||||
strategy_str = user_data.get('trafficLimitStrategy') or 'NO_RESET'
|
||||
try:
|
||||
traffic_strategy = TrafficLimitStrategy(strategy_str)
|
||||
except ValueError:
|
||||
logger.warning(f"Неизвестная стратегия трафика: {strategy_str}, используем NO_RESET")
|
||||
traffic_strategy = TrafficLimitStrategy.NO_RESET
|
||||
|
||||
return RemnaWaveUser(
|
||||
uuid=user_data['uuid'],
|
||||
short_uuid=user_data['shortUuid'],
|
||||
username=user_data['username'],
|
||||
status=UserStatus(user_data['status']),
|
||||
used_traffic_bytes=int(user_data['usedTrafficBytes']),
|
||||
lifetime_used_traffic_bytes=int(user_data['lifetimeUsedTrafficBytes']),
|
||||
traffic_limit_bytes=user_data['trafficLimitBytes'],
|
||||
traffic_limit_strategy=TrafficLimitStrategy(user_data['trafficLimitStrategy']),
|
||||
status=status,
|
||||
traffic_limit_bytes=user_data.get('trafficLimitBytes', 0),
|
||||
traffic_limit_strategy=traffic_strategy,
|
||||
expire_at=datetime.fromisoformat(user_data['expireAt'].replace('Z', '+00:00')),
|
||||
telegram_id=user_data.get('telegramId'),
|
||||
email=user_data.get('email'),
|
||||
hwid_device_limit=user_data.get('hwidDeviceLimit'),
|
||||
description=user_data.get('description'),
|
||||
tag=user_data.get('tag'),
|
||||
subscription_url=user_data['subscriptionUrl'],
|
||||
active_internal_squads=user_data['activeInternalSquads'],
|
||||
subscription_url=user_data.get('subscriptionUrl', ''),
|
||||
active_internal_squads=user_data.get('activeInternalSquads', []),
|
||||
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
|
||||
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
|
||||
user_traffic=user_traffic,
|
||||
sub_last_user_agent=user_data.get('subLastUserAgent'),
|
||||
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
|
||||
online_at=self._parse_optional_datetime(user_data.get('onlineAt')),
|
||||
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
|
||||
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
|
||||
trojan_password=user_data.get('trojanPassword'),
|
||||
vless_uuid=user_data.get('vlessUuid'),
|
||||
ss_password=user_data.get('ssPassword'),
|
||||
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
|
||||
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0),
|
||||
happ_link=happ_link,
|
||||
happ_crypto_link=happ_crypto_link
|
||||
happ_crypto_link=happ_crypto_link,
|
||||
external_squad_uuid=user_data.get('externalSquadUuid'),
|
||||
id=user_data.get('id')
|
||||
)
|
||||
|
||||
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||
@@ -629,28 +782,76 @@ class RemnaWaveAPI:
|
||||
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
return None
|
||||
|
||||
def _parse_inbound(self, inbound_data: Dict) -> RemnaWaveInbound:
|
||||
"""Парсит данные inbound"""
|
||||
return RemnaWaveInbound(
|
||||
uuid=inbound_data['uuid'],
|
||||
profile_uuid=inbound_data['profileUuid'],
|
||||
tag=inbound_data['tag'],
|
||||
type=inbound_data['type'],
|
||||
network=inbound_data.get('network'),
|
||||
security=inbound_data.get('security'),
|
||||
port=inbound_data.get('port'),
|
||||
raw_inbound=inbound_data.get('rawInbound')
|
||||
)
|
||||
|
||||
def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad:
|
||||
info = squad_data.get('info', {})
|
||||
inbounds_raw = squad_data.get('inbounds', [])
|
||||
inbounds = [self._parse_inbound(ib) for ib in inbounds_raw] if inbounds_raw else []
|
||||
return RemnaWaveInternalSquad(
|
||||
uuid=squad_data['uuid'],
|
||||
name=squad_data['name'],
|
||||
members_count=squad_data['info']['membersCount'],
|
||||
inbounds_count=squad_data['info']['inboundsCount'],
|
||||
inbounds=squad_data['inbounds']
|
||||
members_count=info.get('membersCount', 0),
|
||||
inbounds_count=info.get('inboundsCount', 0),
|
||||
inbounds=inbounds,
|
||||
view_position=squad_data.get('viewPosition', 0),
|
||||
created_at=self._parse_optional_datetime(squad_data.get('createdAt')),
|
||||
updated_at=self._parse_optional_datetime(squad_data.get('updatedAt'))
|
||||
)
|
||||
|
||||
|
||||
def _parse_accessible_node(self, node_data: Dict) -> RemnaWaveAccessibleNode:
|
||||
"""Парсит данные доступной ноды для Internal Squad"""
|
||||
return RemnaWaveAccessibleNode(
|
||||
uuid=node_data['uuid'],
|
||||
node_name=node_data['nodeName'],
|
||||
country_code=node_data['countryCode'],
|
||||
config_profile_uuid=node_data['configProfileUuid'],
|
||||
config_profile_name=node_data['configProfileName'],
|
||||
active_inbounds=node_data.get('activeInbounds', [])
|
||||
)
|
||||
|
||||
def _parse_node(self, node_data: Dict) -> RemnaWaveNode:
|
||||
return RemnaWaveNode(
|
||||
uuid=node_data['uuid'],
|
||||
name=node_data['name'],
|
||||
address=node_data['address'],
|
||||
country_code=node_data['countryCode'],
|
||||
is_connected=node_data['isConnected'],
|
||||
is_disabled=node_data['isDisabled'],
|
||||
is_node_online=node_data['isNodeOnline'],
|
||||
is_xray_running=node_data['isXrayRunning'],
|
||||
country_code=node_data.get('countryCode', ''),
|
||||
is_connected=node_data.get('isConnected', False),
|
||||
is_disabled=node_data.get('isDisabled', False),
|
||||
users_online=node_data.get('usersOnline'),
|
||||
traffic_used_bytes=node_data.get('trafficUsedBytes'),
|
||||
traffic_limit_bytes=node_data.get('trafficLimitBytes')
|
||||
traffic_limit_bytes=node_data.get('trafficLimitBytes'),
|
||||
port=node_data.get('port'),
|
||||
is_connecting=node_data.get('isConnecting', False),
|
||||
xray_version=node_data.get('xrayVersion'),
|
||||
node_version=node_data.get('nodeVersion'),
|
||||
view_position=node_data.get('viewPosition', 0),
|
||||
tags=node_data.get('tags', []),
|
||||
# Новые поля API
|
||||
last_status_change=self._parse_optional_datetime(node_data.get('lastStatusChange')),
|
||||
last_status_message=node_data.get('lastStatusMessage'),
|
||||
xray_uptime=node_data.get('xrayUptime'),
|
||||
is_traffic_tracking_active=node_data.get('isTrafficTrackingActive', False),
|
||||
traffic_reset_day=node_data.get('trafficResetDay'),
|
||||
notify_percent=node_data.get('notifyPercent'),
|
||||
consumption_multiplier=node_data.get('consumptionMultiplier', 1.0),
|
||||
cpu_count=node_data.get('cpuCount'),
|
||||
cpu_model=node_data.get('cpuModel'),
|
||||
total_ram=node_data.get('totalRam'),
|
||||
created_at=self._parse_optional_datetime(node_data.get('createdAt')),
|
||||
updated_at=self._parse_optional_datetime(node_data.get('updatedAt')),
|
||||
provider_uuid=node_data.get('providerUuid')
|
||||
)
|
||||
|
||||
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
|
||||
|
||||
+113
-56
@@ -1316,7 +1316,29 @@ async def show_node_details(
|
||||
|
||||
status_emoji = "🟢" if node["is_node_online"] else "🔴"
|
||||
xray_emoji = "✅" if node["is_xray_running"] else "❌"
|
||||
|
||||
|
||||
status_change = (
|
||||
format_datetime(node["last_status_change"])
|
||||
if node.get("last_status_change")
|
||||
else "—"
|
||||
)
|
||||
created_at = (
|
||||
format_datetime(node["created_at"])
|
||||
if node.get("created_at")
|
||||
else "—"
|
||||
)
|
||||
updated_at = (
|
||||
format_datetime(node["updated_at"])
|
||||
if node.get("updated_at")
|
||||
else "—"
|
||||
)
|
||||
notify_percent = (
|
||||
f"{node['notify_percent']}%" if node.get("notify_percent") is not None else "—"
|
||||
)
|
||||
cpu_info = node.get("cpu_model") or "—"
|
||||
if node.get("cpu_count"):
|
||||
cpu_info = f"{node['cpu_count']}x {cpu_info}"
|
||||
|
||||
text = f"""
|
||||
🖥️ <b>Нода: {node['name']}</b>
|
||||
|
||||
@@ -1325,15 +1347,29 @@ async def show_node_details(
|
||||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
|
||||
- Подключена: {'📡 Да' if node['is_connected'] else '📵 Нет'}
|
||||
- Отключена: {'❌ Да' if node['is_disabled'] else '✅ Нет'}
|
||||
- Изменение статуса: {status_change}
|
||||
- Сообщение: {node.get('last_status_message') or '—'}
|
||||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||||
|
||||
<b>Информация:</b>
|
||||
- Адрес: {node['address']}
|
||||
- Страна: {node['country_code']}
|
||||
- Пользователей онлайн: {node['users_online']}
|
||||
- CPU: {cpu_info}
|
||||
- RAM: {node.get('total_ram') or '—'}
|
||||
- Провайдер: {node.get('provider_uuid') or '—'}
|
||||
|
||||
<b>Трафик:</b>
|
||||
- Использовано: {format_bytes(node['traffic_used_bytes'])}
|
||||
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
|
||||
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
|
||||
- День сброса: {node.get('traffic_reset_day') or '—'}
|
||||
- Уведомления: {notify_percent}
|
||||
- Множитель: {node.get('consumption_multiplier') or 1}
|
||||
|
||||
<b>Метаданные:</b>
|
||||
- Создана: {created_at}
|
||||
- Обновлена: {updated_at}
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -1350,28 +1386,18 @@ async def manage_node(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1]
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
success = await remnawave_service.manage_node(node_uuid, action)
|
||||
|
||||
if success:
|
||||
action_text = {"enable": "включена", "disable": "отключена", "restart": "перезагружена"}
|
||||
await callback.answer(f"✅ Нода {action_text.get(action, 'обработана')}")
|
||||
else:
|
||||
await callback.answer("❌ Ошибка выполнения действия", show_alert=True)
|
||||
|
||||
await show_node_details(
|
||||
types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data=f"admin_node_manage_{node_uuid}",
|
||||
message=callback.message
|
||||
),
|
||||
db_user,
|
||||
db
|
||||
)
|
||||
action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1]
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
success = await remnawave_service.manage_node(node_uuid, action)
|
||||
|
||||
if success:
|
||||
action_text = {"enable": "включена", "disable": "отключена", "restart": "перезагружена"}
|
||||
await callback.answer(f"✅ Нода {action_text.get(action, 'обработана')}")
|
||||
else:
|
||||
await callback.answer("❌ Ошибка выполнения действия", show_alert=True)
|
||||
|
||||
await show_node_details(callback, db_user, db)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -1407,10 +1433,32 @@ async def show_node_statistics(
|
||||
if stats.get('nodeUuid') == node_uuid:
|
||||
node_realtime = stats
|
||||
break
|
||||
|
||||
|
||||
status_change = (
|
||||
format_datetime(node["last_status_change"])
|
||||
if node.get("last_status_change")
|
||||
else "—"
|
||||
)
|
||||
created_at = (
|
||||
format_datetime(node["created_at"])
|
||||
if node.get("created_at")
|
||||
else "—"
|
||||
)
|
||||
updated_at = (
|
||||
format_datetime(node["updated_at"])
|
||||
if node.get("updated_at")
|
||||
else "—"
|
||||
)
|
||||
notify_percent = (
|
||||
f"{node['notify_percent']}%" if node.get("notify_percent") is not None else "—"
|
||||
)
|
||||
cpu_info = node.get("cpu_model") or "—"
|
||||
if node.get("cpu_count"):
|
||||
cpu_info = f"{node['cpu_count']}x {cpu_info}"
|
||||
|
||||
status_emoji = "🟢" if node["is_node_online"] else "🔴"
|
||||
xray_emoji = "✅" if node["is_xray_running"] else "❌"
|
||||
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика ноды: {node['name']}</b>
|
||||
|
||||
@@ -1418,10 +1466,26 @@ async def show_node_statistics(
|
||||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
|
||||
- Пользователей онлайн: {node['users_online'] or 0}
|
||||
- Изменение статуса: {status_change}
|
||||
- Сообщение: {node.get('last_status_message') or '—'}
|
||||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||||
|
||||
<b>Ресурсы:</b>
|
||||
- CPU: {cpu_info}
|
||||
- RAM: {node.get('total_ram') or '—'}
|
||||
- Провайдер: {node.get('provider_uuid') or '—'}
|
||||
|
||||
<b>Трафик:</b>
|
||||
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
|
||||
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
|
||||
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
|
||||
- День сброса: {node.get('traffic_reset_day') or '—'}
|
||||
- Уведомления: {notify_percent}
|
||||
- Множитель: {node.get('consumption_multiplier') or 1}
|
||||
|
||||
<b>Метаданные:</b>
|
||||
- Создана: {created_at}
|
||||
- Обновлена: {updated_at}
|
||||
"""
|
||||
|
||||
if node_realtime:
|
||||
@@ -1456,18 +1520,25 @@ async def show_node_statistics(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики ноды {node_uuid}: {e}")
|
||||
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика ноды: {node['name']}</b>
|
||||
|
||||
<b>Статус:</b>
|
||||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
|
||||
- Пользователей онлайн: {node['users_online'] or 0}
|
||||
- Изменение статуса: {format_datetime(node.get('last_status_change')) if node.get('last_status_change') else '—'}
|
||||
- Сообщение: {node.get('last_status_message') or '—'}
|
||||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||||
|
||||
<b>Трафик:</b>
|
||||
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
|
||||
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
|
||||
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
|
||||
- День сброса: {node.get('traffic_reset_day') or '—'}
|
||||
- Уведомления: {node.get('notify_percent') or '—'}
|
||||
- Множитель: {node.get('consumption_multiplier') or 1}
|
||||
|
||||
⚠️ <b>Детальная статистика временно недоступна</b>
|
||||
Возможные причины:
|
||||
@@ -1566,17 +1637,11 @@ async def manage_squad_action(
|
||||
await callback.answer("❌ Ошибка удаления сквада", show_alert=True)
|
||||
return
|
||||
|
||||
await show_squad_details(
|
||||
types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data=f"admin_squad_manage_{squad_uuid}",
|
||||
message=callback.message
|
||||
),
|
||||
db_user,
|
||||
db
|
||||
)
|
||||
refreshed_callback = callback.model_copy(
|
||||
update={"data": f"admin_squad_manage_{squad_uuid}"}
|
||||
).as_(callback.bot)
|
||||
|
||||
await show_squad_details(refreshed_callback, db_user, db)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -1734,15 +1799,11 @@ async def cancel_squad_rename(
|
||||
|
||||
await state.clear()
|
||||
|
||||
new_callback = types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data=f"squad_edit_{squad_uuid}",
|
||||
message=callback.message
|
||||
)
|
||||
|
||||
await show_squad_edit_menu(new_callback, db_user, db)
|
||||
refreshed_callback = callback.model_copy(
|
||||
update={"data": f"squad_edit_{squad_uuid}"}
|
||||
).as_(callback.bot)
|
||||
|
||||
await show_squad_edit_menu(refreshed_callback, db_user, db)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -1953,15 +2014,11 @@ async def show_squad_edit_menu_short(
|
||||
await callback.answer("❌ Сквад не найден", show_alert=True)
|
||||
return
|
||||
|
||||
new_callback = types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data=f"squad_edit_{full_squad_uuid}",
|
||||
message=callback.message
|
||||
)
|
||||
|
||||
await show_squad_edit_menu(new_callback, db_user, db)
|
||||
refreshed_callback = callback.model_copy(
|
||||
update={"data": f"squad_edit_{full_squad_uuid}"}
|
||||
).as_(callback.bot)
|
||||
|
||||
await show_squad_edit_menu(refreshed_callback, db_user, db)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
|
||||
@@ -94,15 +94,38 @@ async def show_balance_menu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
balance_text = texts.BALANCE_INFO.format(
|
||||
balance=texts.format_price(db_user.balance_kopeks)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
balance_text,
|
||||
reply_markup=get_balance_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
reply_markup = get_balance_keyboard(db_user.language)
|
||||
|
||||
try:
|
||||
if callback.message and callback.message.text:
|
||||
await callback.message.edit_text(
|
||||
balance_text,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
elif callback.message and callback.message.caption:
|
||||
await callback.message.edit_caption(
|
||||
balance_text,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
else:
|
||||
await callback.message.answer(
|
||||
balance_text,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
except TelegramBadRequest as error:
|
||||
logger.warning(
|
||||
"Failed to edit balance message, sending a new one instead: %s",
|
||||
error,
|
||||
)
|
||||
await callback.message.answer(
|
||||
balance_text,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
|
||||
+134
-2
@@ -149,6 +149,18 @@ async def show_main_menu(
|
||||
*,
|
||||
skip_callback_answer: bool = False,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
@@ -164,7 +176,7 @@ async def show_main_menu(
|
||||
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
|
||||
# Проверяем наличие сохраненной корзины в Redis
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
|
||||
@@ -230,6 +242,18 @@ async def show_service_rules(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -252,6 +276,18 @@ async def show_info_menu(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
header = texts.t("MENU_INFO_HEADER", "ℹ️ <b>Инфо</b>")
|
||||
@@ -283,6 +319,18 @@ async def show_promo_groups_info(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
promo_groups = await get_auto_assign_promo_groups(db)
|
||||
@@ -423,6 +471,18 @@ async def show_faq_pages(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
pages = await FaqService.get_pages(db, db_user.language)
|
||||
@@ -467,6 +527,18 @@ async def show_faq_page(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_data = callback.data or ""
|
||||
@@ -590,6 +662,18 @@ async def show_privacy_policy(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_page = 1
|
||||
@@ -697,6 +781,18 @@ async def show_public_offer(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_page = 1
|
||||
@@ -804,6 +900,18 @@ async def show_language_menu(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
@@ -834,6 +942,18 @@ async def process_language_change(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
@@ -889,6 +1009,18 @@ async def handle_back_to_menu(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -903,7 +1035,7 @@ async def handle_back_to_menu(
|
||||
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
|
||||
# Проверяем наличие сохраненной корзины в Redis
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
|
||||
|
||||
@@ -406,6 +406,17 @@ async def apply_countries_changes(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, что пользователь не пытается отключить все страны (должна остаться хотя бы 1 страна)
|
||||
if len(selected_countries) == 0:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"COUNTRIES_MINIMUM_REQUIRED",
|
||||
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
|
||||
),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if added and total_cost > 0:
|
||||
success = await subtract_user_balance(
|
||||
@@ -898,6 +909,17 @@ async def confirm_add_countries_to_subscription(
|
||||
return
|
||||
|
||||
try:
|
||||
# Проверяем, что пользователь не пытается отключить все страны (должна остаться хотя бы 1 страна)
|
||||
if len(selected_countries) == 0:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"COUNTRIES_MINIMUM_REQUIRED",
|
||||
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
|
||||
),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
if new_countries and total_price > 0:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, total_price,
|
||||
|
||||
@@ -1760,9 +1760,11 @@ async def select_devices(
|
||||
)
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
# Проверяем, что ключ 'countries' существует в данных перед доступом к нему
|
||||
selected_countries = data.get('countries', [])
|
||||
countries_price = sum(
|
||||
c['price_kopeks'] for c in countries
|
||||
if c['uuid'] in data['countries']
|
||||
if c['uuid'] in selected_countries
|
||||
)
|
||||
|
||||
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
@@ -1821,9 +1823,15 @@ async def confirm_purchase(
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
|
||||
months_in_period = data.get(
|
||||
'months_in_period', calculate_months_from_days(data['period_days'])
|
||||
)
|
||||
period_days = data.get('period_days')
|
||||
if period_days is None:
|
||||
await callback.message.edit_text(
|
||||
texts.t("SUBSCRIPTION_PURCHASE_ERROR", "Ошибка при оформлении подписки. Попробуйте начать сначала."),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
months_in_period = data.get('months_in_period', calculate_months_from_days(period_days))
|
||||
|
||||
base_price = data.get('base_price')
|
||||
base_price_original = data.get('base_price_original')
|
||||
@@ -1831,10 +1839,10 @@ async def confirm_purchase(
|
||||
base_discount_total = data.get('base_discount_total')
|
||||
|
||||
if base_price is None:
|
||||
base_price_original = PERIOD_PRICES[data['period_days']]
|
||||
base_price_original = PERIOD_PRICES[period_days]
|
||||
base_discount_percent = db_user.get_promo_discount(
|
||||
"period",
|
||||
data['period_days'],
|
||||
period_days,
|
||||
)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
@@ -1842,11 +1850,11 @@ async def confirm_purchase(
|
||||
)
|
||||
else:
|
||||
if base_price_original is None:
|
||||
base_price_original = PERIOD_PRICES[data['period_days']]
|
||||
base_price_original = PERIOD_PRICES[period_days]
|
||||
if base_discount_percent is None:
|
||||
base_discount_percent = db_user.get_promo_discount(
|
||||
"period",
|
||||
data['period_days'],
|
||||
period_days,
|
||||
)
|
||||
if base_discount_total is None:
|
||||
_, base_discount_total = apply_percentage_discount(
|
||||
@@ -1859,14 +1867,16 @@ async def confirm_purchase(
|
||||
countries_price_per_month = 0
|
||||
per_month_prices: List[int] = []
|
||||
for country in countries:
|
||||
if country['uuid'] in data['countries']:
|
||||
# Проверяем, что ключ 'countries' существует в данных перед доступом к нему
|
||||
selected_countries = data.get('countries', [])
|
||||
if country['uuid'] in selected_countries:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
countries_price_per_month += server_price_per_month
|
||||
per_month_prices.append(server_price_per_month)
|
||||
|
||||
servers_discount_percent = db_user.get_promo_discount(
|
||||
"servers",
|
||||
data['period_days'],
|
||||
period_days,
|
||||
)
|
||||
total_servers_price = 0
|
||||
total_servers_discount = 0
|
||||
@@ -1928,7 +1938,7 @@ async def confirm_purchase(
|
||||
else:
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
"devices",
|
||||
data['period_days'],
|
||||
period_days,
|
||||
)
|
||||
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
@@ -1944,9 +1954,15 @@ async def confirm_purchase(
|
||||
)
|
||||
else:
|
||||
final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb'))
|
||||
traffic_price_per_month = data.get(
|
||||
'traffic_price_per_month', settings.get_traffic_price(data['traffic_gb'])
|
||||
)
|
||||
traffic_gb = data.get('traffic_gb')
|
||||
if traffic_gb is not None:
|
||||
traffic_price_per_month = data.get(
|
||||
'traffic_price_per_month', settings.get_traffic_price(traffic_gb)
|
||||
)
|
||||
else:
|
||||
traffic_price_per_month = data.get(
|
||||
'traffic_price_per_month', 0
|
||||
)
|
||||
|
||||
if 'traffic_discount_percent' in data:
|
||||
traffic_discount_percent = data.get('traffic_discount_percent', 0)
|
||||
@@ -1960,7 +1976,7 @@ async def confirm_purchase(
|
||||
else:
|
||||
traffic_discount_percent = db_user.get_promo_discount(
|
||||
"traffic",
|
||||
data['period_days'],
|
||||
period_days,
|
||||
)
|
||||
discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
@@ -1971,7 +1987,7 @@ async def confirm_purchase(
|
||||
|
||||
total_servers_price = data.get('total_servers_price', total_countries_price)
|
||||
|
||||
cached_total_price = data['total_price']
|
||||
cached_total_price = data.get('total_price', 0)
|
||||
cached_promo_discount_value = data.get('promo_offer_discount_value', 0)
|
||||
|
||||
validation_total_price = data.get('total_price_before_promo_offer')
|
||||
@@ -2181,10 +2197,10 @@ async def confirm_purchase(
|
||||
trial_duration_days=trial_duration,
|
||||
payment_method="balance",
|
||||
first_payment_amount_kopeks=final_price,
|
||||
first_paid_period_days=data['period_days']
|
||||
first_paid_period_days=period_days
|
||||
)
|
||||
logger.info(
|
||||
f"Записана конверсия: {trial_duration} дн. триал → {data['period_days']} дн. платная за {final_price / 100}₽")
|
||||
f"Записана конверсия: {trial_duration} дн. триал → {period_days} дн. платная за {final_price / 100}₽")
|
||||
except Exception as conversion_error:
|
||||
logger.error(f"Ошибка записи конверсии: {conversion_error}")
|
||||
|
||||
@@ -2193,10 +2209,37 @@ async def confirm_purchase(
|
||||
existing_subscription.traffic_limit_gb = final_traffic_gb
|
||||
if should_update_devices:
|
||||
existing_subscription.device_limit = selected_devices
|
||||
existing_subscription.connected_squads = data['countries']
|
||||
# Проверяем, что при обновлении существующей подписки есть хотя бы одна страна
|
||||
selected_countries = data.get('countries', [])
|
||||
if not selected_countries:
|
||||
# В случае если подписка уже существовала, не разрешаем отключать все страны
|
||||
# Если подписка новая, разрешаем, но обычно через UI пользователь должен выбрать хотя бы один сервер
|
||||
if existing_subscription and existing_subscription.connected_squads is not None:
|
||||
# Проверим, что в данных есть информация о том, что это обновление существующей подписки
|
||||
# или что-то указывает, что не нужно отключать все страны
|
||||
pass # Для простоты в этом случае просто проверим, что список стран не пустой
|
||||
else:
|
||||
# Для новой подписки разрешаем пустой список, если не является обновлением
|
||||
pass
|
||||
|
||||
# Но для безопасности - если список стран пустой, проверим, что это разрешено
|
||||
# иначе вернем ошибку
|
||||
if not selected_countries:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"COUNTRIES_MINIMUM_REQUIRED",
|
||||
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
existing_subscription.connected_squads = selected_countries
|
||||
|
||||
existing_subscription.start_date = current_time
|
||||
existing_subscription.end_date = current_time + timedelta(days=data['period_days']) + bonus_period
|
||||
existing_subscription.end_date = current_time + timedelta(days=period_days) + bonus_period
|
||||
existing_subscription.updated_at = current_time
|
||||
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
@@ -2222,12 +2265,29 @@ async def confirm_purchase(
|
||||
if resolved_device_limit is None and devices_selection_enabled:
|
||||
resolved_device_limit = default_device_limit
|
||||
|
||||
# Проверяем, что для новой подписки также есть хотя бы одна страна, если пользователь проходит через интерфейс стран
|
||||
new_subscription_countries = data.get('countries', [])
|
||||
if not new_subscription_countries:
|
||||
# Проверяем, была ли это покупка через интерфейс стран, и если да, то требуем хотя бы одну страну
|
||||
# Если в данных явно указано, что это интерфейс стран, или есть другие признаки - требуем страну
|
||||
# Для упрощения - проверим, что страна обязательна, если идет через UI стран
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"COUNTRIES_MINIMUM_REQUIRED",
|
||||
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
subscription = await create_paid_subscription_with_traffic_mode(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
duration_days=data['period_days'],
|
||||
duration_days=period_days,
|
||||
device_limit=resolved_device_limit,
|
||||
connected_squads=data['countries'],
|
||||
connected_squads=new_subscription_countries,
|
||||
traffic_gb=final_traffic_gb
|
||||
)
|
||||
|
||||
@@ -2237,7 +2297,7 @@ async def confirm_purchase(
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers
|
||||
from app.database.crud.subscription import add_subscription_servers
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, data['countries'])
|
||||
server_ids = await get_server_ids_by_uuids(db, data.get('countries', []))
|
||||
|
||||
if server_ids:
|
||||
await add_subscription_servers(db, subscription, server_ids, server_prices)
|
||||
@@ -2278,13 +2338,13 @@ async def confirm_purchase(
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=final_price,
|
||||
description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)"
|
||||
description=f"Подписка на {period_days} дней ({months_in_period} мес)"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db, db_user, subscription, transaction, data['period_days'], was_trial_conversion
|
||||
db, db_user, subscription, transaction, period_days, was_trial_conversion
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
|
||||
|
||||
@@ -865,6 +865,7 @@
|
||||
"COUNTRY_CHANGES_REMOVED_HEADER": "➖ <b>Removed countries:</b>\n",
|
||||
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Reconnecting later will be charged",
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Countries updated!</b>\n\n",
|
||||
"COUNTRIES_MINIMUM_REQUIRED": "❌ Cannot disconnect all countries. At least one country must remain connected.",
|
||||
"COUNTRY_MANAGEMENT_NONE": "No countries connected",
|
||||
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Manage subscription countries</b>\n\n📋 <b>Current countries ({current_count}):</b>\n{current_list}\n\n💡 <b>How it works:</b>\n✅ — currently connected\n➕ — will be added (paid)\n➖ — will be removed (free)\n⚪ — not selected\n\n⚠️ <b>Important:</b> Reconnecting removed countries will be charged again!",
|
||||
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Server management is unavailable — only one server is accessible",
|
||||
@@ -1086,6 +1087,8 @@
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket",
|
||||
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Cryptocurrency (Heleket)</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
|
||||
|
||||
@@ -877,6 +877,7 @@
|
||||
"COUNTRY_CHANGES_REMOVED_HEADER": "➖ <b>Отключены страны:</b>\n",
|
||||
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Повторное подключение будет платным",
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Страны успешно обновлены!</b>\n\n",
|
||||
"COUNTRIES_MINIMUM_REQUIRED": "❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна.",
|
||||
"COUNTRY_MANAGEMENT_NONE": "Нет подключенных стран",
|
||||
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Управление странами подписки</b>\n\n📋 <b>Текущие страны ({current_count}):</b>\n{current_list}\n\n💡 <b>Инструкция:</b>\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪ - не выбрана\n\n⚠️ <b>Важно:</b> Повторное подключение отключенных стран будет платным!",
|
||||
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Управление серверами недоступно - доступен только один сервер",
|
||||
@@ -1098,6 +1099,8 @@
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
|
||||
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
|
||||
|
||||
@@ -1093,10 +1093,12 @@
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банківська картка (Platega)</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "швидко та зручно",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "інші способи",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через підтримку</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "інші способи",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через підтримку</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банківська картка</b>",
|
||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
|
||||
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банківська картка</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему швидких платежів YooKassa",
|
||||
|
||||
@@ -1096,6 +1096,8 @@
|
||||
"PAYMENT_METHOD_SUPPORT_NAME":"🛠️<b>通过支持</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION":"通过Tribute",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME":"💳<b>银行卡</b>",
|
||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION":"通过Heleket",
|
||||
"PAYMENT_METHOD_HELEKET_NAME":"🪙<b>加密货币(Heleket)</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION":"通过YooKassa",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME":"💳<b>银行卡</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION":"通过YooKassa快速支付系统",
|
||||
|
||||
@@ -56,14 +56,7 @@ class TelegramStarsMixin:
|
||||
|
||||
# Если количество звёзд не задано, вычисляем его из курса.
|
||||
if stars_amount is None:
|
||||
rate = Decimal(str(settings.get_stars_rate()))
|
||||
if rate <= 0:
|
||||
raise ValueError("Stars rate must be positive")
|
||||
|
||||
normalized_stars = (amount_rubles / rate).to_integral_value(
|
||||
rounding=ROUND_FLOOR
|
||||
)
|
||||
stars_amount = int(normalized_stars) or 1
|
||||
stars_amount = settings.rubles_to_stars(float(amount_rubles))
|
||||
|
||||
if stars_amount <= 0:
|
||||
raise ValueError("Stars amount must be positive")
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
import re
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -46,6 +47,26 @@ from app.utils.timezone import get_local_timezone
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_user_traffic_bytes(panel_user: Dict[str, Any]) -> int:
|
||||
"""Извлекает usedTrafficBytes из панельного пользователя (совместимо с новым и старым API)"""
|
||||
# Новый формат: userTraffic.usedTrafficBytes
|
||||
user_traffic = panel_user.get('userTraffic')
|
||||
if user_traffic and isinstance(user_traffic, dict):
|
||||
return user_traffic.get('usedTrafficBytes', 0)
|
||||
# Старый формат: usedTrafficBytes напрямую
|
||||
return panel_user.get('usedTrafficBytes', 0)
|
||||
|
||||
|
||||
def _get_lifetime_traffic_bytes(panel_user: Dict[str, Any]) -> int:
|
||||
"""Извлекает lifetimeUsedTrafficBytes из панельного пользователя (совместимо с новым и старым API)"""
|
||||
# Новый формат: userTraffic.lifetimeUsedTrafficBytes
|
||||
user_traffic = panel_user.get('userTraffic')
|
||||
if user_traffic and isinstance(user_traffic, dict):
|
||||
return user_traffic.get('lifetimeUsedTrafficBytes', 0)
|
||||
# Старый формат: lifetimeUsedTrafficBytes напрямую
|
||||
return panel_user.get('lifetimeUsedTrafficBytes', 0)
|
||||
|
||||
|
||||
_UUID_MAP_MISSING = object()
|
||||
|
||||
|
||||
@@ -760,7 +781,20 @@ class RemnaWaveService:
|
||||
"is_xray_running": node.is_xray_running,
|
||||
"users_online": node.users_online or 0,
|
||||
"traffic_used_bytes": node.traffic_used_bytes or 0,
|
||||
"traffic_limit_bytes": node.traffic_limit_bytes or 0
|
||||
"traffic_limit_bytes": node.traffic_limit_bytes or 0,
|
||||
"last_status_change": node.last_status_change,
|
||||
"last_status_message": node.last_status_message,
|
||||
"xray_uptime": node.xray_uptime,
|
||||
"is_traffic_tracking_active": node.is_traffic_tracking_active,
|
||||
"traffic_reset_day": node.traffic_reset_day,
|
||||
"notify_percent": node.notify_percent,
|
||||
"consumption_multiplier": node.consumption_multiplier,
|
||||
"cpu_count": node.cpu_count,
|
||||
"cpu_model": node.cpu_model,
|
||||
"total_ram": node.total_ram,
|
||||
"created_at": node.created_at,
|
||||
"updated_at": node.updated_at,
|
||||
"provider_uuid": node.provider_uuid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -818,15 +852,19 @@ class RemnaWaveService:
|
||||
try:
|
||||
async with self.get_api_client() as api:
|
||||
squads = await api.get_internal_squads()
|
||||
|
||||
|
||||
result = []
|
||||
for squad in squads:
|
||||
inbounds = [
|
||||
asdict(inbound) if is_dataclass(inbound) else inbound
|
||||
for inbound in squad.inbounds or []
|
||||
]
|
||||
result.append({
|
||||
'uuid': squad.uuid,
|
||||
'name': squad.name,
|
||||
'members_count': squad.members_count,
|
||||
'inbounds_count': squad.inbounds_count,
|
||||
'inbounds': squad.inbounds
|
||||
'inbounds': inbounds,
|
||||
})
|
||||
|
||||
logger.info(f"✅ Получено {len(result)} сквадов из Remnawave")
|
||||
@@ -1463,10 +1501,10 @@ class RemnaWaveService:
|
||||
|
||||
traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0)
|
||||
traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0
|
||||
|
||||
used_traffic_bytes = panel_user.get('usedTrafficBytes', 0)
|
||||
|
||||
used_traffic_bytes = _get_user_traffic_bytes(panel_user)
|
||||
traffic_used_gb = used_traffic_bytes / (1024**3)
|
||||
|
||||
|
||||
active_squads = panel_user.get('activeInternalSquads', [])
|
||||
squad_uuids = []
|
||||
if isinstance(active_squads, list):
|
||||
@@ -1568,10 +1606,10 @@ class RemnaWaveService:
|
||||
if subscription.status != new_status:
|
||||
subscription.status = new_status
|
||||
logger.debug(f"Обновлен статус подписки: {new_status}")
|
||||
|
||||
used_traffic_bytes = panel_user.get('usedTrafficBytes', 0)
|
||||
|
||||
used_traffic_bytes = _get_user_traffic_bytes(panel_user)
|
||||
traffic_used_gb = used_traffic_bytes / (1024**3)
|
||||
|
||||
|
||||
if abs(subscription.traffic_used_gb - traffic_used_gb) > 0.01:
|
||||
subscription.traffic_used_gb = traffic_used_gb
|
||||
logger.debug(f"Обновлен использованный трафик: {traffic_used_gb} GB")
|
||||
@@ -1827,12 +1865,16 @@ class RemnaWaveService:
|
||||
async with self.get_api_client() as api:
|
||||
squad = await api.get_internal_squad_by_uuid(squad_uuid)
|
||||
if squad:
|
||||
inbounds = [
|
||||
asdict(inbound) if is_dataclass(inbound) else inbound
|
||||
for inbound in squad.inbounds or []
|
||||
]
|
||||
return {
|
||||
'uuid': squad.uuid,
|
||||
'name': squad.name,
|
||||
'members_count': squad.members_count,
|
||||
'inbounds_count': squad.inbounds_count,
|
||||
'inbounds': squad.inbounds
|
||||
'inbounds': inbounds
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
|
||||
@@ -124,10 +124,13 @@ def _safe_int(value: Optional[object], default: int = 0) -> int:
|
||||
|
||||
|
||||
async def _prepare_auto_extend_context(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
cart_data: dict,
|
||||
) -> Optional[AutoExtendContext]:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if subscription is None:
|
||||
logger.info(
|
||||
"🔁 Автопокупка: у пользователя %s нет активной подписки для продления",
|
||||
@@ -233,7 +236,7 @@ async def _auto_extend_subscription(
|
||||
bot: Optional[Bot] = None,
|
||||
) -> bool:
|
||||
try:
|
||||
prepared = await _prepare_auto_extend_context(user, cart_data)
|
||||
prepared = await _prepare_auto_extend_context(db, user, cart_data)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"❌ Автопокупка: ошибка подготовки данных продления для пользователя %s: %s",
|
||||
|
||||
@@ -329,7 +329,9 @@ class MiniAppSubscriptionPurchaseService:
|
||||
"""Builds configuration and pricing for subscription purchases in the mini app."""
|
||||
|
||||
async def build_options(self, db: AsyncSession, user: User) -> PurchaseOptionsContext:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0)
|
||||
currency = (getattr(user, "balance_currency", None) or "RUB").upper()
|
||||
texts = get_texts(getattr(user, "language", None))
|
||||
|
||||
@@ -132,6 +132,13 @@ class SubscriptionService:
|
||||
|
||||
self._last_config_signature = config_signature
|
||||
|
||||
@staticmethod
|
||||
def _resolve_user_tag(subscription: Subscription) -> Optional[str]:
|
||||
if getattr(subscription, "is_trial", False):
|
||||
return settings.get_trial_user_tag()
|
||||
|
||||
return settings.get_paid_subscription_user_tag()
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self._config_error is None
|
||||
@@ -173,7 +180,9 @@ class SubscriptionService:
|
||||
if not validation_success:
|
||||
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
|
||||
return None
|
||||
|
||||
|
||||
user_tag = self._resolve_user_tag(subscription)
|
||||
|
||||
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)
|
||||
@@ -201,6 +210,9 @@ class SubscriptionService:
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if user_tag is not None:
|
||||
update_kwargs['tag'] = user_tag
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
@@ -236,6 +248,9 @@ class SubscriptionService:
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if user_tag is not None:
|
||||
create_kwargs['tag'] = user_tag
|
||||
|
||||
if hwid_limit is not None:
|
||||
create_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
@@ -288,15 +303,17 @@ class SubscriptionService:
|
||||
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date > current_time)
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = current_time
|
||||
await db.commit()
|
||||
is_actually_active = False
|
||||
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
|
||||
|
||||
|
||||
user_tag = self._resolve_user_tag(subscription)
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
|
||||
@@ -314,6 +331,9 @@ class SubscriptionService:
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
|
||||
if user_tag is not None:
|
||||
update_kwargs['tag'] = user_tag
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
|
||||
@@ -220,6 +220,7 @@ class BotConfigurationService:
|
||||
"PRICE_90_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_180_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_360_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PAID_SUBSCRIPTION_USER_TAG": "SUBSCRIPTION_PRICES",
|
||||
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
|
||||
@@ -227,6 +228,7 @@ class BotConfigurationService:
|
||||
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
|
||||
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
|
||||
"TRIAL_WARNING_HOURS": "TRIAL",
|
||||
"TRIAL_USER_TAG": "TRIAL",
|
||||
"SUPPORT_USERNAME": "SUPPORT",
|
||||
"SUPPORT_MENU_ENABLED": "SUPPORT",
|
||||
"SUPPORT_SYSTEM_MODE": "SUPPORT",
|
||||
@@ -643,6 +645,24 @@ class BotConfigurationService:
|
||||
"warning": "Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.",
|
||||
"dependencies": "Результат вызова getMe() в Telegram Bot API",
|
||||
},
|
||||
"TRIAL_USER_TAG": {
|
||||
"description": (
|
||||
"Тег, который бот передаст пользователю при активации триальной подписки в панели RemnaWave."
|
||||
),
|
||||
"format": "До 16 символов: заглавные A-Z, цифры и подчёркивание.",
|
||||
"example": "TRIAL_USER",
|
||||
"warning": "Неверный формат будет проигнорирован при создании пользователя.",
|
||||
"dependencies": "Активация триала и включенная интеграция с RemnaWave",
|
||||
},
|
||||
"PAID_SUBSCRIPTION_USER_TAG": {
|
||||
"description": (
|
||||
"Тег, который бот ставит пользователю при покупке платной подписки в панели RemnaWave."
|
||||
),
|
||||
"format": "До 16 символов: заглавные A-Z, цифры и подчёркивание.",
|
||||
"example": "PAID_USER",
|
||||
"warning": "Если тег не задан или невалиден, существующий тег не будет изменён.",
|
||||
"dependencies": "Оплата подписки и интеграция с RemnaWave",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
+39
-14
@@ -121,27 +121,52 @@ def validate_subscription_period(days: Union[str, int]) -> Optional[int]:
|
||||
|
||||
|
||||
def sanitize_html(text: str) -> str:
|
||||
"""
|
||||
Безопасно санитизирует HTML-текст, заменяя HTML-сущности на соответствующие теги,
|
||||
при этом предотвращая XSS-уязвимости за счет безопасной обработки атрибутов.
|
||||
|
||||
Args:
|
||||
text (str): Текст с HTML-сущностями (например, <b> жирный </b>)
|
||||
|
||||
Returns:
|
||||
str: Санитизированный HTML-текст (например, <b> жирный </b>)
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
text = html.escape(text)
|
||||
# Для безопасности нужно обработать разрешенные теги, заменяя их сущности на теги
|
||||
# Но при этом безопасно обрабатывая атрибуты, чтобы избежать XSS
|
||||
|
||||
allowed_tags = ALLOWED_HTML_TAGS.union(SELF_CLOSING_TAGS)
|
||||
|
||||
# Обработка всех разрешенных тегов
|
||||
for tag in allowed_tags:
|
||||
text = re.sub(
|
||||
f'<(/?{tag}\\b[^>]*)>',
|
||||
lambda m: "<"
|
||||
+ (
|
||||
m.group(1)
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&")
|
||||
)
|
||||
+ ">",
|
||||
text,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
# Паттерн: захватываем <tag>, </tag>, или <tag атрибуты>
|
||||
# Используем более сложный паттерн, чтобы захватить атрибуты до закрывающего >
|
||||
# (?s) - позволяет . захватывать новую строку
|
||||
# [^>]*? - ленивый захват до >
|
||||
pattern = rf'(<)(/?{tag}\b)([^>]*?)(>)'
|
||||
|
||||
def replace_tag(match):
|
||||
opening = match.group(1) # <
|
||||
full_tag_content = match.group(2) # /?tagname
|
||||
attrs_part = match.group(3) # атрибуты (без >)
|
||||
closing = match.group(4) # >
|
||||
|
||||
# Убираем начальный пробел, если есть
|
||||
if attrs_part.startswith(' '):
|
||||
attrs_part = attrs_part[1:]
|
||||
|
||||
# Формируем результат
|
||||
if attrs_part:
|
||||
# Безопасно обрабатываем атрибуты, заменяя только безопасные сущности
|
||||
# Не разворачиваем < и > внутри атрибутов, чтобы избежать XSS
|
||||
processed_attrs = attrs_part.replace('"', '"').replace(''', "'")
|
||||
return f'<{full_tag_content} {processed_attrs}>'
|
||||
else:
|
||||
return f'<{full_tag_content}>'
|
||||
|
||||
text = re.sub(pattern, replace_tag, text, flags=re.IGNORECASE)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
+9
-1
@@ -4,6 +4,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.webapi.docs import add_redoc_endpoint
|
||||
|
||||
from .middleware import RequestLoggingMiddleware
|
||||
from .routes import (
|
||||
@@ -144,11 +145,18 @@ def create_web_api_app() -> FastAPI:
|
||||
title=settings.WEB_API_TITLE,
|
||||
version=settings.WEB_API_VERSION,
|
||||
docs_url=docs_config.get("docs_url"),
|
||||
redoc_url=docs_config.get("redoc_url"),
|
||||
redoc_url=None,
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
swagger_ui_parameters={"persistAuthorization": True},
|
||||
)
|
||||
|
||||
add_redoc_endpoint(
|
||||
app,
|
||||
redoc_url=docs_config.get("redoc_url"),
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
title=settings.WEB_API_TITLE,
|
||||
)
|
||||
|
||||
allowed_origins = settings.get_web_api_allowed_origins()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.openapi.docs import get_redoc_html
|
||||
|
||||
|
||||
def add_redoc_endpoint(
|
||||
app: FastAPI,
|
||||
*,
|
||||
redoc_url: str | None,
|
||||
openapi_url: str | None,
|
||||
title: str | None,
|
||||
) -> None:
|
||||
"""Attach a ReDoc endpoint if docs are enabled.
|
||||
|
||||
The default FastAPI ReDoc handler sometimes renders a blank page when the
|
||||
CDN bundle fails to load. By explicitly registering the handler and
|
||||
pinning the bundle version, we ensure the endpoint always returns a fully
|
||||
rendered page.
|
||||
"""
|
||||
|
||||
if not redoc_url or not openapi_url:
|
||||
return
|
||||
|
||||
for route in app.router.routes:
|
||||
if getattr(route, "path", None) == redoc_url:
|
||||
return
|
||||
|
||||
@app.get(redoc_url, include_in_schema=False)
|
||||
async def redoc_html(): # pragma: no cover - template rendering
|
||||
return get_redoc_html(
|
||||
openapi_url=openapi_url,
|
||||
title=f"{title or app.title} - ReDoc",
|
||||
redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2.1.5/bundles/redoc.standalone.js",
|
||||
)
|
||||
@@ -11,16 +11,20 @@ from app.database.crud.discount_offer import (
|
||||
list_discount_offers,
|
||||
upsert_discount_offer,
|
||||
)
|
||||
from app.handlers.admin.messages import get_custom_users, get_target_users
|
||||
from app.database.crud.promo_offer_log import list_promo_offer_logs
|
||||
from app.database.crud.promo_offer_template import (
|
||||
get_promo_offer_template_by_id,
|
||||
list_promo_offer_templates,
|
||||
update_promo_offer_template,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, Subscription, User
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.promo_offers import (
|
||||
PromoOfferBroadcastRequest,
|
||||
PromoOfferBroadcastResponse,
|
||||
PromoOfferCreateRequest,
|
||||
PromoOfferListResponse,
|
||||
PromoOfferLogListResponse,
|
||||
@@ -137,6 +141,14 @@ def _build_log_response(entry: PromoOfferLog) -> PromoOfferLogResponse:
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
|
||||
normalized = target.strip().lower()
|
||||
if normalized.startswith("custom_"):
|
||||
criteria = normalized[len("custom_"):]
|
||||
return await get_custom_users(db, criteria)
|
||||
return await get_target_users(db, normalized)
|
||||
|
||||
|
||||
@router.get("", response_model=PromoOfferListResponse)
|
||||
async def list_promo_offers(
|
||||
_: Any = Security(require_api_token),
|
||||
@@ -144,20 +156,35 @@ async def list_promo_offers(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user_id: Optional[int] = Query(None, ge=1),
|
||||
telegram_id: Optional[int] = Query(None, ge=1),
|
||||
notification_type: Optional[str] = Query(None, min_length=1),
|
||||
is_active: Optional[bool] = Query(None),
|
||||
) -> PromoOfferListResponse:
|
||||
resolved_user_id = user_id
|
||||
if telegram_id is not None:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
if resolved_user_id and resolved_user_id != user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail="telegram_id does not match the provided user_id",
|
||||
)
|
||||
|
||||
resolved_user_id = user.id
|
||||
|
||||
offers = await list_discount_offers(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
user_id=user_id,
|
||||
user_id=resolved_user_id,
|
||||
notification_type=notification_type,
|
||||
is_active=is_active,
|
||||
)
|
||||
total = await count_discount_offers(
|
||||
db,
|
||||
user_id=user_id,
|
||||
user_id=resolved_user_id,
|
||||
notification_type=notification_type,
|
||||
is_active=is_active,
|
||||
)
|
||||
@@ -187,7 +214,26 @@ async def create_promo_offer(
|
||||
if not payload.effect_type.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "effect_type must not be empty")
|
||||
|
||||
user = await db.get(User, payload.user_id)
|
||||
target_user_id = payload.user_id
|
||||
user: Optional[User] = None
|
||||
if payload.telegram_id is not None:
|
||||
user = await get_user_by_telegram_id(db, payload.telegram_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
if target_user_id and target_user_id != user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Provided user_id does not match telegram_id",
|
||||
)
|
||||
|
||||
target_user_id = user.id
|
||||
|
||||
if target_user_id is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "user_id or telegram_id is required")
|
||||
|
||||
if user is None:
|
||||
user = await db.get(User, target_user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
@@ -195,12 +241,12 @@ async def create_promo_offer(
|
||||
subscription = await db.get(Subscription, payload.subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
|
||||
if subscription.user_id != payload.user_id:
|
||||
if subscription.user_id != target_user_id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription does not belong to the user")
|
||||
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
user_id=target_user_id,
|
||||
subscription_id=payload.subscription_id,
|
||||
notification_type=payload.notification_type.strip(),
|
||||
discount_percent=payload.discount_percent,
|
||||
@@ -215,6 +261,101 @@ async def create_promo_offer(
|
||||
return _serialize_offer(offer)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/broadcast",
|
||||
response_model=PromoOfferBroadcastResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def broadcast_promo_offers(
|
||||
payload: PromoOfferBroadcastRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferBroadcastResponse:
|
||||
if payload.discount_percent < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative")
|
||||
if payload.bonus_amount_kopeks < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative")
|
||||
if payload.valid_hours <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive")
|
||||
if not payload.notification_type.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "notification_type must not be empty")
|
||||
if not payload.effect_type.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "effect_type must not be empty")
|
||||
|
||||
recipients: dict[int, User] = {}
|
||||
|
||||
target = payload.target
|
||||
if target:
|
||||
users = await _resolve_target_users(db, target)
|
||||
recipients.update({user.id: user for user in users if user and user.id})
|
||||
|
||||
target_user_id = payload.user_id
|
||||
user: Optional[User] = None
|
||||
if payload.telegram_id is not None:
|
||||
user = await get_user_by_telegram_id(db, payload.telegram_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
if target_user_id and target_user_id != user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Provided user_id does not match telegram_id",
|
||||
)
|
||||
|
||||
target_user_id = user.id
|
||||
|
||||
if target_user_id is not None:
|
||||
if user is None:
|
||||
user = await db.get(User, target_user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
recipients[target_user_id] = user
|
||||
|
||||
if not recipients:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Пустая аудитория: укажите target или конкретного пользователя",
|
||||
)
|
||||
|
||||
if payload.subscription_id is not None:
|
||||
if len(recipients) > 1:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"subscription_id можно использовать только при отправке одному пользователю",
|
||||
)
|
||||
sole_user = next(iter(recipients.values()))
|
||||
subscription = await db.get(Subscription, payload.subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
|
||||
if subscription.user_id != sole_user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Subscription does not belong to the user",
|
||||
)
|
||||
|
||||
created_offers = 0
|
||||
for user in recipients.values():
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=user.id,
|
||||
subscription_id=payload.subscription_id,
|
||||
notification_type=payload.notification_type.strip(),
|
||||
discount_percent=payload.discount_percent,
|
||||
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
||||
valid_hours=payload.valid_hours,
|
||||
effect_type=payload.effect_type,
|
||||
extra_data=payload.extra_data,
|
||||
)
|
||||
if offer:
|
||||
created_offers += 1
|
||||
|
||||
return PromoOfferBroadcastResponse(
|
||||
created_offers=created_offers,
|
||||
user_ids=list(recipients.keys()),
|
||||
target=payload.target,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logs", response_model=PromoOfferLogListResponse)
|
||||
async def get_promo_offer_logs(
|
||||
_: Any = Security(require_api_token),
|
||||
|
||||
@@ -93,6 +93,19 @@ def _serialize_node(node_data: Dict[str, Any]) -> RemnaWaveNode:
|
||||
users_online=node_data.get("users_online"),
|
||||
traffic_used_bytes=node_data.get("traffic_used_bytes"),
|
||||
traffic_limit_bytes=node_data.get("traffic_limit_bytes"),
|
||||
last_status_change=_parse_last_updated(node_data.get("last_status_change")),
|
||||
last_status_message=node_data.get("last_status_message"),
|
||||
xray_uptime=node_data.get("xray_uptime"),
|
||||
is_traffic_tracking_active=bool(node_data.get("is_traffic_tracking_active", False)),
|
||||
traffic_reset_day=node_data.get("traffic_reset_day"),
|
||||
notify_percent=node_data.get("notify_percent"),
|
||||
consumption_multiplier=float(node_data.get("consumption_multiplier", 1.0)),
|
||||
cpu_count=node_data.get("cpu_count"),
|
||||
cpu_model=node_data.get("cpu_model"),
|
||||
total_ram=node_data.get("total_ram"),
|
||||
created_at=_parse_last_updated(node_data.get("created_at")),
|
||||
updated_at=_parse_last_updated(node_data.get("updated_at")),
|
||||
provider_uuid=node_data.get("provider_uuid"),
|
||||
)
|
||||
|
||||
|
||||
@@ -291,9 +304,13 @@ async def create_squad(
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
success = await service.create_squad(payload.name, payload.inbound_uuids)
|
||||
squad_uuid = await service.create_squad(payload.name, payload.inbound_uuids)
|
||||
|
||||
success = squad_uuid is not None
|
||||
detail = "Сквад успешно создан" if success else "Не удалось создать сквад"
|
||||
return RemnaWaveOperationResponse(success=success, detail=detail)
|
||||
data = {"uuid": squad_uuid} if success else None
|
||||
|
||||
return RemnaWaveOperationResponse(success=success, detail=detail, data=data)
|
||||
|
||||
|
||||
@router.patch("/squads/{squad_uuid}", response_model=RemnaWaveOperationResponse)
|
||||
|
||||
+214
-39
@@ -2,6 +2,11 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.database.crud.referral import get_referral_statistics
|
||||
from app.database.crud.subscription import get_subscriptions_statistics, get_trial_statistics
|
||||
from app.database.crud.transaction import get_transactions_statistics
|
||||
from app.database.crud.user import get_users_statistics
|
||||
|
||||
from fastapi import APIRouter, Depends, Security
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -22,43 +27,11 @@ from ..dependencies import get_db_session, require_api_token
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/overview",
|
||||
summary="Общая статистика",
|
||||
response_description="Агрегированные показатели пользователей, подписок, саппорта и платежей",
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"users": {
|
||||
"total": 12345,
|
||||
"active": 9876,
|
||||
"blocked": 321,
|
||||
"balance_kopeks": 1234567,
|
||||
"balance_rubles": 12345.67,
|
||||
},
|
||||
"subscriptions": {
|
||||
"active": 4321,
|
||||
"expired": 210,
|
||||
},
|
||||
"support": {
|
||||
"open_tickets": 42,
|
||||
},
|
||||
"payments": {
|
||||
"today_kopeks": 654321,
|
||||
"today_rubles": 6543.21,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
async def stats_overview(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, object]:
|
||||
def _kopeks_to_rubles(value: int | float | None) -> float:
|
||||
return round((value or 0) / 100, 2)
|
||||
|
||||
|
||||
async def _get_overview(db: AsyncSession) -> dict[str, object]:
|
||||
total_users = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||
active_users = await db.scalar(
|
||||
select(func.count()).select_from(User).where(User.status == UserStatus.ACTIVE.value)
|
||||
@@ -103,7 +76,7 @@ async def stats_overview(
|
||||
"active": active_users,
|
||||
"blocked": blocked_users,
|
||||
"balance_kopeks": int(total_balance_kopeks),
|
||||
"balance_rubles": round(total_balance_kopeks / 100, 2),
|
||||
"balance_rubles": _kopeks_to_rubles(total_balance_kopeks),
|
||||
},
|
||||
"subscriptions": {
|
||||
"active": active_subscriptions,
|
||||
@@ -114,6 +87,208 @@ async def stats_overview(
|
||||
},
|
||||
"payments": {
|
||||
"today_kopeks": int(today_transactions),
|
||||
"today_rubles": round(today_transactions / 100, 2),
|
||||
"today_rubles": _kopeks_to_rubles(today_transactions),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/overview",
|
||||
summary="Общая статистика",
|
||||
response_description="Агрегированные показатели пользователей, подписок, саппорта и платежей",
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"users": {
|
||||
"total": 12345,
|
||||
"active": 9876,
|
||||
"blocked": 321,
|
||||
"balance_kopeks": 1234567,
|
||||
"balance_rubles": 12345.67,
|
||||
},
|
||||
"subscriptions": {
|
||||
"active": 4321,
|
||||
"expired": 210,
|
||||
},
|
||||
"support": {
|
||||
"open_tickets": 42,
|
||||
},
|
||||
"payments": {
|
||||
"today_kopeks": 654321,
|
||||
"today_rubles": 6543.21,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
async def stats_overview(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, object]:
|
||||
return await _get_overview(db)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/full",
|
||||
summary="Полная статистика",
|
||||
response_description="Расширенные показатели пользователей, подписок, платежей и рефералов",
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"overview": {
|
||||
"users": {
|
||||
"total": 12345,
|
||||
"active": 9876,
|
||||
"blocked": 321,
|
||||
"balance_kopeks": 1234567,
|
||||
"balance_rubles": 12345.67,
|
||||
},
|
||||
"subscriptions": {
|
||||
"active": 4321,
|
||||
"expired": 210,
|
||||
},
|
||||
"support": {
|
||||
"open_tickets": 42,
|
||||
},
|
||||
"payments": {
|
||||
"today_kopeks": 654321,
|
||||
"today_rubles": 6543.21,
|
||||
},
|
||||
},
|
||||
"users": {
|
||||
"total_users": 12345,
|
||||
"active_users": 9876,
|
||||
"blocked_users": 321,
|
||||
"new_today": 12,
|
||||
"new_week": 345,
|
||||
"new_month": 1234,
|
||||
},
|
||||
"subscriptions": {
|
||||
"total_subscriptions": 9876,
|
||||
"active_subscriptions": 8765,
|
||||
"trial_subscriptions": 321,
|
||||
"paid_subscriptions": 8444,
|
||||
"purchased_today": 12,
|
||||
"purchased_week": 210,
|
||||
"purchased_month": 765,
|
||||
"trial_to_paid_conversion": 42.5,
|
||||
"renewals_count": 123,
|
||||
"trial_statistics": {
|
||||
"used_trials": 555,
|
||||
"active_trials": 210,
|
||||
"resettable_trials": 42,
|
||||
},
|
||||
},
|
||||
"transactions": {
|
||||
"period": {
|
||||
"start_date": "2024-06-01T00:00:00Z",
|
||||
"end_date": "2024-06-30T23:59:59Z",
|
||||
},
|
||||
"totals": {
|
||||
"income_kopeks": 1234567,
|
||||
"income_rubles": 12345.67,
|
||||
"expenses_kopeks": 21000,
|
||||
"expenses_rubles": 210,
|
||||
"profit_kopeks": 1213567,
|
||||
"profit_rubles": 12135.67,
|
||||
"subscription_income_kopeks": 987654,
|
||||
"subscription_income_rubles": 9876.54,
|
||||
},
|
||||
"today": {
|
||||
"transactions_count": 42,
|
||||
"income_kopeks": 654321,
|
||||
"income_rubles": 6543.21,
|
||||
},
|
||||
"by_type": {
|
||||
"deposit": {"count": 123, "amount": 1234567},
|
||||
"withdrawal": {"count": 10, "amount": 21000},
|
||||
},
|
||||
"by_payment_method": {
|
||||
"card": {"count": 100, "amount": 1000000}
|
||||
},
|
||||
},
|
||||
"referrals": {
|
||||
"users_with_referrals": 4321,
|
||||
"active_referrers": 123,
|
||||
"total_paid_kopeks": 765432,
|
||||
"total_paid_rubles": 7654.32,
|
||||
"today_earnings_kopeks": 12345,
|
||||
"today_earnings_rubles": 123.45,
|
||||
"week_earnings_kopeks": 23456,
|
||||
"week_earnings_rubles": 234.56,
|
||||
"month_earnings_kopeks": 34567,
|
||||
"month_earnings_rubles": 345.67,
|
||||
"top_referrers": [
|
||||
{
|
||||
"user_id": 123456789,
|
||||
"display_name": "@testuser",
|
||||
"username": "testuser",
|
||||
"telegram_id": 123456789,
|
||||
"total_earned_kopeks": 54321,
|
||||
"referrals_count": 42,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
async def stats_full(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, object]:
|
||||
overview = await _get_overview(db)
|
||||
|
||||
users_stats = await get_users_statistics(db)
|
||||
subscriptions_stats = await get_subscriptions_statistics(db)
|
||||
trial_stats = await get_trial_statistics(db)
|
||||
transactions_stats = await get_transactions_statistics(db)
|
||||
referral_stats = await get_referral_statistics(db)
|
||||
|
||||
transactions_totals = transactions_stats.get("totals", {})
|
||||
transactions_today = transactions_stats.get("today", {})
|
||||
|
||||
transactions_totals = {
|
||||
**transactions_totals,
|
||||
"income_rubles": _kopeks_to_rubles(transactions_totals.get("income_kopeks")),
|
||||
"expenses_rubles": _kopeks_to_rubles(transactions_totals.get("expenses_kopeks")),
|
||||
"profit_rubles": _kopeks_to_rubles(transactions_totals.get("profit_kopeks")),
|
||||
"subscription_income_rubles": _kopeks_to_rubles(
|
||||
transactions_totals.get("subscription_income_kopeks")
|
||||
),
|
||||
}
|
||||
|
||||
transactions_today = {
|
||||
**transactions_today,
|
||||
"income_rubles": _kopeks_to_rubles(transactions_today.get("income_kopeks")),
|
||||
}
|
||||
|
||||
referral_stats = {
|
||||
**referral_stats,
|
||||
"total_paid_rubles": _kopeks_to_rubles(referral_stats.get("total_paid_kopeks")),
|
||||
"today_earnings_rubles": _kopeks_to_rubles(
|
||||
referral_stats.get("today_earnings_kopeks")
|
||||
),
|
||||
"week_earnings_rubles": _kopeks_to_rubles(referral_stats.get("week_earnings_kopeks")),
|
||||
"month_earnings_rubles": _kopeks_to_rubles(referral_stats.get("month_earnings_kopeks")),
|
||||
}
|
||||
|
||||
return {
|
||||
"overview": overview,
|
||||
"users": users_stats,
|
||||
"subscriptions": {**subscriptions_stats, "trial_statistics": trial_stats},
|
||||
"transactions": {
|
||||
**transactions_stats,
|
||||
"totals": transactions_totals,
|
||||
"today": transactions_today,
|
||||
},
|
||||
"referrals": referral_stats,
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
class PromoOfferUserInfo(BaseModel):
|
||||
@@ -50,7 +50,8 @@ class PromoOfferListResponse(BaseModel):
|
||||
|
||||
|
||||
class PromoOfferCreateRequest(BaseModel):
|
||||
user_id: int
|
||||
user_id: Optional[int] = Field(None, ge=1)
|
||||
telegram_id: Optional[int] = Field(None, ge=1)
|
||||
notification_type: str = Field(..., min_length=1)
|
||||
valid_hours: int = Field(..., ge=1, description="Срок действия предложения в часах")
|
||||
discount_percent: int = Field(0, ge=0)
|
||||
@@ -60,6 +61,65 @@ class PromoOfferCreateRequest(BaseModel):
|
||||
extra_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PromoOfferBroadcastRequest(PromoOfferCreateRequest):
|
||||
target: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Категория пользователей для рассылки. Поддерживает те же сегменты, что "
|
||||
"и API рассылок (all, active, trial, custom_today и т.д.)."
|
||||
),
|
||||
)
|
||||
|
||||
_ALLOWED_TARGETS: ClassVar[set[str]] = {
|
||||
"all",
|
||||
"active",
|
||||
"trial",
|
||||
"no",
|
||||
"expiring",
|
||||
"expired",
|
||||
"active_zero",
|
||||
"trial_zero",
|
||||
"zero",
|
||||
}
|
||||
_CUSTOM_TARGETS: ClassVar[set[str]] = {
|
||||
"today",
|
||||
"week",
|
||||
"month",
|
||||
"active_today",
|
||||
"inactive_week",
|
||||
"inactive_month",
|
||||
"referrals",
|
||||
"direct",
|
||||
}
|
||||
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
|
||||
"no_sub": "no",
|
||||
}
|
||||
|
||||
@validator("target")
|
||||
def validate_target(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
normalized = value.strip().lower()
|
||||
normalized = cls._TARGET_ALIASES.get(normalized, normalized)
|
||||
|
||||
if normalized in cls._ALLOWED_TARGETS:
|
||||
return normalized
|
||||
|
||||
if normalized.startswith("custom_"):
|
||||
criteria = normalized[len("custom_"):]
|
||||
if criteria in cls._CUSTOM_TARGETS:
|
||||
return normalized
|
||||
|
||||
raise ValueError("Unsupported target value")
|
||||
|
||||
|
||||
class PromoOfferBroadcastResponse(BaseModel):
|
||||
created_offers: int
|
||||
user_ids: List[int]
|
||||
target: Optional[str] = None
|
||||
|
||||
|
||||
class PromoOfferTemplateResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@@ -32,6 +32,19 @@ class RemnaWaveNode(BaseModel):
|
||||
users_online: Optional[int] = None
|
||||
traffic_used_bytes: Optional[int] = None
|
||||
traffic_limit_bytes: Optional[int] = None
|
||||
last_status_change: Optional[datetime] = None
|
||||
last_status_message: Optional[str] = None
|
||||
xray_uptime: Optional[str] = None
|
||||
is_traffic_tracking_active: bool = False
|
||||
traffic_reset_day: Optional[int] = None
|
||||
notify_percent: Optional[int] = None
|
||||
consumption_multiplier: float = 1.0
|
||||
cpu_count: Optional[int] = None
|
||||
cpu_model: Optional[str] = None
|
||||
total_ram: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
provider_uuid: Optional[str] = None
|
||||
|
||||
|
||||
class RemnaWaveNodeListResponse(BaseModel):
|
||||
|
||||
@@ -13,6 +13,7 @@ from aiogram import Dispatcher
|
||||
from app.config import settings
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.webapi.app import create_web_api_app
|
||||
from app.webapi.docs import add_redoc_endpoint
|
||||
|
||||
from . import payments
|
||||
from . import telegram
|
||||
@@ -47,12 +48,19 @@ def _create_base_app() -> FastAPI:
|
||||
app = create_web_api_app()
|
||||
else:
|
||||
app = FastAPI(
|
||||
title="Bedolaga Unified Server",
|
||||
version=settings.WEB_API_VERSION,
|
||||
docs_url=docs_config.get("docs_url"),
|
||||
redoc_url=docs_config.get("redoc_url"),
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
)
|
||||
title="Bedolaga Unified Server",
|
||||
version=settings.WEB_API_VERSION,
|
||||
docs_url=docs_config.get("docs_url"),
|
||||
redoc_url=None,
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
)
|
||||
|
||||
add_redoc_endpoint(
|
||||
app,
|
||||
redoc_url=docs_config.get("redoc_url"),
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
title="Bedolaga Unified Server",
|
||||
)
|
||||
|
||||
_attach_docs_alias(app, app.docs_url)
|
||||
return app
|
||||
|
||||
@@ -128,7 +128,7 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
|
||||
| `PATCH` | `/promo-groups/{id}` | Обновить промо-группу.
|
||||
| `DELETE` | `/promo-groups/{id}` | Удалить промо-группу.
|
||||
| `GET` | `/promo-offers` | Список промо-предложений с фильтрами по пользователю, статусу и типу уведомления.
|
||||
| `POST` | `/promo-offers` | Создать или обновить персональное промо-предложение пользователю.
|
||||
| `POST` | `/promo-offers` | Создать или обновить персональное промо-предложение пользователю. ID может быть как внутренним (user.id), так и Telegram ID (user.telegram_id).
|
||||
| `GET` | `/promo-offers/{id}` | Детали конкретного промо-предложения.
|
||||
| `GET` | `/promo-offers/templates` | Список шаблонов промо-предложений.
|
||||
| `GET` | `/promo-offers/templates/{id}` | Получить данные шаблона промо-предложения.
|
||||
|
||||
@@ -133,7 +133,6 @@ async def test_unified_app_docs_enabled_with_alias(monkeypatch: pytest.MonkeyPat
|
||||
app = _build_unified_app(monkeypatch, docs_enabled=True)
|
||||
|
||||
assert app.docs_url == "/docs"
|
||||
assert app.redoc_url == "/redoc"
|
||||
assert app.openapi_url == "/openapi.json"
|
||||
|
||||
alias_route = next(
|
||||
@@ -143,6 +142,16 @@ async def test_unified_app_docs_enabled_with_alias(monkeypatch: pytest.MonkeyPat
|
||||
assert alias_route is not None
|
||||
assert getattr(alias_route, "include_in_schema", True) is False
|
||||
|
||||
redoc_route = next(
|
||||
(route for route in app.routes if getattr(route, "path", None) == "/redoc"),
|
||||
None,
|
||||
)
|
||||
assert redoc_route is not None
|
||||
assert getattr(redoc_route, "include_in_schema", True) is False
|
||||
|
||||
response = await alias_route.endpoint() # type: ignore[func-returns-value]
|
||||
assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT
|
||||
assert response.headers["location"] == "/docs"
|
||||
|
||||
redoc_response = await redoc_route.endpoint() # type: ignore[func-returns-value]
|
||||
assert b"ReDoc" in redoc_response.body # type: ignore[attr-defined]
|
||||
|
||||
Reference in New Issue
Block a user