Compare commits
224 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29743351de | |||
| fd897464f5 | |||
| d9b0420202 | |||
| 527d907035 | |||
| 6c3270e6d7 | |||
| b7382eb7c3 | |||
| 0cd7d45338 | |||
| 23dcb9699c | |||
| 9603952b2a | |||
| 299eeee9d3 | |||
| 63c7e9c08b | |||
| 42131457bf | |||
| a7370fbe52 | |||
| 41f0a15cea | |||
| dd92f37fc1 | |||
| 46d4d699dd | |||
| 76b594363b | |||
| 107b18d642 | |||
| 9973491503 | |||
| 418081048e | |||
| f22838b99f | |||
| 9bdacea568 | |||
| 6bec0ed0c8 | |||
| 4363955443 | |||
| 47a272c7e8 | |||
| 07ce852917 | |||
| a6ada5c9e3 | |||
| 90c5b1d003 | |||
| 326aca31d7 | |||
| 4fd5ba863b | |||
| 4bd0b221e7 | |||
| f47511f26a | |||
| 2d409eaa1f | |||
| 9d42c4fb7d | |||
| d3ced8f7e2 | |||
| a5715b2ca8 | |||
| c18dd604d8 | |||
| c2656f4ee6 | |||
| 18641d46d3 | |||
| 7ef3a08215 | |||
| 55f1a3b220 | |||
| e2f313e6ea | |||
| 5f99478433 | |||
| 89f0a3663d | |||
| e53dab87e3 | |||
| 6d85c179bb | |||
| 30dafcef49 | |||
| 32d7a7394e | |||
| 3cb4f27433 | |||
| 50692566d5 | |||
| fbb2afd9e1 | |||
| 3de951b153 | |||
| 1b86d467b5 | |||
| e4743ced9b | |||
| 2351056d56 | |||
| 748f9a2393 | |||
| ada15065ea | |||
| 53f1831f59 | |||
| 860ebf18b6 | |||
| ae05ca08ea | |||
| 88e8a56526 | |||
| fb4d714441 | |||
| 2690f63a82 | |||
| ec9855a0a9 | |||
| 984ad8e448 | |||
| ca81b32502 | |||
| 70f190501b | |||
| 432efdbb7c | |||
| 83a821a591 | |||
| 14bdfd4a1e | |||
| 66ff2a38d3 | |||
| 9f7c9cad38 | |||
| 2132226b15 | |||
| 849465b2e1 | |||
| 402b31625b | |||
| 5fc24dbfae | |||
| 8ba65cc2fe | |||
| ee2bffe08e | |||
| c4b5db9002 | |||
| 1a78bdc194 | |||
| a3bb749a6a | |||
| 626f78b230 | |||
| 5d44285c0a | |||
| 2d8091ddcb | |||
| fa6a4698ab | |||
| f40454313f | |||
| 8a256b1bc2 | |||
| 71ac7ea464 | |||
| bbc9f93429 | |||
| 3ba9598b5e | |||
| ffa51986a8 | |||
| 7e20140a00 | |||
| 50d080f54e | |||
| 29829b1c8c | |||
| e84e707d2f | |||
| c12566ae53 | |||
| e6976b5262 | |||
| 81cfdcf7ac | |||
| 9c1ca5b748 | |||
| a0d14f97f4 | |||
| 8fdc70fdc3 | |||
| d93139a458 | |||
| b78dc12db9 | |||
| 2a02f589bf | |||
| bcfa4f8400 | |||
| 042dc900ef | |||
| 6004607bf6 | |||
| 40e4b1ce7f | |||
| a4c5a2295f | |||
| 3d9a75ff51 | |||
| 1216a4dd9d | |||
| 4bc1c402e9 | |||
| 42a758bd68 | |||
| 07a0d0ca43 | |||
| f53b04444b | |||
| 9877c00c92 | |||
| 14aa175193 | |||
| 562766a637 | |||
| 4616911189 | |||
| 1e14a0919f | |||
| e1f0bd152c | |||
| c10e34ad43 | |||
| 714f742560 | |||
| dc5e440fe2 | |||
| d53873dc7f | |||
| 10ab557d8d | |||
| 81147d42a9 | |||
| 8d5e2037d3 | |||
| 7f03e61b5f | |||
| 870ee2fb66 | |||
| a525079282 | |||
| 39aff2bc1b | |||
| b4dc9f1970 | |||
| 37ac3e7a81 | |||
| e3e6428d2a | |||
| 3e3385d1eb | |||
| 0375e74227 | |||
| b6bdeeefdd | |||
| 24b3660f7b | |||
| da2c30cd8d | |||
| 1fad95c0bb | |||
| 6a1b1a9a91 | |||
| 9abf0b6ebe | |||
| 34e90b335b | |||
| e84c82f29d | |||
| 1c929a1a70 | |||
| 1c8878e651 | |||
| 84645d84f7 | |||
| 0cc59501bc | |||
| ef6aaa58ea | |||
| 942ce48241 | |||
| 625ec43c34 | |||
| f184303415 | |||
| 84a17864cd | |||
| 1476247519 | |||
| 0ea95005ee | |||
| ba808f66da | |||
| e8472a502f | |||
| 7d552ab715 | |||
| 00de3a41df | |||
| 36f38187d9 | |||
| dd68b2b788 | |||
| 7a9d37c884 | |||
| 6a904d4be2 | |||
| b5b8408055 | |||
| 86a3228579 | |||
| 560993c0f3 | |||
| a3fe935149 | |||
| a75549aebf | |||
| be3fc7aa09 | |||
| 6cbfc6d74b | |||
| 7ff7a051d9 | |||
| 9a2f156158 | |||
| 38b058636c | |||
| 32523f5dea | |||
| ff70e38dbc | |||
| 8110f52bc3 | |||
| a5a0176433 | |||
| 2d960e3e30 | |||
| b5fa978023 | |||
| 9cc49e9f66 | |||
| dbf63e518f | |||
| 3e218b6e6d | |||
| 2f52f6b8de | |||
| 5d377cf885 | |||
| 62ecacc3cc | |||
| 4ab6d45e50 | |||
| d1bed5e41a | |||
| b907f884ba | |||
| 83a76c928d | |||
| 90d3f3aef2 | |||
| 263df606d2 | |||
| 2a38969e3d | |||
| 300feec18e | |||
| 7c56452eb4 | |||
| 548ec540f2 | |||
| bceba34cc3 | |||
| b07ce2be5c | |||
| 9472cf5e8c | |||
| 82f34e8c6e | |||
| 02b718dac5 | |||
| a65a23acb2 | |||
| 12523e7515 | |||
| d3497f924a | |||
| 75faa54b85 | |||
| d22f9aaf62 | |||
| 68f5e2ab80 | |||
| 4e2b71cb85 | |||
| e229a7ee65 | |||
| abf1f53218 | |||
| 2859219f80 | |||
| e71b7f4533 | |||
| 5ba652fab6 | |||
| 0a3913a2aa | |||
| 118ef8a29f | |||
| b5f7e06605 | |||
| d258d27406 | |||
| 554fb28cdb | |||
| 808901c38b | |||
| 68508d8a86 | |||
| 0daf8d7691 | |||
| 5aeb2038c5 | |||
| d3a1060117 | |||
| 4d22e99076 |
@@ -89,8 +89,13 @@ MAX_DEVICES_LIMIT=15
|
||||
# Дефолт параметры для подписок выданных через админку
|
||||
DEFAULT_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# Плюсовать дни триала при покупке платной подписке
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID=false
|
||||
|
||||
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
|
||||
# Сбрасывать трафик при каждой оплате подписки
|
||||
RESET_TRAFFIC_ON_PAYMENT=false
|
||||
|
||||
# ===== НАСТРОЙКИ ТРАФИКА =====
|
||||
# Режим выбора трафика:
|
||||
@@ -338,6 +343,8 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
|
||||
@@ -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.4.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.1-$(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.4.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.1-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.4.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.1-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.4.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.1-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.4.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.1-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.4.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.1-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
!app-config.json
|
||||
!main.py
|
||||
!requirements.txt
|
||||
!docs/
|
||||
!docs/**
|
||||
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.4.0"
|
||||
ARG VERSION="v2.4.1"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
EXPOSE 8081 8082 8083
|
||||
EXPOSE 8081 8082
|
||||
|
||||
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
|
||||
|
||||
@@ -108,6 +108,10 @@ docker compose logs
|
||||
| 🛡️ **REMNAWAVE_SECRET_KEY** | Ключ защиты панели | `secret_name:secret_value` |
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
|
||||
### 🌐 Интеграция веб-админки
|
||||
|
||||
Подробное пошаговое руководство по запуску административного веб-API и подключению внешней панели находится в [docs/web-admin-integration.md](docs/web-admin-integration.md).
|
||||
|
||||
### 📊 Статус серверов в главном меню
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
@@ -331,6 +335,8 @@ DEFAULT_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
|
||||
# Сбрасывать трафик при каждой оплате подписки
|
||||
RESET_TRAFFIC_ON_PAYMENT=false
|
||||
|
||||
# ===== НАСТРОЙКИ ТРАФИКА =====
|
||||
# Режим выбора трафика:
|
||||
@@ -578,6 +584,8 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
|
||||
+46
-2
@@ -66,10 +66,12 @@ class Settings(BaseSettings):
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
TRIAL_SQUAD_UUID: str = ""
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
RESET_TRAFFIC_ON_PAYMENT: bool = False
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
|
||||
TRIAL_WARNING_HOURS: int = 2
|
||||
@@ -224,6 +226,7 @@ class Settings(BaseSettings):
|
||||
|
||||
DEFAULT_LANGUAGE: str = "ru"
|
||||
AVAILABLE_LANGUAGES: str = "ru,en"
|
||||
LANGUAGE_SELECTION_ENABLED: bool = True
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FILE: str = "logs/bot.log"
|
||||
@@ -231,6 +234,19 @@ class Settings(BaseSettings):
|
||||
DEBUG: bool = False
|
||||
WEBHOOK_URL: Optional[str] = None
|
||||
WEBHOOK_PATH: str = "/webhook"
|
||||
|
||||
WEB_API_ENABLED: bool = False
|
||||
WEB_API_HOST: str = "0.0.0.0"
|
||||
WEB_API_PORT: int = 8080
|
||||
WEB_API_WORKERS: int = 1
|
||||
WEB_API_ALLOWED_ORIGINS: str = "*"
|
||||
WEB_API_DOCS_ENABLED: bool = False
|
||||
WEB_API_TITLE: str = "Remnawave Bot Admin API"
|
||||
WEB_API_VERSION: str = "1.0.0"
|
||||
WEB_API_DEFAULT_TOKEN: Optional[str] = None
|
||||
WEB_API_DEFAULT_TOKEN_NAME: str = "Bootstrap Token"
|
||||
WEB_API_TOKEN_HASH_ALGORITHM: str = "sha256"
|
||||
WEB_API_REQUEST_LOGGING: bool = True
|
||||
|
||||
APP_CONFIG_PATH: str = "app-config.json"
|
||||
ENABLE_DEEP_LINKS: bool = True
|
||||
@@ -437,7 +453,10 @@ class Settings(BaseSettings):
|
||||
return ["ru", "en"]
|
||||
except AttributeError:
|
||||
return ["ru", "en"]
|
||||
|
||||
|
||||
def is_language_selection_enabled(self) -> bool:
|
||||
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
|
||||
|
||||
def format_price(self, price_kopeks: int) -> str:
|
||||
rubles = price_kopeks // 100
|
||||
return f"{rubles} ₽"
|
||||
@@ -556,6 +575,13 @@ class Settings(BaseSettings):
|
||||
def is_happ_download_button_enabled(self) -> bool:
|
||||
return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED
|
||||
|
||||
def should_hide_subscription_link(self) -> bool:
|
||||
"""Returns True when subscription links must be hidden from the interface."""
|
||||
|
||||
if self.is_happ_cryptolink_mode():
|
||||
return False
|
||||
return self.HIDE_SUBSCRIPTION_LINK
|
||||
|
||||
def get_happ_cryptolink_redirect_template(self) -> Optional[str]:
|
||||
template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip()
|
||||
return template or None
|
||||
@@ -954,7 +980,25 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_server_status_request_timeout(self) -> int:
|
||||
return max(1, self.SERVER_STATUS_REQUEST_TIMEOUT)
|
||||
|
||||
|
||||
def is_web_api_enabled(self) -> bool:
|
||||
return bool(self.WEB_API_ENABLED)
|
||||
|
||||
def get_web_api_allowed_origins(self) -> list[str]:
|
||||
raw = (self.WEB_API_ALLOWED_ORIGINS or "").split(",")
|
||||
origins = [origin.strip() for origin in raw if origin.strip()]
|
||||
return origins or ["*"]
|
||||
|
||||
def get_web_api_docs_config(self) -> Dict[str, Optional[str]]:
|
||||
if self.WEB_API_DOCS_ENABLED:
|
||||
return {
|
||||
"docs_url": "/docs",
|
||||
"redoc_url": "/redoc",
|
||||
"openapi_url": "/openapi.json",
|
||||
}
|
||||
|
||||
return {"docs_url": None, "redoc_url": None, "openapi_url": None}
|
||||
|
||||
def get_support_system_mode(self) -> str:
|
||||
mode = (self.SUPPORT_SYSTEM_MODE or "both").strip().lower()
|
||||
return mode if mode in {"tickets", "contact", "both"} else "both"
|
||||
|
||||
@@ -32,6 +32,7 @@ async def create_campaign(
|
||||
subscription_traffic_gb: Optional[int] = None,
|
||||
subscription_device_limit: Optional[int] = None,
|
||||
subscription_squads: Optional[List[str]] = None,
|
||||
is_active: bool = True,
|
||||
) -> AdvertisingCampaign:
|
||||
campaign = AdvertisingCampaign(
|
||||
name=name,
|
||||
@@ -43,7 +44,7 @@ async def create_campaign(
|
||||
subscription_device_limit=subscription_device_limit,
|
||||
subscription_squads=subscription_squads or [],
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
db.add(campaign)
|
||||
@@ -135,7 +136,10 @@ async def update_campaign(
|
||||
"is_active",
|
||||
}
|
||||
|
||||
update_data = {key: value for key, value in kwargs.items() if key in allowed_fields}
|
||||
update_data = {}
|
||||
for key, value in kwargs.items():
|
||||
if key in allowed_fields and value is not None:
|
||||
update_data[key] = value
|
||||
|
||||
if not update_data:
|
||||
return campaign
|
||||
|
||||
@@ -30,13 +30,23 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_promo_groups_with_counts(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[Tuple[PromoGroup, int]]:
|
||||
result = await db.execute(
|
||||
query = (
|
||||
select(PromoGroup, func.count(User.id))
|
||||
.outerjoin(User, User.promo_group_id == PromoGroup.id)
|
||||
.group_by(PromoGroup.id)
|
||||
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
|
||||
)
|
||||
|
||||
if offset:
|
||||
query = query.offset(offset)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.all()
|
||||
|
||||
|
||||
@@ -44,6 +54,11 @@ async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[Pro
|
||||
return await db.get(PromoGroup, group_id)
|
||||
|
||||
|
||||
async def count_promo_groups(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count(PromoGroup.id)))
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.is_default.is_(True))
|
||||
@@ -61,6 +76,7 @@ async def create_promo_group(
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None,
|
||||
apply_discounts_to_addons: bool = True,
|
||||
is_default: bool = False,
|
||||
) -> PromoGroup:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
|
||||
@@ -70,6 +86,9 @@ async def create_promo_group(
|
||||
else None
|
||||
)
|
||||
|
||||
existing_default = await get_default_promo_group(db)
|
||||
should_be_default = existing_default is None or is_default
|
||||
|
||||
promo_group = PromoGroup(
|
||||
name=name.strip(),
|
||||
server_discount_percent=max(0, min(100, server_discount_percent)),
|
||||
@@ -78,16 +97,26 @@ async def create_promo_group(
|
||||
period_discounts=normalized_period_discounts or None,
|
||||
auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=bool(apply_discounts_to_addons),
|
||||
is_default=False,
|
||||
is_default=should_be_default,
|
||||
)
|
||||
|
||||
db.add(promo_group)
|
||||
await db.flush()
|
||||
|
||||
if should_be_default and existing_default and existing_default.id != promo_group.id:
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id != promo_group.id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(promo_group)
|
||||
|
||||
logger.info(
|
||||
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
|
||||
"Создана промогруппа '%s' (default=%s) с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
|
||||
promo_group.name,
|
||||
promo_group.is_default,
|
||||
promo_group.server_discount_percent,
|
||||
promo_group.traffic_discount_percent,
|
||||
promo_group.device_discount_percent,
|
||||
@@ -110,6 +139,7 @@ async def update_promo_group(
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None,
|
||||
apply_discounts_to_addons: Optional[bool] = None,
|
||||
is_default: Optional[bool] = None,
|
||||
) -> PromoGroup:
|
||||
if name is not None:
|
||||
group.name = name.strip()
|
||||
@@ -127,6 +157,36 @@ async def update_promo_group(
|
||||
if apply_discounts_to_addons is not None:
|
||||
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
|
||||
|
||||
if is_default is not None:
|
||||
if is_default:
|
||||
group.is_default = True
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id != group.id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
else:
|
||||
if group.is_default:
|
||||
group.is_default = False
|
||||
await db.flush()
|
||||
replacement = await db.execute(
|
||||
select(PromoGroup)
|
||||
.where(PromoGroup.id != group.id)
|
||||
.order_by(PromoGroup.id)
|
||||
.limit(1)
|
||||
)
|
||||
new_default = replacement.scalars().first()
|
||||
if new_default:
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id == new_default.id)
|
||||
.values(is_default=True)
|
||||
)
|
||||
else:
|
||||
# Не допускаем состояния без базовой промогруппы
|
||||
group.is_default = True
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
@@ -148,6 +208,7 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
|
||||
logger.error("Не найдена базовая промогруппа для reassignment")
|
||||
return False
|
||||
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.promo_group_id == group.id)
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import select, and_, func, update, delete, text
|
||||
from sqlalchemy import (
|
||||
select,
|
||||
and_,
|
||||
func,
|
||||
update,
|
||||
delete,
|
||||
text,
|
||||
or_,
|
||||
cast,
|
||||
String,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription
|
||||
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -232,10 +243,10 @@ async def sync_with_remnawave(
|
||||
db: AsyncSession,
|
||||
remnawave_squads: List[dict]
|
||||
) -> Tuple[int, int, int]:
|
||||
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
disabled = 0
|
||||
removed = 0
|
||||
|
||||
existing_servers = {}
|
||||
result = await db.execute(select(ServerSquad))
|
||||
@@ -265,15 +276,121 @@ async def sync_with_remnawave(
|
||||
)
|
||||
created += 1
|
||||
|
||||
for uuid, server in existing_servers.items():
|
||||
if uuid not in remnawave_uuids and server.is_available:
|
||||
server.is_available = False
|
||||
disabled += 1
|
||||
|
||||
removed_servers = [
|
||||
server for uuid, server in existing_servers.items()
|
||||
if uuid not in remnawave_uuids
|
||||
]
|
||||
|
||||
if removed_servers:
|
||||
removed_ids = [server.id for server in removed_servers]
|
||||
removed_uuids = {server.squad_uuid for server in removed_servers}
|
||||
|
||||
subscription_ids_result = await db.execute(
|
||||
select(SubscriptionServer.subscription_id)
|
||||
.where(SubscriptionServer.server_squad_id.in_(removed_ids))
|
||||
)
|
||||
subscription_ids = {row[0] for row in subscription_ids_result.fetchall()}
|
||||
|
||||
for server in removed_servers:
|
||||
logger.info(
|
||||
"🗑️ Удаляется сервер %s (UUID: %s)",
|
||||
server.display_name,
|
||||
server.squad_uuid,
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids))
|
||||
)
|
||||
|
||||
subscriptions_to_update: dict[int, Subscription] = {}
|
||||
|
||||
if subscription_ids:
|
||||
subscriptions_result = await db.execute(
|
||||
select(Subscription).where(Subscription.id.in_(subscription_ids))
|
||||
)
|
||||
for subscription in subscriptions_result.scalars().unique().all():
|
||||
subscriptions_to_update[subscription.id] = subscription
|
||||
|
||||
for squad_uuid in removed_uuids:
|
||||
if not squad_uuid:
|
||||
continue
|
||||
|
||||
extra_result = await db.execute(
|
||||
select(Subscription).where(
|
||||
text("connected_squads::text LIKE :uuid_pattern")
|
||||
),
|
||||
{"uuid_pattern": f'%"{squad_uuid}"%'}
|
||||
)
|
||||
|
||||
for subscription in extra_result.scalars().unique().all():
|
||||
subscriptions_to_update[subscription.id] = subscription
|
||||
|
||||
cleaned_subscriptions = 0
|
||||
|
||||
for subscription in subscriptions_to_update.values():
|
||||
current_squads = list(subscription.connected_squads or [])
|
||||
if not current_squads:
|
||||
continue
|
||||
|
||||
filtered_squads = [
|
||||
squad_uuid for squad_uuid in current_squads if squad_uuid not in removed_uuids
|
||||
]
|
||||
|
||||
if len(filtered_squads) != len(current_squads):
|
||||
subscription.connected_squads = filtered_squads
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
cleaned_subscriptions += 1
|
||||
|
||||
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
|
||||
removed = len(removed_servers)
|
||||
|
||||
if cleaned_subscriptions:
|
||||
logger.info(
|
||||
"🧹 Обновлены подписки после удаления серверов: %s",
|
||||
cleaned_subscriptions,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
|
||||
return created, updated, disabled
|
||||
|
||||
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{removed}")
|
||||
return created, updated, removed
|
||||
|
||||
|
||||
async def get_server_connected_users(
|
||||
db: AsyncSession,
|
||||
server_id: int
|
||||
) -> List[User]:
|
||||
|
||||
server_uuid_result = await db.execute(
|
||||
select(ServerSquad.squad_uuid).where(ServerSquad.id == server_id)
|
||||
)
|
||||
server_uuid = server_uuid_result.scalar_one_or_none()
|
||||
|
||||
connection_filters = [SubscriptionServer.id.isnot(None)]
|
||||
|
||||
if server_uuid:
|
||||
connection_filters.append(
|
||||
cast(Subscription.connected_squads, String).like(
|
||||
f'%"{server_uuid}"%'
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.join(Subscription, Subscription.user_id == User.id)
|
||||
.outerjoin(
|
||||
SubscriptionServer,
|
||||
and_(
|
||||
SubscriptionServer.subscription_id == Subscription.id,
|
||||
SubscriptionServer.server_squad_id == server_id,
|
||||
),
|
||||
)
|
||||
.where(or_(*connection_filters))
|
||||
.options(selectinload(User.subscription))
|
||||
.order_by(User.id)
|
||||
)
|
||||
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
@@ -116,13 +116,33 @@ async def extend_subscription(
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
|
||||
|
||||
|
||||
if subscription.is_trial:
|
||||
start_date = subscription.start_date or current_time
|
||||
total_duration = subscription.end_date - start_date
|
||||
max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS)
|
||||
|
||||
if total_duration > max_trial_duration:
|
||||
subscription.is_trial = False
|
||||
logger.info(
|
||||
"🎯 Подписка %s автоматически переведена из триальной в платную после продления"
|
||||
", итоговая длительность: %s дней",
|
||||
subscription.id,
|
||||
total_duration.days,
|
||||
)
|
||||
if subscription.user:
|
||||
subscription.user.has_had_paid_subscription = True
|
||||
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
|
||||
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
|
||||
|
||||
subscription.updated_at = current_time
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await clear_notifications(db, subscription.id)
|
||||
|
||||
@@ -118,12 +118,7 @@ async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -7,9 +7,18 @@ from sqlalchemy import select, and_, or_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, UserStatus, Subscription, Transaction, PromoGroup
|
||||
from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
Subscription,
|
||||
Transaction,
|
||||
PromoGroup,
|
||||
PaymentMethod,
|
||||
TransactionType,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database.crud.promo_group import get_default_promo_group
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -104,11 +113,13 @@ async def create_user(
|
||||
|
||||
promo_group_id = default_group.id
|
||||
|
||||
safe_first = sanitize_telegram_name(first_name)
|
||||
safe_last = sanitize_telegram_name(last_name)
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
@@ -136,7 +147,10 @@ async def update_user(
|
||||
**kwargs
|
||||
) -> User:
|
||||
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
for field, value in kwargs.items():
|
||||
if field in ("first_name", "last_name"):
|
||||
value = sanitize_telegram_name(value)
|
||||
if hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
|
||||
@@ -205,10 +219,12 @@ async def add_user_balance_by_id(
|
||||
|
||||
|
||||
async def subtract_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
create_transaction: bool = False,
|
||||
payment_method: Optional[PaymentMethod] = None,
|
||||
) -> bool:
|
||||
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
|
||||
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
||||
@@ -227,7 +243,21 @@ async def subtract_user_balance(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import (
|
||||
create_transaction as create_trans,
|
||||
)
|
||||
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.WITHDRAWAL,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
logger.error(f" ✅ Средства списаны: {old_balance} → {user.balance_kopeks}")
|
||||
return True
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""CRUD операции для токенов административного веб-API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WebApiToken
|
||||
|
||||
|
||||
async def list_tokens(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
) -> List[WebApiToken]:
|
||||
query = select(WebApiToken)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.where(WebApiToken.is_active.is_(True))
|
||||
|
||||
query = query.order_by(WebApiToken.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_token_by_id(db: AsyncSession, token_id: int) -> Optional[WebApiToken]:
|
||||
return await db.get(WebApiToken, token_id)
|
||||
|
||||
|
||||
async def get_token_by_hash(db: AsyncSession, token_hash: str) -> Optional[WebApiToken]:
|
||||
query = select(WebApiToken).where(
|
||||
WebApiToken.token_hash == token_hash
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_token(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
description: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> WebApiToken:
|
||||
token = WebApiToken(
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=token_prefix,
|
||||
description=description,
|
||||
expires_at=expires_at,
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(token)
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def update_token(
|
||||
db: AsyncSession,
|
||||
token: WebApiToken,
|
||||
**kwargs,
|
||||
) -> WebApiToken:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(token, key):
|
||||
setattr(token, key, value)
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def set_tokens_active_status(
|
||||
db: AsyncSession,
|
||||
token_ids: Iterable[int],
|
||||
*,
|
||||
is_active: bool,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
update(WebApiToken)
|
||||
.where(WebApiToken.id.in_(list(token_ids)))
|
||||
.values(is_active=is_active, updated_at=datetime.utcnow())
|
||||
)
|
||||
|
||||
|
||||
async def delete_token(db: AsyncSession, token: WebApiToken) -> None:
|
||||
await db.delete(token)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"list_tokens",
|
||||
"get_token_by_id",
|
||||
"get_token_by_hash",
|
||||
"create_token",
|
||||
"update_token",
|
||||
"set_tokens_active_status",
|
||||
"delete_token",
|
||||
]
|
||||
@@ -10,7 +10,7 @@ from app.database.models import Base
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
settings.get_database_url(),
|
||||
poolclass=NullPool,
|
||||
echo=settings.DEBUG,
|
||||
future=True
|
||||
@@ -48,4 +48,4 @@ async def init_db():
|
||||
|
||||
async def close_db():
|
||||
await engine.dispose()
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
|
||||
+24
-3
@@ -1091,7 +1091,7 @@ class Ticket(Base):
|
||||
|
||||
class TicketMessage(Base):
|
||||
__tablename__ = "ticket_messages"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
@@ -1118,6 +1118,27 @@ class TicketMessage(Base):
|
||||
@property
|
||||
def is_admin_message(self) -> bool:
|
||||
return self.is_from_admin
|
||||
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
|
||||
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
|
||||
|
||||
|
||||
class WebApiToken(Base):
|
||||
__tablename__ = "web_api_tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
token_hash = Column(String(128), nullable=False, unique=True, index=True)
|
||||
token_prefix = Column(String(32), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
last_used_ip = Column(String(64), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_by = Column(String(255), nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "active" if self.is_active else "revoked"
|
||||
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
|
||||
@@ -1,7 +1,13 @@
|
||||
import logging
|
||||
from sqlalchemy import text, inspect
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.database import engine
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal, engine
|
||||
from app.database.models import WebApiToken
|
||||
from app.utils.security import hash_api_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -978,6 +984,7 @@ async def ensure_promo_groups_setup():
|
||||
logger.info(
|
||||
"Добавлена колонка promo_groups.apply_discounts_to_addons"
|
||||
)
|
||||
addon_discount_column_exists = True
|
||||
|
||||
column_exists = await check_column_exists("users", "promo_group_id")
|
||||
|
||||
@@ -1110,9 +1117,25 @@ async def ensure_promo_groups_setup():
|
||||
if existing_default:
|
||||
default_group_id = existing_default[0]
|
||||
else:
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
insert_params = {
|
||||
"name": default_group_name,
|
||||
"is_default": True,
|
||||
}
|
||||
|
||||
if addon_discount_column_exists:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
server_discount_percent,
|
||||
traffic_discount_percent,
|
||||
device_discount_percent,
|
||||
apply_discounts_to_addons,
|
||||
is_default
|
||||
) VALUES (:name, 0, 0, 0, :apply_discounts_to_addons, :is_default)
|
||||
"""
|
||||
insert_params["apply_discounts_to_addons"] = True
|
||||
else:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
server_discount_percent,
|
||||
@@ -1121,9 +1144,8 @@ async def ensure_promo_groups_setup():
|
||||
is_default
|
||||
) VALUES (:name, 0, 0, 0, :is_default)
|
||||
"""
|
||||
),
|
||||
{"name": default_group_name, "is_default": True},
|
||||
)
|
||||
|
||||
await conn.execute(text(insert_sql), insert_params)
|
||||
|
||||
result = await conn.execute(
|
||||
text(
|
||||
@@ -1886,6 +1908,135 @@ async def create_system_settings_table() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def create_web_api_tokens_table() -> bool:
|
||||
table_exists = await check_table_exists("web_api_tokens")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица web_api_tokens уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE web_api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
token_hash VARCHAR(128) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NULL,
|
||||
last_used_at DATETIME NULL,
|
||||
last_used_ip VARCHAR(64) NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_by VARCHAR(255) NULL
|
||||
);
|
||||
CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active);
|
||||
CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix);
|
||||
CREATE INDEX idx_web_api_tokens_last_used ON web_api_tokens(last_used_at);
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE web_api_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
token_hash VARCHAR(128) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
expires_at TIMESTAMP NULL,
|
||||
last_used_at TIMESTAMP NULL,
|
||||
last_used_ip VARCHAR(64) NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by VARCHAR(255) NULL
|
||||
);
|
||||
CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active);
|
||||
CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix);
|
||||
CREATE INDEX idx_web_api_tokens_last_used ON web_api_tokens(last_used_at);
|
||||
"""
|
||||
else:
|
||||
create_sql = """
|
||||
CREATE TABLE web_api_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
token_hash VARCHAR(128) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NULL,
|
||||
last_used_at TIMESTAMP NULL,
|
||||
last_used_ip VARCHAR(64) NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by VARCHAR(255) NULL
|
||||
) ENGINE=InnoDB;
|
||||
CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active);
|
||||
CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix);
|
||||
CREATE INDEX idx_web_api_tokens_last_used ON web_api_tokens(last_used_at);
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица web_api_tokens создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы web_api_tokens: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_default_web_api_token() -> bool:
|
||||
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
|
||||
if not default_token:
|
||||
return True
|
||||
|
||||
token_name = (settings.WEB_API_DEFAULT_TOKEN_NAME or "Bootstrap Token").strip()
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
token_hash = hash_api_token(default_token, settings.WEB_API_TOKEN_HASH_ALGORITHM)
|
||||
result = await session.execute(
|
||||
select(WebApiToken).where(WebApiToken.token_hash == token_hash)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
updated = False
|
||||
|
||||
if not existing.is_active:
|
||||
existing.is_active = True
|
||||
updated = True
|
||||
|
||||
if token_name and existing.name != token_name:
|
||||
existing.name = token_name
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
existing.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
token = WebApiToken(
|
||||
name=token_name or "Bootstrap Token",
|
||||
token_hash=token_hash,
|
||||
token_prefix=default_token[:12],
|
||||
description="Автоматически создан при миграции",
|
||||
created_by="migration",
|
||||
is_active=True,
|
||||
)
|
||||
session.add(token)
|
||||
await session.commit()
|
||||
logger.info("✅ Создан дефолтный токен веб-API из конфигурации")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания дефолтного веб-API токена: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def run_universal_migration():
|
||||
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
@@ -1904,6 +2055,20 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей system_settings")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WEB_API_TOKENS ===")
|
||||
web_api_tokens_ready = await create_web_api_tokens_table()
|
||||
if web_api_tokens_ready:
|
||||
logger.info("✅ Таблица web_api_tokens готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
|
||||
|
||||
logger.info("=== ПРОВЕРКА БАЗОВЫХ ТОКЕНОВ ВЕБ-API ===")
|
||||
default_token_ready = await ensure_default_web_api_token()
|
||||
if default_token_ready:
|
||||
logger.info("✅ Бутстрап токен веб-API готов")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось создать бутстрап токен веб-API")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===")
|
||||
cryptobot_created = await create_cryptobot_payments_table()
|
||||
if cryptobot_created:
|
||||
|
||||
@@ -26,75 +26,69 @@ SETTINGS_PAGE_SIZE = 8
|
||||
|
||||
CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = (
|
||||
(
|
||||
"telegram_bot",
|
||||
"🤖 Telegram бот",
|
||||
("SUPPORT", "ADMIN_NOTIFICATIONS", "ADMIN_REPORTS", "CHANNEL"),
|
||||
"core",
|
||||
"⚙️ Основные настройки",
|
||||
("SUPPORT", "LOCALIZATION", "MAINTENANCE"),
|
||||
),
|
||||
(
|
||||
"channels_notifications",
|
||||
"📢 Каналы и уведомления",
|
||||
("CHANNEL", "ADMIN_NOTIFICATIONS", "ADMIN_REPORTS"),
|
||||
),
|
||||
(
|
||||
"subscriptions",
|
||||
"💎 Подписки и тарифы",
|
||||
("TRIAL", "PAID_SUBSCRIPTION", "PERIODS", "SUBSCRIPTION_PRICES", "TRAFFIC", "TRAFFIC_PACKAGES", "DISCOUNTS"),
|
||||
),
|
||||
(
|
||||
"payments",
|
||||
"💳 Платежные системы",
|
||||
("PAYMENT", "TELEGRAM", "CRYPTOBOT", "YOOKASSA", "TRIBUTE", "MULENPAY", "PAL24"),
|
||||
),
|
||||
(
|
||||
"remnawave",
|
||||
"🔗 RemnaWave API",
|
||||
("REMNAWAVE",),
|
||||
),
|
||||
(
|
||||
"referral",
|
||||
"🤝 Реферальная система",
|
||||
("REFERRAL",),
|
||||
),
|
||||
(
|
||||
"autopay",
|
||||
"🔄 Автопродление",
|
||||
("AUTOPAY",),
|
||||
),
|
||||
(
|
||||
"interface",
|
||||
"🎨 Интерфейс и UX",
|
||||
("INTERFACE_BRANDING", "INTERFACE_SUBSCRIPTION", "CONNECT_BUTTON", "HAPP", "SKIP", "ADDITIONAL"),
|
||||
),
|
||||
(
|
||||
"database",
|
||||
"🗄️ База данных",
|
||||
("DATABASE", "POSTGRES", "SQLITE", "REDIS"),
|
||||
),
|
||||
(
|
||||
"remnawave",
|
||||
"🌊 Remnawave API",
|
||||
("REMNAWAVE",),
|
||||
),
|
||||
(
|
||||
"subscriptions",
|
||||
"🪙 Подписки и тарифы",
|
||||
(
|
||||
"TRIAL",
|
||||
"PAID_SUBSCRIPTION",
|
||||
"SUBSCRIPTIONS_GLOBAL",
|
||||
"TRAFFIC",
|
||||
"PERIODS",
|
||||
"SUBSCRIPTION_PRICES",
|
||||
"TRAFFIC_PACKAGES",
|
||||
"DISCOUNTS",
|
||||
"REFERRAL",
|
||||
"AUTOPAY",
|
||||
),
|
||||
),
|
||||
(
|
||||
"payments",
|
||||
"💳 Платежные системы",
|
||||
("TELEGRAM", "TRIBUTE", "YOOKASSA", "CRYPTOBOT", "MULENPAY", "PAL24", "PAYMENT"),
|
||||
),
|
||||
(
|
||||
"interface",
|
||||
"🎨 Интерфейс и UX",
|
||||
("INTERFACE_BRANDING", "INTERFACE_SUBSCRIPTION", "CONNECT_BUTTON", "HAPP", "SKIP"),
|
||||
),
|
||||
(
|
||||
"monitoring",
|
||||
"📣 Мониторинг и уведомления",
|
||||
("MONITORING", "NOTIFICATIONS"),
|
||||
"📊 Мониторинг",
|
||||
("MONITORING", "NOTIFICATIONS", "SERVER"),
|
||||
),
|
||||
(
|
||||
"operations",
|
||||
"🛠️ Статусы и обслуживание",
|
||||
("SERVER", "MAINTENANCE"),
|
||||
"backup",
|
||||
"💾 Система бэкапов",
|
||||
("BACKUP",),
|
||||
),
|
||||
(
|
||||
"localization",
|
||||
"🈯 Локализация",
|
||||
("LOCALIZATION",),
|
||||
"updates",
|
||||
"🔄 Обновления",
|
||||
("VERSION",),
|
||||
),
|
||||
(
|
||||
"extras",
|
||||
"🧩 Дополнительные настройки",
|
||||
("ADDITIONAL",),
|
||||
),
|
||||
(
|
||||
"reliability",
|
||||
"💾 Бекапы и обновления",
|
||||
("BACKUP", "VERSION"),
|
||||
),
|
||||
(
|
||||
"technical",
|
||||
"🧰 Технические",
|
||||
("LOG", "WEBHOOK", "DEBUG"),
|
||||
"development",
|
||||
"🔧 Разработка",
|
||||
("LOG", "WEBHOOK", "WEB_API", "DEBUG"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -862,7 +856,7 @@ async def test_payment_provider(
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
payment_url = payment_result.get("link_url") or payment_result.get("link_page_url")
|
||||
payment_url = payment_result.get("link_page_url") or payment_result.get("link_url")
|
||||
message_text = (
|
||||
"🧪 <b>Тестовый платеж PayPalych</b>\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
@@ -872,7 +866,7 @@ async def test_payment_provider(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💳 Перейти к оплате",
|
||||
text="🏦 Перейти к оплате (СБП)",
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -15,6 +16,7 @@ from app.database.crud.server_squad import (
|
||||
create_server_squad,
|
||||
get_available_server_squads,
|
||||
update_server_squad_promo_groups,
|
||||
get_server_connected_users,
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_groups_with_counts
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
@@ -73,6 +75,11 @@ def _build_server_edit_view(server):
|
||||
text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="👥 Юзеры", callback_data=f"admin_server_users_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}"
|
||||
@@ -276,7 +283,7 @@ async def sync_servers_with_remnawave(
|
||||
)
|
||||
return
|
||||
|
||||
created, updated, disabled = await sync_with_remnawave(db, squads)
|
||||
created, updated, removed = await sync_with_remnawave(db, squads)
|
||||
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
@@ -286,7 +293,7 @@ async def sync_servers_with_remnawave(
|
||||
📊 <b>Результаты:</b>
|
||||
• Создано новых серверов: {created}
|
||||
• Обновлено существующих: {updated}
|
||||
• Отключено неактивных: {disabled}
|
||||
• Удалено отсутствующих: {removed}
|
||||
• Всего обработано: {len(squads)}
|
||||
|
||||
ℹ️ Новые серверы созданы как недоступные.
|
||||
@@ -314,7 +321,7 @@ async def sync_servers_with_remnawave(
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -343,6 +350,149 @@ async def show_server_edit_menu(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_server_users(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
payload = callback.data.split("admin_server_users_", 1)[-1]
|
||||
payload_parts = payload.split("_")
|
||||
|
||||
server_id = int(payload_parts[0])
|
||||
page = int(payload_parts[1]) if len(payload_parts) > 1 else 1
|
||||
page = max(page, 1)
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
users = await get_server_connected_users(db, server_id)
|
||||
total_users = len(users)
|
||||
|
||||
page_size = 10
|
||||
total_pages = max((total_users + page_size - 1) // page_size, 1)
|
||||
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
start_index = (page - 1) * page_size
|
||||
end_index = start_index + page_size
|
||||
page_users = users[start_index:end_index]
|
||||
|
||||
safe_name = html.escape(server.display_name or "—")
|
||||
safe_uuid = html.escape(server.squad_uuid or "—")
|
||||
|
||||
header = [
|
||||
"🌐 <b>Пользователи сервера</b>",
|
||||
"",
|
||||
f"• Сервер: {safe_name}",
|
||||
f"• UUID: <code>{safe_uuid}</code>",
|
||||
f"• Подключений: {total_users}",
|
||||
]
|
||||
|
||||
if total_pages > 1:
|
||||
header.append(f"• Страница: {page}/{total_pages}")
|
||||
|
||||
header.append("")
|
||||
|
||||
text = "\n".join(header)
|
||||
|
||||
def _get_status_icon(status_text: str) -> str:
|
||||
if not status_text:
|
||||
return ""
|
||||
|
||||
parts = status_text.split(" ", 1)
|
||||
return parts[0] if parts else status_text
|
||||
|
||||
if users:
|
||||
lines = []
|
||||
for index, user in enumerate(page_users, start=start_index + 1):
|
||||
safe_user_name = html.escape(user.full_name)
|
||||
lines.append(f"{index}. {safe_user_name}")
|
||||
|
||||
text += "\n" + "\n".join(lines)
|
||||
else:
|
||||
text += "Пользователи не найдены."
|
||||
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
for user in page_users:
|
||||
display_name = user.full_name
|
||||
if len(display_name) > 30:
|
||||
display_name = display_name[:27] + "..."
|
||||
|
||||
subscription_status = (
|
||||
user.subscription.status_display
|
||||
if user.subscription
|
||||
else "❌ Нет подписки"
|
||||
)
|
||||
status_icon = _get_status_icon(subscription_status)
|
||||
|
||||
if status_icon:
|
||||
button_text = f"{status_icon} {display_name}"
|
||||
else:
|
||||
button_text = display_name
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}",
|
||||
)
|
||||
])
|
||||
|
||||
if total_pages > 1:
|
||||
navigation_buttons: list[types.InlineKeyboardButton] = []
|
||||
|
||||
if page > 1:
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Предыдущая",
|
||||
callback_data=f"admin_server_users_{server_id}_{page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"Стр. {page}/{total_pages}",
|
||||
callback_data=f"admin_server_users_{server_id}_{page}",
|
||||
)
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="Следующая ➡️",
|
||||
callback_data=f"admin_server_users_{server_id}_{page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.append(navigation_buttons)
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ К серверу", callback_data=f"admin_server_edit_{server_id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ К списку", callback_data="admin_servers_list"
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_server_availability(
|
||||
@@ -1105,6 +1255,7 @@ def register_handlers(dp: Dispatcher):
|
||||
& ~F.data.contains("promo"),
|
||||
)
|
||||
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
|
||||
dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_"))
|
||||
|
||||
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
|
||||
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
|
||||
|
||||
@@ -79,12 +79,16 @@ async def show_admin_tickets(
|
||||
ticket_data = []
|
||||
for ticket in tickets:
|
||||
user_name = ticket.user.full_name if ticket.user else "Unknown"
|
||||
username = ticket.user.username if ticket.user else None
|
||||
telegram_id = ticket.user.telegram_id if ticket.user else None
|
||||
ticket_data.append({
|
||||
'id': ticket.id,
|
||||
'title': ticket.title,
|
||||
'status_emoji': ticket.status_emoji,
|
||||
'priority_emoji': ticket.priority_emoji,
|
||||
'user_name': user_name,
|
||||
'username': username,
|
||||
'telegram_id': telegram_id,
|
||||
'is_closed': ticket.is_closed,
|
||||
'locked_emoji': ("🔒" if ticket.is_user_reply_blocked else "")
|
||||
})
|
||||
@@ -169,9 +173,13 @@ async def view_admin_ticket(
|
||||
}.get(ticket.status, ticket.status)
|
||||
|
||||
user_name = ticket.user.full_name if ticket.user else "Unknown"
|
||||
|
||||
telegram_id_display = ticket.user.telegram_id if ticket.user else "—"
|
||||
username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
|
||||
|
||||
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
|
||||
ticket_text += f"👤 Пользователь: {user_name}\n"
|
||||
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
|
||||
ticket_text += f"📱 Username: @{username_display}\n"
|
||||
ticket_text += f"📝 Заголовок: {ticket.title}\n"
|
||||
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
|
||||
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
@@ -201,6 +209,33 @@ async def view_admin_ticket(
|
||||
db_user.language,
|
||||
is_user_blocked=ticket.is_user_reply_blocked
|
||||
)
|
||||
# Кнопка открытия профиля пользователя в админке
|
||||
try:
|
||||
if ticket.user:
|
||||
admin_profile_btn = types.InlineKeyboardButton(
|
||||
text="👤 К пользователю",
|
||||
callback_data=f"admin_user_manage_{ticket.user.id}_from_ticket_{ticket.id}"
|
||||
)
|
||||
keyboard.inline_keyboard.insert(0, [admin_profile_btn])
|
||||
except Exception:
|
||||
pass
|
||||
# Кнопки ЛС и профиль
|
||||
try:
|
||||
if ticket.user and ticket.user.telegram_id:
|
||||
buttons_row = []
|
||||
# DM: при наличии username используем tg://resolve, иначе fallback по ID
|
||||
if ticket.user.username:
|
||||
pm_url = f"tg://resolve?domain={ticket.user.username}"
|
||||
else:
|
||||
pm_url = f"tg://user?id={ticket.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
|
||||
# Профиль: по ID
|
||||
profile_url = f"tg://user?id={ticket.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
|
||||
if buttons_row:
|
||||
keyboard.inline_keyboard.insert(0, buttons_row)
|
||||
except Exception:
|
||||
pass
|
||||
if has_photos:
|
||||
try:
|
||||
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{ticket_id}")])
|
||||
@@ -445,6 +480,17 @@ async def close_admin_ticket(
|
||||
# audit
|
||||
try:
|
||||
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
# обогатим details контактами пользователя тикета
|
||||
details = {}
|
||||
try:
|
||||
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
|
||||
if t and t.user:
|
||||
details.update({
|
||||
"target_telegram_id": t.user.telegram_id,
|
||||
"target_username": t.user.username,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
await TicketCRUD.add_support_audit(
|
||||
db,
|
||||
actor_user_id=db_user.id if db_user else None,
|
||||
@@ -453,7 +499,7 @@ async def close_admin_ticket(
|
||||
action="close_ticket",
|
||||
ticket_id=ticket_id,
|
||||
target_user_id=None,
|
||||
details={}
|
||||
details=details
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -645,6 +691,31 @@ async def handle_admin_block_duration_input(
|
||||
ticket_text += "📎 Вложение: фото\n\n"
|
||||
|
||||
kb = get_admin_ticket_view_keyboard(updated.id, updated.is_closed, db_user.language, is_user_blocked=updated.is_user_reply_blocked)
|
||||
# Кнопка открытия профиля пользователя в админке
|
||||
try:
|
||||
if updated.user:
|
||||
admin_profile_btn = types.InlineKeyboardButton(
|
||||
text="👤 К пользователю",
|
||||
callback_data=f"admin_user_manage_{updated.user.id}_from_ticket_{updated.id}"
|
||||
)
|
||||
kb.inline_keyboard.insert(0, [admin_profile_btn])
|
||||
except Exception:
|
||||
pass
|
||||
# Кнопки ЛС и профиль при обновлении карточки
|
||||
try:
|
||||
if updated.user and updated.user.telegram_id:
|
||||
buttons_row = []
|
||||
if updated.user.username:
|
||||
pm_url = f"tg://resolve?domain={updated.user.username}"
|
||||
else:
|
||||
pm_url = f"tg://user?id={updated.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
|
||||
profile_url = f"tg://user?id={updated.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
|
||||
if buttons_row:
|
||||
kb.inline_keyboard.insert(0, buttons_row)
|
||||
except Exception:
|
||||
pass
|
||||
has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in updated.messages or [])
|
||||
if has_photos:
|
||||
try:
|
||||
@@ -699,6 +770,16 @@ async def unblock_user_in_ticket(
|
||||
try:
|
||||
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", ""))
|
||||
details = {}
|
||||
try:
|
||||
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
|
||||
if t and t.user:
|
||||
details.update({
|
||||
"target_telegram_id": t.user.telegram_id,
|
||||
"target_username": t.user.username,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
await TicketCRUD.add_support_audit(
|
||||
db,
|
||||
actor_user_id=db_user.id if db_user else None,
|
||||
@@ -707,7 +788,7 @@ async def unblock_user_in_ticket(
|
||||
action="unblock_user",
|
||||
ticket_id=ticket_id,
|
||||
target_user_id=None,
|
||||
details={}
|
||||
details=details
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -741,6 +822,16 @@ async def block_user_permanently(
|
||||
# audit
|
||||
try:
|
||||
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
details = {}
|
||||
try:
|
||||
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
|
||||
if t and t.user:
|
||||
details.update({
|
||||
"target_telegram_id": t.user.telegram_id,
|
||||
"target_username": t.user.username,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
await TicketCRUD.add_support_audit(
|
||||
db,
|
||||
actor_user_id=db_user.id if db_user else None,
|
||||
@@ -749,7 +840,7 @@ async def block_user_permanently(
|
||||
action="block_user_perm",
|
||||
ticket_id=ticket_id,
|
||||
target_user_id=None,
|
||||
details={}
|
||||
details=details
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+286
-48
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -20,12 +21,19 @@ from app.keyboards.admin import (
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.user_service import UserService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.database.crud.promo_group import get_promo_groups_with_counts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.external.remnawave_api import TrafficLimitStrategy
|
||||
from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_uuid, get_server_squad_by_id
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_uuid,
|
||||
get_server_squad_by_id,
|
||||
get_server_ids_by_uuids,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -348,32 +356,46 @@ async def show_users_statistics(
|
||||
user_service = UserService()
|
||||
stats = await user_service.get_user_statistics(db)
|
||||
|
||||
from sqlalchemy import select, func, and_
|
||||
|
||||
with_sub_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.join(Subscription)
|
||||
from sqlalchemy import select, func, or_
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
active_subscription_query = (
|
||||
select(func.count(Subscription.id))
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.where(
|
||||
and_(
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.is_active == True
|
||||
)
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
]
|
||||
),
|
||||
Subscription.end_date > current_time,
|
||||
)
|
||||
)
|
||||
users_with_subscription = with_sub_result.scalar() or 0
|
||||
|
||||
trial_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.join(Subscription)
|
||||
users_with_subscription = (
|
||||
await db.execute(active_subscription_query)
|
||||
).scalar() or 0
|
||||
|
||||
trial_subscription_query = (
|
||||
select(func.count(Subscription.id))
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.where(
|
||||
and_(
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.is_trial == True,
|
||||
Subscription.is_active == True
|
||||
)
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.end_date > current_time,
|
||||
or_(
|
||||
Subscription.status == SubscriptionStatus.TRIAL.value,
|
||||
Subscription.is_trial.is_(True),
|
||||
),
|
||||
)
|
||||
)
|
||||
trial_users = trial_result.scalar() or 0
|
||||
trial_users = (await db.execute(trial_subscription_query)).scalar() or 0
|
||||
|
||||
users_without_subscription = max(
|
||||
stats["active_users"] - users_with_subscription,
|
||||
0,
|
||||
)
|
||||
|
||||
avg_balance_result = await db.execute(
|
||||
select(func.avg(User.balance_kopeks))
|
||||
@@ -392,7 +414,7 @@ async def show_users_statistics(
|
||||
📱 <b>Подписки:</b>
|
||||
• С активной подпиской: {users_with_subscription}
|
||||
• На триале: {trial_users}
|
||||
• Без подписки: {stats['active_users'] - users_with_subscription}
|
||||
• Без подписки: {users_without_subscription}
|
||||
|
||||
💰 <b>Финансы:</b>
|
||||
• Средний баланс: {settings.format_price(int(avg_balance))}
|
||||
@@ -766,7 +788,32 @@ async def show_user_management(
|
||||
state: FSMContext
|
||||
):
|
||||
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
# Поддерживаем переход "из тикета": admin_user_manage_{userId}_from_ticket_{ticketId}
|
||||
parts = callback.data.split('_')
|
||||
try:
|
||||
user_id = int(parts[3]) # admin_user_manage_{userId}
|
||||
except Exception:
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
origin_ticket_id = None
|
||||
if "from" in parts and "ticket" in parts:
|
||||
try:
|
||||
origin_ticket_id = int(parts[-1])
|
||||
except Exception:
|
||||
origin_ticket_id = None
|
||||
# Если пришли из тикета — запомним в состоянии, чтобы сохранять кнопку возврата
|
||||
try:
|
||||
if origin_ticket_id:
|
||||
await state.update_data(origin_ticket_id=origin_ticket_id, origin_ticket_user_id=user_id)
|
||||
except Exception:
|
||||
pass
|
||||
# Если не пришло в колбэке — попробуем достать из состояния
|
||||
if origin_ticket_id is None:
|
||||
try:
|
||||
data_state = await state.get_data()
|
||||
if data_state.get("origin_ticket_user_id") == user_id:
|
||||
origin_ticket_id = data_state.get("origin_ticket_id")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Проверяем, откуда пришел пользователь
|
||||
back_callback = "admin_users_list"
|
||||
@@ -844,9 +891,22 @@ async def show_user_management(
|
||||
if current_state == AdminStates.viewing_user_from_balance_list:
|
||||
back_callback = "admin_users_balance_filter"
|
||||
|
||||
# Базовая клавиатура профиля
|
||||
kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
|
||||
# Если пришли из тикета — добавим в начало кнопку возврата к тикету
|
||||
try:
|
||||
if origin_ticket_id:
|
||||
back_to_ticket_btn = types.InlineKeyboardButton(
|
||||
text="🎫 Вернуться к тикету",
|
||||
callback_data=f"admin_view_ticket_{origin_ticket_id}"
|
||||
)
|
||||
kb.inline_keyboard.insert(0, [back_to_ticket_btn])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
|
||||
reply_markup=kb
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -941,7 +1001,7 @@ async def set_user_promo_group(
|
||||
return
|
||||
|
||||
user_service = UserService()
|
||||
success, updated_user, new_group = await user_service.update_user_promo_group(
|
||||
success, updated_user, new_group, old_group = await user_service.update_user_promo_group(
|
||||
db,
|
||||
user_id,
|
||||
group_id
|
||||
@@ -959,6 +1019,27 @@ async def set_user_promo_group(
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
reason = (
|
||||
f"Назначено администратором {db_user.full_name} (ID: {db_user.telegram_id})"
|
||||
)
|
||||
await notification_service.send_user_promo_group_change_notification(
|
||||
db,
|
||||
updated_user,
|
||||
old_group,
|
||||
new_group,
|
||||
reason=reason,
|
||||
initiator=db_user,
|
||||
automatic=False,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о смене промогруппы пользователя %s: %s",
|
||||
updated_user.telegram_id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -2599,6 +2680,56 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
|
||||
logger.error(f"Ошибка выдачи платной подписки: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _calculate_subscription_period_price(
|
||||
db: AsyncSession,
|
||||
target_user: User,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
subscription_service: Optional[SubscriptionService] = None,
|
||||
) -> int:
|
||||
"""Рассчитывает стоимость подписки для администратора с учётом всех параметров."""
|
||||
|
||||
service = subscription_service or SubscriptionService()
|
||||
|
||||
connected_squads = list(subscription.connected_squads or [])
|
||||
server_ids = []
|
||||
|
||||
if connected_squads:
|
||||
try:
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_squads)
|
||||
if len(server_ids) != len(connected_squads):
|
||||
logger.warning(
|
||||
"Не удалось сопоставить все сервера подписки пользователя %s для расчёта цены",
|
||||
target_user.telegram_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Не удалось получить идентификаторы серверов для расчёта цены подписки пользователя %s: %s",
|
||||
target_user.telegram_id,
|
||||
e,
|
||||
)
|
||||
server_ids = []
|
||||
traffic_limit_gb = subscription.traffic_limit_gb
|
||||
if traffic_limit_gb is None:
|
||||
traffic_limit_gb = settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
|
||||
device_limit = subscription.device_limit
|
||||
if not device_limit or device_limit < 0:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
total_price, _ = await service.calculate_subscription_price(
|
||||
period_days=period_days,
|
||||
traffic_gb=traffic_limit_gb,
|
||||
server_squad_ids=server_ids,
|
||||
devices=device_limit,
|
||||
db=db,
|
||||
user=target_user,
|
||||
promo_group=target_user.promo_group,
|
||||
)
|
||||
|
||||
return total_price
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def cleanup_inactive_users(
|
||||
@@ -2697,30 +2828,55 @@ async def admin_buy_subscription(
|
||||
return
|
||||
|
||||
available_periods = settings.get_available_subscription_periods()
|
||||
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
period_buttons = []
|
||||
|
||||
for period in available_periods:
|
||||
price_attr = f"PRICE_{period}_DAYS"
|
||||
if hasattr(settings, price_attr):
|
||||
price_kopeks = getattr(settings, price_attr)
|
||||
price_rubles = price_kopeks // 100
|
||||
period_buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{period} дней ({price_rubles} ₽)",
|
||||
callback_data=f"admin_buy_sub_confirm_{user_id}_{period}_{price_kopeks}"
|
||||
)
|
||||
])
|
||||
|
||||
try:
|
||||
price_kopeks = await _calculate_subscription_period_price(
|
||||
db,
|
||||
target_user,
|
||||
subscription,
|
||||
period,
|
||||
subscription_service=subscription_service,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка расчёта стоимости подписки для пользователя %s и периода %s дней: %s",
|
||||
target_user.telegram_id,
|
||||
period,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
|
||||
period_buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{period} дней ({settings.format_price(price_kopeks)})",
|
||||
callback_data=f"admin_buy_sub_confirm_{user_id}_{period}_{price_kopeks}"
|
||||
)
|
||||
])
|
||||
|
||||
if not period_buttons:
|
||||
await callback.answer("❌ Не удалось рассчитать стоимость подписки", show_alert=True)
|
||||
return
|
||||
|
||||
period_buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data=f"admin_user_subscription_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
|
||||
text = f"💳 <b>Покупка подписки для пользователя</b>\n\n"
|
||||
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
|
||||
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
|
||||
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
servers_count = len(subscription.connected_squads or [])
|
||||
text += f"📶 Трафик: {traffic_text}\n"
|
||||
text += f"📱 Устройства: {devices_limit}\n"
|
||||
text += f"🌐 Серверов: {servers_count}\n\n"
|
||||
text += "Выберите период подписки:\n"
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -2740,7 +2896,7 @@ async def admin_buy_subscription_confirm(
|
||||
parts = callback.data.split('_')
|
||||
user_id = int(parts[4])
|
||||
period_days = int(parts[5])
|
||||
price_kopeks = int(parts[6])
|
||||
price_kopeks_from_callback = int(parts[6]) if len(parts) > 6 else None
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
@@ -2751,7 +2907,38 @@ async def admin_buy_subscription_confirm(
|
||||
|
||||
target_user = profile["user"]
|
||||
subscription = profile["subscription"]
|
||||
|
||||
|
||||
if not subscription:
|
||||
await callback.answer("❌ У пользователя нет подписки", show_alert=True)
|
||||
return
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
try:
|
||||
price_kopeks = await _calculate_subscription_period_price(
|
||||
db,
|
||||
target_user,
|
||||
subscription,
|
||||
period_days,
|
||||
subscription_service=subscription_service,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка расчёта стоимости подписки при подтверждении админом для пользователя %s: %s",
|
||||
target_user.telegram_id,
|
||||
e,
|
||||
)
|
||||
await callback.answer("❌ Не удалось рассчитать стоимость подписки", show_alert=True)
|
||||
return
|
||||
|
||||
if price_kopeks_from_callback is not None and price_kopeks_from_callback != price_kopeks:
|
||||
logger.info(
|
||||
"Стоимость подписки для пользователя %s изменилась с %s до %s при подтверждении",
|
||||
target_user.telegram_id,
|
||||
price_kopeks_from_callback,
|
||||
price_kopeks,
|
||||
)
|
||||
|
||||
if target_user.balance_kopeks < price_kopeks:
|
||||
missing_kopeks = price_kopeks - target_user.balance_kopeks
|
||||
await callback.message.edit_text(
|
||||
@@ -2770,12 +2957,17 @@ async def admin_buy_subscription_confirm(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
price_rubles = price_kopeks // 100
|
||||
text = f"💳 <b>Подтверждение покупки подписки</b>\n\n"
|
||||
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
text += f"📅 Период подписки: {period_days} дней\n"
|
||||
text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
|
||||
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
|
||||
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
|
||||
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
servers_count = len(subscription.connected_squads or [])
|
||||
text += f"📶 Трафик: {traffic_text}\n"
|
||||
text += f"📱 Устройства: {devices_limit}\n"
|
||||
text += f"🌐 Серверов: {servers_count}\n\n"
|
||||
text += "Вы уверены, что хотите купить подписку для этого пользователя?"
|
||||
|
||||
keyboard = [
|
||||
@@ -2810,7 +3002,7 @@ async def admin_buy_subscription_execute(
|
||||
parts = callback.data.split('_')
|
||||
user_id = int(parts[4])
|
||||
period_days = int(parts[5])
|
||||
price_kopeks = int(parts[6])
|
||||
price_kopeks_from_callback = int(parts[6]) if len(parts) > 6 else None
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
@@ -2821,7 +3013,38 @@ async def admin_buy_subscription_execute(
|
||||
|
||||
target_user = profile["user"]
|
||||
subscription = profile["subscription"]
|
||||
|
||||
|
||||
if not subscription:
|
||||
await callback.answer("❌ У пользователя нет подписки", show_alert=True)
|
||||
return
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
try:
|
||||
price_kopeks = await _calculate_subscription_period_price(
|
||||
db,
|
||||
target_user,
|
||||
subscription,
|
||||
period_days,
|
||||
subscription_service=subscription_service,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка расчёта стоимости подписки при списании средств админом для пользователя %s: %s",
|
||||
target_user.telegram_id,
|
||||
e,
|
||||
)
|
||||
await callback.answer("❌ Не удалось рассчитать стоимость подписки", show_alert=True)
|
||||
return
|
||||
|
||||
if price_kopeks_from_callback is not None and price_kopeks_from_callback != price_kopeks:
|
||||
logger.info(
|
||||
"Стоимость подписки для пользователя %s изменилась с %s до %s перед списанием",
|
||||
target_user.telegram_id,
|
||||
price_kopeks_from_callback,
|
||||
price_kopeks,
|
||||
)
|
||||
|
||||
if target_user.balance_kopeks < price_kopeks:
|
||||
await callback.answer("❌ Недостаточно средств на балансе пользователя", show_alert=True)
|
||||
return
|
||||
@@ -2839,16 +3062,31 @@ async def admin_buy_subscription_execute(
|
||||
|
||||
if subscription:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
bonus_period = timedelta()
|
||||
|
||||
if (
|
||||
subscription.is_trial
|
||||
and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID
|
||||
and subscription.end_date
|
||||
):
|
||||
remaining_trial_delta = subscription.end_date - current_time
|
||||
if remaining_trial_delta.total_seconds() > 0:
|
||||
bonus_period = remaining_trial_delta
|
||||
logger.info(
|
||||
"Админ продлевает подписку: добавляем оставшееся время триала (%s) пользователю %s",
|
||||
bonus_period,
|
||||
target_user.telegram_id,
|
||||
)
|
||||
|
||||
if subscription.end_date <= current_time:
|
||||
subscription.start_date = current_time
|
||||
|
||||
subscription.end_date = current_time + timedelta(days=period_days)
|
||||
|
||||
subscription.end_date = current_time + timedelta(days=period_days) + bonus_period
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = current_time
|
||||
|
||||
|
||||
if subscription.is_trial or not subscription.is_active:
|
||||
subscription.is_trial = False
|
||||
subscription.is_trial = False
|
||||
if subscription.traffic_limit_gb != 0:
|
||||
subscription.traffic_limit_gb = 0
|
||||
subscription.device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
+24
-11
@@ -386,9 +386,9 @@ async def start_pal24_payment(
|
||||
message_text = texts.t(
|
||||
"PAL24_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через PayPalych</b>\n\n"
|
||||
"🏦 <b>Оплата через PayPalych (СБП)</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
"Оплата проходит через защищенную платформу PayPalych."
|
||||
"Оплата проходит через систему быстрых платежей PayPalych."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1002,7 +1002,9 @@ async def process_pal24_payment_amount(
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("link_url"):
|
||||
if not payment_result or not (
|
||||
payment_result.get("link_url") or payment_result.get("link_page_url")
|
||||
):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
@@ -1012,7 +1014,10 @@ async def process_pal24_payment_amount(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
link_url = payment_result.get("link_url")
|
||||
payment_url = (
|
||||
payment_result.get("link_page_url")
|
||||
or payment_result.get("link_url")
|
||||
)
|
||||
bill_id = payment_result.get("bill_id")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
|
||||
@@ -1020,8 +1025,8 @@ async def process_pal24_payment_amount(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAL24_PAY_BUTTON", "💳 Оплатить через PayPalych"),
|
||||
url=link_url,
|
||||
text=texts.t("PAL24_PAY_BUTTON", "🏦 Оплатить через PayPalych (СБП)"),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
@@ -1037,11 +1042,11 @@ async def process_pal24_payment_amount(
|
||||
message_template = texts.t(
|
||||
"PAL24_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через PayPalych</b>\n\n"
|
||||
"🏦 <b>Оплата через PayPalych (СБП)</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID счета: {bill_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку ‘Оплатить через PayPalych’\n"
|
||||
"1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
@@ -1226,8 +1231,10 @@ async def check_pal24_payment_status(
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
payment_link = payment.link_page_url or payment.link_url
|
||||
|
||||
message_lines = [
|
||||
"💳 Статус платежа PayPalych:\n\n",
|
||||
"🏦 Статус платежа PayPalych (СБП):\n\n",
|
||||
f"🆔 ID счета: {payment.bill_id}\n",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
|
||||
f"📊 Статус: {emoji} {status_text}\n",
|
||||
@@ -1238,8 +1245,8 @@ async def check_pal24_payment_status(
|
||||
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
elif payment.status in {"NEW", "PROCESS"}:
|
||||
message_lines.append("\n⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
|
||||
if payment.link_url:
|
||||
message_lines.append(f"\n🔗 Ссылка на оплату: {payment.link_url}")
|
||||
if payment_link:
|
||||
message_lines.append(f"\n🔗 Ссылка на оплату: {payment_link}")
|
||||
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
|
||||
message_lines.append(
|
||||
f"\n❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
|
||||
@@ -1528,6 +1535,12 @@ async def handle_quick_amount_selection(
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
+105
-7
@@ -1,12 +1,13 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id, update_user
|
||||
from app.keyboards.inline import get_main_menu_keyboard
|
||||
from app.keyboards.inline import get_main_menu_keyboard, get_language_selection_keyboard
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.database.models import User
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
@@ -24,11 +25,12 @@ logger = logging.getLogger(__name__)
|
||||
async def show_main_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
skip_callback_answer: bool = False,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
from datetime import datetime
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
@@ -59,7 +61,8 @@ async def show_main_menu(
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
if not skip_callback_answer:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def mark_user_as_had_paid_subscription(
|
||||
@@ -74,8 +77,8 @@ async def mark_user_as_had_paid_subscription(
|
||||
|
||||
|
||||
async def show_service_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
@@ -95,6 +98,90 @@ async def show_service_rules(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_language_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=texts.t("LANGUAGE_PROMPT", "🌐 Выберите язык интерфейса:"),
|
||||
keyboard=get_language_selection_keyboard(
|
||||
current_language=db_user.language,
|
||||
include_back=True,
|
||||
language=db_user.language,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def process_language_change(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
selected_raw = (callback.data or "").split(":", 1)[-1]
|
||||
normalized_selected = selected_raw.strip().lower()
|
||||
|
||||
available_map = {
|
||||
lang.strip().lower(): lang.strip()
|
||||
for lang in settings.get_available_languages()
|
||||
if isinstance(lang, str) and lang.strip()
|
||||
}
|
||||
|
||||
if normalized_selected not in available_map:
|
||||
await callback.answer("❌ Unsupported language", show_alert=True)
|
||||
return
|
||||
|
||||
resolved_language = available_map[normalized_selected].lower()
|
||||
|
||||
if db_user.language.lower() == normalized_selected:
|
||||
await show_main_menu(
|
||||
callback,
|
||||
db_user,
|
||||
db,
|
||||
skip_callback_answer=True,
|
||||
)
|
||||
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
|
||||
return
|
||||
|
||||
updated_user = await update_user(db, db_user, language=resolved_language)
|
||||
texts = get_texts(updated_user.language)
|
||||
|
||||
await show_main_menu(
|
||||
callback,
|
||||
updated_user,
|
||||
db,
|
||||
skip_callback_answer=True,
|
||||
)
|
||||
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
|
||||
|
||||
|
||||
async def handle_back_to_menu(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
@@ -240,3 +327,14 @@ def register_handlers(dp: Dispatcher):
|
||||
show_service_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_language_menu,
|
||||
F.data == "menu_language"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
process_language_change,
|
||||
F.data.startswith("language_select:"),
|
||||
StateFilter(None)
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,6 +62,21 @@ async def process_promocode(
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_promocode_activation_notification(
|
||||
db,
|
||||
db_user,
|
||||
result.get("promocode", {"code": code}),
|
||||
result["description"],
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления об активации промокода %s: %s",
|
||||
code,
|
||||
notify_error,
|
||||
)
|
||||
else:
|
||||
error_messages = {
|
||||
"not_found": texts.PROMOCODE_INVALID,
|
||||
|
||||
+222
-47
@@ -19,12 +19,16 @@ from app.database.crud.campaign import (
|
||||
)
|
||||
from app.database.models import UserStatus, SubscriptionStatus
|
||||
from app.keyboards.inline import (
|
||||
get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard
|
||||
get_rules_keyboard,
|
||||
get_main_menu_keyboard,
|
||||
get_post_registration_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
)
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
@@ -72,8 +76,8 @@ async def _apply_campaign_bonus_if_needed(
|
||||
|
||||
|
||||
async def handle_potential_referral_code(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
@@ -133,7 +137,80 @@ async def handle_potential_referral_code(
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _get_language_prompt_text() -> str:
|
||||
return "🌐 Выберите язык / Choose your language:"
|
||||
|
||||
|
||||
async def _prompt_language_selection(message: types.Message, state: FSMContext) -> None:
|
||||
logger.info(f"🌐 LANGUAGE: Запрос выбора языка для пользователя {message.from_user.id}")
|
||||
|
||||
await state.set_state(RegistrationStates.waiting_for_language)
|
||||
await message.answer(
|
||||
_get_language_prompt_text(),
|
||||
reply_markup=get_language_selection_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
async def _continue_registration_after_language(
|
||||
*,
|
||||
message: types.Message | None,
|
||||
callback: types.CallbackQuery | None,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
target_message = callback.message if callback else message
|
||||
if not target_message:
|
||||
logger.warning("⚠️ LANGUAGE: Нет доступного сообщения для продолжения регистрации")
|
||||
return
|
||||
|
||||
async def _complete_registration_wrapper():
|
||||
if callback:
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
logger.info("⚙️ LANGUAGE: SKIP_RULES_ACCEPT включен - пропускаем правила")
|
||||
|
||||
if data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ LANGUAGE: Реферер найден: {referrer.id}")
|
||||
|
||||
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
|
||||
await _complete_registration_wrapper()
|
||||
else:
|
||||
try:
|
||||
await target_message.answer(
|
||||
texts.t(
|
||||
"REFERRAL_CODE_QUESTION",
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
),
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info("🔍 LANGUAGE: Ожидание ввода реферального кода")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде после выбора языка: {error}")
|
||||
await _complete_registration_wrapper()
|
||||
return
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await target_message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 LANGUAGE: Правила отправлены после выбора языка")
|
||||
|
||||
|
||||
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
|
||||
@@ -165,6 +242,22 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
await state.update_data(referral_code=referral_code)
|
||||
|
||||
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if campaign:
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_campaign_link_visit_notification(
|
||||
db,
|
||||
message.from_user,
|
||||
campaign,
|
||||
user,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления о переходе по кампании %s: %s",
|
||||
campaign.id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
|
||||
@@ -309,55 +402,128 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
else:
|
||||
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = language
|
||||
await state.set_data(data)
|
||||
logger.info(f"💾 Установлен язык по умолчанию: {language}")
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
logger.info("⚙️ SKIP_RULES_ACCEPT включен - пропускаем принятие правил")
|
||||
if data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||
if not data.get('language'):
|
||||
if settings.is_language_selection_enabled():
|
||||
await _prompt_language_selection(message, state)
|
||||
return
|
||||
|
||||
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
|
||||
await complete_registration(message, state, db)
|
||||
else:
|
||||
try:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"REFERRAL_CODE_QUESTION",
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
),
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
default_language = (
|
||||
(settings.DEFAULT_LANGUAGE or DEFAULT_LANGUAGE)
|
||||
if isinstance(settings.DEFAULT_LANGUAGE, str)
|
||||
else DEFAULT_LANGUAGE
|
||||
)
|
||||
normalized_default = default_language.split("-")[0].lower()
|
||||
data['language'] = normalized_default
|
||||
await state.set_data(data)
|
||||
logger.info(
|
||||
"🌐 LANGUAGE: выбор языка отключен, устанавливаем язык по умолчанию '%s'",
|
||||
normalized_default,
|
||||
)
|
||||
|
||||
await _continue_registration_after_language(
|
||||
message=message,
|
||||
callback=None,
|
||||
state=state,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
async def process_language_selection(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
logger.info(
|
||||
f"🌐 LANGUAGE: Пользователь {callback.from_user.id} выбрал язык ({callback.data})"
|
||||
)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
data = await state.get_data() or {}
|
||||
default_language = (
|
||||
(settings.DEFAULT_LANGUAGE or DEFAULT_LANGUAGE)
|
||||
if isinstance(settings.DEFAULT_LANGUAGE, str)
|
||||
else DEFAULT_LANGUAGE
|
||||
)
|
||||
normalized_default = default_language.split("-")[0].lower()
|
||||
data['language'] = normalized_default
|
||||
await state.set_data(data)
|
||||
|
||||
texts = get_texts(normalized_default)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info("🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration(message, state, db)
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
|
||||
)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await _continue_registration_after_language(
|
||||
message=None,
|
||||
callback=callback,
|
||||
state=state,
|
||||
db=db,
|
||||
)
|
||||
return
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
logger.info(f"📋 Правила отправлены")
|
||||
selected_raw = (callback.data or "").split(":", 1)[-1]
|
||||
normalized_selected = selected_raw.strip().lower()
|
||||
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Установлено состояние: {current_state}")
|
||||
available_map = {
|
||||
lang.strip().lower(): lang.strip()
|
||||
for lang in settings.get_available_languages()
|
||||
if isinstance(lang, str) and lang.strip()
|
||||
}
|
||||
|
||||
if normalized_selected not in available_map:
|
||||
logger.warning(
|
||||
f"⚠️ LANGUAGE: Выбран недоступный язык '{normalized_selected}' пользователем {callback.from_user.id}"
|
||||
)
|
||||
await callback.answer("❌ Unsupported language", show_alert=True)
|
||||
return
|
||||
|
||||
resolved_language = available_map[normalized_selected].lower()
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = resolved_language
|
||||
await state.set_data(data)
|
||||
|
||||
texts = get_texts(resolved_language)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"⚠️ LANGUAGE: Не удалось обновить сообщение выбора языка: {error}")
|
||||
await callback.message.answer(
|
||||
texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."),
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await _continue_registration_after_language(
|
||||
message=None,
|
||||
callback=callback,
|
||||
state=state,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
async def process_rules_accept(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
@@ -1070,8 +1236,9 @@ def get_referral_code_keyboard(language: str):
|
||||
|
||||
async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
import html
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
user_name=html.escape(user.full_name or ""),
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
)
|
||||
|
||||
@@ -1089,8 +1256,9 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
|
||||
import html
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
user_name=html.escape(user_name or ""),
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
)
|
||||
|
||||
@@ -1291,7 +1459,14 @@ def register_handlers(dp: Dispatcher):
|
||||
StateFilter(RegistrationStates.waiting_for_rules_accept)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_rules_accept")
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
process_language_selection,
|
||||
F.data.startswith("language_select:"),
|
||||
StateFilter(RegistrationStates.waiting_for_language)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_language_selection")
|
||||
|
||||
dp.callback_query.register(
|
||||
process_referral_code_skip,
|
||||
F.data == "referral_skip",
|
||||
|
||||
+207
-71
@@ -68,6 +68,7 @@ from app.utils.pagination import paginate_list
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -292,13 +293,15 @@ async def _prepare_subscription_summary(
|
||||
else:
|
||||
traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ"
|
||||
|
||||
base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
|
||||
if base_discount_total > 0:
|
||||
base_line += (
|
||||
f" → {texts.format_price(base_price)}"
|
||||
base_line = (
|
||||
f"- Базовый период: <s>{texts.format_price(base_price_original)}</s> "
|
||||
f"{texts.format_price(base_price)}"
|
||||
f" (скидка {period_discount_percent}%:"
|
||||
f" -{texts.format_price(base_discount_total)})"
|
||||
)
|
||||
else:
|
||||
base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
|
||||
|
||||
details_lines = [base_line]
|
||||
|
||||
@@ -616,20 +619,21 @@ async def show_subscription_info(
|
||||
message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "</blockquote>")
|
||||
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
if subscription_link:
|
||||
if (
|
||||
actual_status in ['trial_active', 'paid_active']
|
||||
and not settings.HIDE_SUBSCRIPTION_LINK
|
||||
and not settings.is_happ_cryptolink_mode()
|
||||
):
|
||||
message += "\n\n" + texts.t(
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION",
|
||||
"🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
|
||||
).format(subscription_url=subscription_link)
|
||||
message += "\n\n" + texts.t(
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT",
|
||||
"📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
|
||||
)
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
if (
|
||||
subscription_link
|
||||
and actual_status in ["trial_active", "paid_active"]
|
||||
and not hide_subscription_link
|
||||
):
|
||||
message += "\n\n" + texts.t(
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION",
|
||||
"🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
|
||||
).format(subscription_url=subscription_link)
|
||||
message += "\n\n" + texts.t(
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT",
|
||||
"📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
@@ -893,6 +897,8 @@ async def activate_trial(
|
||||
logger.error(f"Ошибка отправки уведомления о триале: {e}")
|
||||
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
if remnawave_user and subscription_link:
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
trial_success_text = (
|
||||
@@ -903,8 +909,21 @@ async def activate_trial(
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT',
|
||||
'📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве',
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
|
||||
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
)
|
||||
)
|
||||
elif hide_subscription_link:
|
||||
trial_success_text = (
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE",
|
||||
"ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
|
||||
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -2578,9 +2597,11 @@ async def confirm_extend_subscription(
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
new_end_date = subscription.end_date + timedelta(days=days)
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
new_end_date = current_time + timedelta(days=days)
|
||||
|
||||
subscription.end_date = new_end_date
|
||||
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = current_time
|
||||
@@ -2589,6 +2610,11 @@ async def confirm_extend_subscription(
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(db_user)
|
||||
|
||||
# ensure freshly loaded values are available even if SQLAlchemy expires
|
||||
# attributes on subsequent access
|
||||
refreshed_end_date = subscription.end_date
|
||||
refreshed_balance = db_user.balance_kopeks
|
||||
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||
from app.database.crud.subscription import add_subscription_servers
|
||||
|
||||
@@ -2609,7 +2635,12 @@ async def confirm_extend_subscription(
|
||||
await add_subscription_servers(db, subscription, server_ids, server_prices_for_period)
|
||||
|
||||
try:
|
||||
remnawave_result = await subscription_service.update_remnawave_user(db, subscription)
|
||||
remnawave_result = await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="продление подписки",
|
||||
)
|
||||
if remnawave_result:
|
||||
logger.info("✅ RemnaWave обновлен успешно")
|
||||
else:
|
||||
@@ -2628,7 +2659,14 @@ async def confirm_extend_subscription(
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_extension_notification(
|
||||
db, db_user, subscription, transaction, days, old_end_date
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
transaction,
|
||||
days,
|
||||
old_end_date,
|
||||
new_end_date=refreshed_end_date,
|
||||
balance_after=refreshed_balance,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
|
||||
@@ -2636,7 +2674,7 @@ async def confirm_extend_subscription(
|
||||
success_message = (
|
||||
"✅ Подписка успешно продлена!\n\n"
|
||||
f"⏰ Добавлено: {days} дней\n"
|
||||
f"Действует до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"Действует до: {refreshed_end_date.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"💰 Списано: {texts.format_price(price)}"
|
||||
)
|
||||
|
||||
@@ -2909,9 +2947,13 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
if subscription_cost > 0:
|
||||
info_text += f"\n💰 <b>Стоимость подписки в месяц:</b> {texts.format_price(subscription_cost)}"
|
||||
|
||||
if subscription_url and subscription_url != "Генерируется...":
|
||||
if (
|
||||
subscription_url
|
||||
and subscription_url != "Генерируется..."
|
||||
and not settings.should_hide_subscription_link()
|
||||
):
|
||||
info_text += f"\n\n🔗 <b>Ваша ссылка для импорта в VPN приложениe:</b>\n<code>{subscription_url}</code>"
|
||||
|
||||
|
||||
return info_text
|
||||
|
||||
def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str:
|
||||
@@ -3413,16 +3455,29 @@ async def confirm_purchase(
|
||||
|
||||
existing_subscription = db_user.subscription
|
||||
was_trial_conversion = False
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if existing_subscription:
|
||||
logger.info(f"Обновляем существующую подписку пользователя {db_user.telegram_id}")
|
||||
|
||||
|
||||
bonus_period = timedelta()
|
||||
|
||||
if existing_subscription.is_trial:
|
||||
logger.info(f"Конверсия из триала в платную для пользователя {db_user.telegram_id}")
|
||||
was_trial_conversion = True
|
||||
|
||||
trial_duration = (datetime.utcnow() - existing_subscription.start_date).days
|
||||
|
||||
|
||||
trial_duration = (current_time - existing_subscription.start_date).days
|
||||
|
||||
if settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID and existing_subscription.end_date:
|
||||
remaining_trial_delta = existing_subscription.end_date - current_time
|
||||
if remaining_trial_delta.total_seconds() > 0:
|
||||
bonus_period = remaining_trial_delta
|
||||
logger.info(
|
||||
"Добавляем оставшееся время триала (%s) к новой подписке пользователя %s",
|
||||
bonus_period,
|
||||
db_user.telegram_id,
|
||||
)
|
||||
|
||||
try:
|
||||
from app.database.crud.subscription_conversion import create_subscription_conversion
|
||||
await create_subscription_conversion(
|
||||
@@ -3442,13 +3497,13 @@ async def confirm_purchase(
|
||||
existing_subscription.traffic_limit_gb = final_traffic_gb
|
||||
existing_subscription.device_limit = data['devices']
|
||||
existing_subscription.connected_squads = data['countries']
|
||||
|
||||
existing_subscription.start_date = datetime.utcnow()
|
||||
existing_subscription.end_date = datetime.utcnow() + timedelta(days=data['period_days'])
|
||||
existing_subscription.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
existing_subscription.start_date = current_time
|
||||
existing_subscription.end_date = current_time + timedelta(days=data['period_days']) + bonus_period
|
||||
existing_subscription.updated_at = current_time
|
||||
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
subscription = existing_subscription
|
||||
@@ -3483,13 +3538,28 @@ async def confirm_purchase(
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
if db_user.remnawave_uuid:
|
||||
remnawave_user = await subscription_service.update_remnawave_user(db, subscription)
|
||||
remnawave_user = await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="покупка подписки",
|
||||
)
|
||||
else:
|
||||
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="покупка подписки",
|
||||
)
|
||||
|
||||
if not remnawave_user:
|
||||
logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}")
|
||||
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="покупка подписки (повторная попытка)",
|
||||
)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
@@ -3511,6 +3581,8 @@ async def confirm_purchase(
|
||||
await db.refresh(subscription)
|
||||
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
if remnawave_user and subscription_link:
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
success_text = (
|
||||
@@ -3521,8 +3593,21 @@ async def confirm_purchase(
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT',
|
||||
'📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве',
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
|
||||
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
)
|
||||
)
|
||||
elif hide_subscription_link:
|
||||
success_text = (
|
||||
f"{texts.SUBSCRIPTION_PURCHASED}\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE",
|
||||
"ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
|
||||
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -4205,22 +4290,21 @@ async def _should_show_countries_management(user: Optional[User] = None) -> bool
|
||||
if server.is_available and not server.is_full
|
||||
]
|
||||
|
||||
if len(allowed_servers) > 1:
|
||||
logger.debug(
|
||||
"Промогруппа %s имеет %s доступных серверов, показываем управление странами",
|
||||
promo_group.id,
|
||||
len(allowed_servers),
|
||||
)
|
||||
return True
|
||||
if allowed_servers:
|
||||
if len(allowed_servers) > 1:
|
||||
logger.debug(
|
||||
"Промогруппа %s имеет %s доступных серверов, показываем управление странами",
|
||||
promo_group.id,
|
||||
len(allowed_servers),
|
||||
)
|
||||
return True
|
||||
|
||||
if len(promo_group.server_squads) > 1:
|
||||
logger.debug(
|
||||
"Промогруппа %s имеет %s серверов, но доступен только %s — показываем управление странами",
|
||||
"Промогруппа %s имеет всего %s доступный сервер, пропускаем шаг выбора стран",
|
||||
promo_group.id,
|
||||
len(promo_group.server_squads),
|
||||
len(allowed_servers),
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
countries = await _get_available_countries(promo_group_id)
|
||||
available_countries = [c for c in countries if c.get('is_available', True)]
|
||||
@@ -4490,6 +4574,7 @@ async def handle_connect_subscription(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
if not subscription_link:
|
||||
await callback.answer(
|
||||
@@ -4618,15 +4703,25 @@ async def handle_connect_subscription(
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
device_text = texts.t(
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE",
|
||||
"""📱 <b>Подключить подписку</b>
|
||||
if hide_subscription_link:
|
||||
device_text = texts.t(
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN",
|
||||
"""📱 <b>Подключить подписку</b>
|
||||
|
||||
ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка".
|
||||
|
||||
💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:""",
|
||||
)
|
||||
else:
|
||||
device_text = texts.t(
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE",
|
||||
"""📱 <b>Подключить подписку</b>
|
||||
|
||||
🔗 <b>Ссылка подписки:</b>
|
||||
<code>{subscription_url}</code>
|
||||
|
||||
💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:""",
|
||||
).format(subscription_url=subscription_link)
|
||||
).format(subscription_url=subscription_link)
|
||||
|
||||
await callback.message.edit_text(
|
||||
device_text,
|
||||
@@ -4725,13 +4820,7 @@ async def handle_device_guide(
|
||||
return
|
||||
|
||||
apps = get_apps_for_device(device_type, db_user.language)
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
if not subscription_link:
|
||||
await callback.answer(
|
||||
texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
if not apps:
|
||||
await callback.answer(
|
||||
@@ -4742,14 +4831,29 @@ async def handle_device_guide(
|
||||
|
||||
featured_app = next((app for app in apps if app.get('isFeatured', False)), apps[0])
|
||||
|
||||
if hide_subscription_link:
|
||||
link_section = (
|
||||
texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 <b>Ссылка подписки:</b>")
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE",
|
||||
"ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
|
||||
)
|
||||
+ "\n\n"
|
||||
)
|
||||
else:
|
||||
link_section = (
|
||||
texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 <b>Ссылка подписки:</b>")
|
||||
+ f"\n<code>{subscription_link}</code>\n\n"
|
||||
)
|
||||
|
||||
guide_text = (
|
||||
texts.t(
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE",
|
||||
"📱 <b>Настройка для {device_name}</b>",
|
||||
).format(device_name=get_device_name(device_type, db_user.language))
|
||||
+ "\n\n"
|
||||
+ texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 <b>Ссылка подписки:</b>")
|
||||
+ f"\n<code>{subscription_link}</code>\n\n"
|
||||
+ link_section
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP",
|
||||
"📋 <b>Рекомендуемое приложение:</b> {app_name}",
|
||||
@@ -4841,6 +4945,15 @@ async def handle_specific_app_guide(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
|
||||
if not subscription_link:
|
||||
await callback.answer(
|
||||
texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
apps = get_apps_for_device(device_type, db_user.language)
|
||||
app = next((a for a in apps if a['id'] == app_id), None)
|
||||
|
||||
@@ -4851,14 +4964,31 @@ async def handle_specific_app_guide(
|
||||
)
|
||||
return
|
||||
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
if hide_subscription_link:
|
||||
link_section = (
|
||||
texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 <b>Ссылка подписки:</b>")
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE",
|
||||
"ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
|
||||
)
|
||||
+ "\n\n"
|
||||
)
|
||||
else:
|
||||
link_section = (
|
||||
texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 <b>Ссылка подписки:</b>")
|
||||
+ f"\n<code>{subscription_link}</code>\n\n"
|
||||
)
|
||||
|
||||
guide_text = (
|
||||
texts.t(
|
||||
"SUBSCRIPTION_SPECIFIC_APP_TITLE",
|
||||
"📱 <b>{app_name} - {device_name}</b>",
|
||||
).format(app_name=app['name'], device_name=get_device_name(device_type, db_user.language))
|
||||
+ "\n\n"
|
||||
+ texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 <b>Ссылка подписки:</b>")
|
||||
+ f"\n<code>{subscription_link}</code>\n\n"
|
||||
+ link_section
|
||||
+ texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "<b>Шаг 1 - Установка:</b>")
|
||||
+ f"\n{app['installationStep']['description'][db_user.language]}\n\n"
|
||||
+ texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "<b>Шаг 2 - Добавление подписки:</b>")
|
||||
@@ -4919,6 +5049,7 @@ async def handle_open_subscription_link(
|
||||
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
redirect_link = get_happ_cryptolink_redirect_link(subscription_link)
|
||||
happ_scheme_link = convert_subscription_link_to_happ_scheme(subscription_link)
|
||||
happ_message = (
|
||||
texts.t(
|
||||
"SUBSCRIPTION_HAPP_OPEN_TITLE",
|
||||
@@ -4928,12 +5059,12 @@ async def handle_open_subscription_link(
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_HAPP_OPEN_LINK",
|
||||
"<a href=\"{subscription_link}\">🔓 Открыть ссылку в Happ</a>",
|
||||
).format(subscription_link=subscription_link)
|
||||
).format(subscription_link=happ_scheme_link)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_HAPP_OPEN_HINT",
|
||||
"💡 Если ссылка не открывается автоматически, скопируйте её вручную: <code>{subscription_link}</code>",
|
||||
).format(subscription_link=subscription_link)
|
||||
"💡 Если ссылка не открывается автоматически, скопируйте её вручную:",
|
||||
)
|
||||
)
|
||||
|
||||
if redirect_link:
|
||||
@@ -4942,6 +5073,11 @@ async def handle_open_subscription_link(
|
||||
"▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
|
||||
)
|
||||
|
||||
happ_message += "\n\n" + texts.t(
|
||||
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK",
|
||||
"<blockquote expandable><code>{crypto_link}</code></blockquote>",
|
||||
).format(crypto_link=subscription_link)
|
||||
|
||||
keyboard = get_happ_cryptolink_keyboard(
|
||||
subscription_link,
|
||||
db_user.language,
|
||||
|
||||
+13
-1
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User, Ticket, TicketStatus
|
||||
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.keyboards.inline import (
|
||||
get_ticket_cancel_keyboard,
|
||||
get_my_tickets_keyboard,
|
||||
@@ -937,10 +938,21 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||||
if len(title) > 60:
|
||||
title = title[:57] + "..."
|
||||
|
||||
# Загрузим пользователя, чтобы отобразить реальный Telegram ID и username
|
||||
try:
|
||||
user = await get_user_by_id(db, ticket.user_id)
|
||||
except Exception:
|
||||
user = None
|
||||
full_name = user.full_name if user else "Unknown"
|
||||
telegram_id_display = user.telegram_id if user else "—"
|
||||
username_display = (user.username or "отсутствует") if user else "отсутствует"
|
||||
|
||||
notification_text = (
|
||||
f"🎫 <b>НОВЫЙ ТИКЕТ</b>\n\n"
|
||||
f"🆔 <b>ID:</b> <code>{ticket.id}</code>\n"
|
||||
f"👤 <b>User ID:</b> <code>{ticket.user_id}</code>\n"
|
||||
f"👤 <b>Пользователь:</b> {full_name}\n"
|
||||
f"🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n"
|
||||
f"📱 <b>Username:</b> @{username_display}\n"
|
||||
f"📝 <b>Заголовок:</b> {title or '—'}\n"
|
||||
f"📅 <b>Создан:</b> {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
)
|
||||
|
||||
+83
-15
@@ -17,6 +17,11 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LANGUAGE_DISPLAY_NAMES = {
|
||||
"ru": "🇷🇺 Русский",
|
||||
"en": "🇬🇧 English",
|
||||
}
|
||||
|
||||
def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -62,6 +67,49 @@ def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKe
|
||||
])
|
||||
|
||||
|
||||
def get_language_selection_keyboard(
|
||||
current_language: Optional[str] = None,
|
||||
*,
|
||||
include_back: bool = False,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
) -> InlineKeyboardMarkup:
|
||||
available_languages = settings.get_available_languages()
|
||||
|
||||
buttons: List[List[InlineKeyboardButton]] = []
|
||||
row: List[InlineKeyboardButton] = []
|
||||
|
||||
normalized_current = (current_language or "").lower()
|
||||
|
||||
for index, lang_code in enumerate(available_languages, start=1):
|
||||
normalized_code = lang_code.lower()
|
||||
display_name = _LANGUAGE_DISPLAY_NAMES.get(
|
||||
normalized_code,
|
||||
normalized_code.upper(),
|
||||
)
|
||||
|
||||
prefix = "✅ " if normalized_code == normalized_current and normalized_current else ""
|
||||
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{prefix}{display_name}",
|
||||
callback_data=f"language_select:{normalized_code}",
|
||||
)
|
||||
)
|
||||
|
||||
if len(row) == 2:
|
||||
buttons.append(row)
|
||||
row = []
|
||||
|
||||
if row:
|
||||
buttons.append(row)
|
||||
|
||||
if include_back:
|
||||
texts = get_texts(language)
|
||||
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_main_menu_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
is_admin: bool = False,
|
||||
@@ -224,6 +272,11 @@ def get_main_menu_keyboard(
|
||||
support_row.append(InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support"))
|
||||
support_row.append(InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules"))
|
||||
keyboard.append(support_row)
|
||||
|
||||
if settings.is_language_selection_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language")
|
||||
])
|
||||
if settings.DEBUG:
|
||||
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
|
||||
|
||||
@@ -392,28 +445,31 @@ def get_insufficient_balance_keyboard(
|
||||
texts = get_texts(language)
|
||||
keyboard = get_payment_methods_keyboard(amount_kopeks or 0, language)
|
||||
|
||||
if resume_callback:
|
||||
keyboard.inline_keyboard.insert(
|
||||
0,
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data=resume_callback,
|
||||
)
|
||||
],
|
||||
)
|
||||
back_row_index: int | None = None
|
||||
|
||||
if keyboard.inline_keyboard:
|
||||
last_row = keyboard.inline_keyboard[-1]
|
||||
if (
|
||||
len(last_row) == 1
|
||||
and isinstance(last_row[0], InlineKeyboardButton)
|
||||
and last_row[0].callback_data == "menu_balance"
|
||||
and last_row[0].callback_data in {"menu_balance", "back_to_menu"}
|
||||
):
|
||||
keyboard.inline_keyboard[-1][0] = InlineKeyboardButton(
|
||||
text=last_row[0].text,
|
||||
text=texts.t("PAYMENT_RETURN_HOME_BUTTON", "🏠 На главную"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
back_row_index = len(keyboard.inline_keyboard) - 1
|
||||
|
||||
if resume_callback:
|
||||
return_row = [
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data=resume_callback,
|
||||
)
|
||||
]
|
||||
|
||||
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
|
||||
keyboard.inline_keyboard.insert(insert_index, return_row)
|
||||
|
||||
return keyboard
|
||||
|
||||
@@ -829,7 +885,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
if settings.is_pal24_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)"),
|
||||
text=texts.t("PAYMENT_CARD_PAL24", "🏦 СБП (PayPalych)"),
|
||||
callback_data=_build_callback("pal24")
|
||||
)
|
||||
])
|
||||
@@ -1922,10 +1978,22 @@ def get_admin_tickets_keyboard(
|
||||
status_emoji = ticket.get('status_emoji', '❓')
|
||||
if ticket.get('is_closed', False):
|
||||
status_emoji = '✅'
|
||||
user_name = ticket.get('user_name', 'Unknown')[:15]
|
||||
user_name = ticket.get('user_name', 'Unknown')
|
||||
username = ticket.get('username')
|
||||
telegram_id = ticket.get('telegram_id')
|
||||
# Сформируем компактное отображение: Имя (@username | ID)
|
||||
name_parts = [user_name[:15]]
|
||||
contact_parts = []
|
||||
if username:
|
||||
contact_parts.append(f"@{username}")
|
||||
if telegram_id:
|
||||
contact_parts.append(str(telegram_id))
|
||||
if contact_parts:
|
||||
name_parts.append(f"({' | '.join(contact_parts)})")
|
||||
name_display = ' '.join(name_parts)
|
||||
title = ticket.get('title', 'Без названия')[:20]
|
||||
locked_emoji = ticket.get('locked_emoji', '')
|
||||
button_text = f"{status_emoji} #{ticket['id']} {locked_emoji} {user_name}: {title}".replace(" ", " ")
|
||||
button_text = f"{status_emoji} #{ticket['id']} {locked_emoji} {name_display}: {title}".replace(" ", " ")
|
||||
row = [InlineKeyboardButton(text=button_text, callback_data=f"admin_view_ticket_{ticket['id']}")]
|
||||
if ticket.get('is_closed', False):
|
||||
closed_rows.append(row)
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
|
||||
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Insufficient funds</b>\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.",
|
||||
"LANGUAGE_PROMPT": "🌐 Choose the interface language:",
|
||||
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
|
||||
"LOADING": "⏳ Loading...",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
|
||||
@@ -330,7 +331,9 @@
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Connect subscription</b>\n\n📱 Tap the button below to open the app:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ The subscription link is available via the buttons below or in the “My subscription” section.",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Connect subscription</b>\n\n🔗 <b>Subscription link:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Connect subscription</b>\n\nℹ️ The subscription link is available via the buttons below or in the “My subscription” section.\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
|
||||
@@ -412,6 +415,7 @@
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Balance top-up methods</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
|
||||
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||
|
||||
@@ -144,6 +144,7 @@
|
||||
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
|
||||
"INVALID_AMOUNT": "❌ Неверная сумма",
|
||||
"LANGUAGE_PROMPT": "🌐 Выберите язык интерфейса:",
|
||||
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
|
||||
"LOADING": "⏳ Загрузка...",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n",
|
||||
@@ -332,7 +333,9 @@
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🔗 <b>Ссылка подписки:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Подключить подписку</b>\n\nℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
|
||||
@@ -414,6 +417,7 @@
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Способы пополнения баланса</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
|
||||
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||
|
||||
+12
-5
@@ -12,6 +12,7 @@ from app.database.crud.user import get_user_by_telegram_id, create_user
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.states import RegistrationStates
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -115,6 +116,7 @@ class AuthMiddleware(BaseMiddleware):
|
||||
current_state = await state.get_state()
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_language.state,
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
@@ -125,7 +127,10 @@ class AuthMiddleware(BaseMiddleware):
|
||||
or (
|
||||
isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
|
||||
and (
|
||||
event.data in ['rules_accept', 'rules_decline', 'referral_skip']
|
||||
or event.data.startswith('language_select:')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -158,15 +163,17 @@ class AuthMiddleware(BaseMiddleware):
|
||||
logger.info(f"🔄 [Middleware] Username обновлен для {user.id}: '{old_username}' → '{db_user.username}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.first_name != user.first_name:
|
||||
safe_first = sanitize_telegram_name(user.first_name)
|
||||
safe_last = sanitize_telegram_name(user.last_name)
|
||||
if db_user.first_name != safe_first:
|
||||
old_first_name = db_user.first_name
|
||||
db_user.first_name = user.first_name
|
||||
db_user.first_name = safe_first
|
||||
logger.info(f"🔄 [Middleware] Имя обновлено для {user.id}: '{old_first_name}' → '{db_user.first_name}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.last_name != user.last_name:
|
||||
if db_user.last_name != safe_last:
|
||||
old_last_name = db_user.last_name
|
||||
db_user.last_name = user.last_name
|
||||
db_user.last_name = safe_last
|
||||
logger.info(f"🔄 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}' → '{db_user.last_name}'")
|
||||
profile_updated = True
|
||||
|
||||
|
||||
@@ -7,8 +7,17 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User, Subscription, Transaction, TransactionType
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
PromoCodeType,
|
||||
PromoGroup,
|
||||
Subscription,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,20 +34,141 @@ class AdminNotificationService:
|
||||
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str:
|
||||
if not referred_by_id:
|
||||
return "Нет"
|
||||
|
||||
|
||||
try:
|
||||
referrer = await get_user_by_id(db, referred_by_id)
|
||||
if not referrer:
|
||||
return f"ID {referred_by_id} (не найден)"
|
||||
|
||||
|
||||
if referrer.username:
|
||||
return f"@{referrer.username} (ID: {referred_by_id})"
|
||||
else:
|
||||
return f"ID {referrer.telegram_id}"
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения данных рефера {referred_by_id}: {e}")
|
||||
return f"ID {referred_by_id}"
|
||||
|
||||
async def _get_user_promo_group(self, db: AsyncSession, user: User) -> Optional[PromoGroup]:
|
||||
if getattr(user, "promo_group", None):
|
||||
return user.promo_group
|
||||
|
||||
if not user.promo_group_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
await db.refresh(user, attribute_names=["promo_group"])
|
||||
except Exception:
|
||||
# relationship might not be available — fallback to direct fetch
|
||||
pass
|
||||
|
||||
if getattr(user, "promo_group", None):
|
||||
return user.promo_group
|
||||
|
||||
try:
|
||||
return await get_promo_group_by_id(db, user.promo_group_id)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка загрузки промогруппы %s пользователя %s: %s",
|
||||
user.promo_group_id,
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
|
||||
discount_lines: List[str] = []
|
||||
|
||||
discount_map = {
|
||||
"servers": ("Серверы", promo_group.server_discount_percent),
|
||||
"traffic": ("Трафик", promo_group.traffic_discount_percent),
|
||||
"devices": ("Устройства", promo_group.device_discount_percent),
|
||||
}
|
||||
|
||||
for _, (title, percent) in discount_map.items():
|
||||
if percent and percent > 0:
|
||||
discount_lines.append(f"• {title}: -{percent}%")
|
||||
|
||||
period_discounts_raw = promo_group.period_discounts or {}
|
||||
period_items: List[tuple[int, int]] = []
|
||||
|
||||
if isinstance(period_discounts_raw, dict):
|
||||
for raw_days, raw_percent in period_discounts_raw.items():
|
||||
try:
|
||||
days = int(raw_days)
|
||||
percent = int(raw_percent)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if percent > 0:
|
||||
period_items.append((days, percent))
|
||||
|
||||
period_items.sort(key=lambda item: item[0])
|
||||
|
||||
if period_items:
|
||||
formatted_periods = ", ".join(
|
||||
f"{days} д. — -{percent}%" for days, percent in period_items
|
||||
)
|
||||
discount_lines.append(f"• Периоды: {formatted_periods}")
|
||||
|
||||
if promo_group.apply_discounts_to_addons:
|
||||
discount_lines.append("• Доп. услуги: ✅ скидка действует")
|
||||
else:
|
||||
discount_lines.append("• Доп. услуги: ❌ без скидки")
|
||||
|
||||
return discount_lines
|
||||
|
||||
def _format_promo_group_block(
|
||||
self,
|
||||
promo_group: Optional[PromoGroup],
|
||||
*,
|
||||
title: str = "Промогруппа",
|
||||
icon: str = "🏷️",
|
||||
) -> str:
|
||||
if not promo_group:
|
||||
return f"{icon} <b>{title}:</b> —"
|
||||
|
||||
lines = [f"{icon} <b>{title}:</b> {promo_group.name}"]
|
||||
|
||||
discount_lines = self._format_promo_group_discounts(promo_group)
|
||||
if discount_lines:
|
||||
lines.append("💸 <b>Скидки:</b>")
|
||||
lines.extend(discount_lines)
|
||||
else:
|
||||
lines.append("💸 <b>Скидки:</b> отсутствуют")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_promocode_type_display(self, promo_type: Optional[str]) -> str:
|
||||
mapping = {
|
||||
PromoCodeType.BALANCE.value: "💰 Бонус на баланс",
|
||||
PromoCodeType.SUBSCRIPTION_DAYS.value: "⏰ Доп. дни подписки",
|
||||
PromoCodeType.TRIAL_SUBSCRIPTION.value: "🎁 Триал подписка",
|
||||
}
|
||||
|
||||
if not promo_type:
|
||||
return "ℹ️ Не указан"
|
||||
|
||||
return mapping.get(promo_type, f"ℹ️ {promo_type}")
|
||||
|
||||
def _format_campaign_bonus(self, campaign: AdvertisingCampaign) -> List[str]:
|
||||
if campaign.is_balance_bonus:
|
||||
return [
|
||||
f"💰 Баланс: {settings.format_price(campaign.balance_bonus_kopeks or 0)}",
|
||||
]
|
||||
|
||||
if campaign.is_subscription_bonus:
|
||||
default_devices = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1)
|
||||
details = [
|
||||
f"📅 Дней подписки: {campaign.subscription_duration_days or 0}",
|
||||
f"📊 Трафик: {campaign.subscription_traffic_gb or 0} ГБ",
|
||||
f"📱 Устройства: {campaign.subscription_device_limit or default_devices}",
|
||||
]
|
||||
if campaign.subscription_squads:
|
||||
details.append(f"🌐 Сквады: {len(campaign.subscription_squads)} шт.")
|
||||
return details
|
||||
|
||||
return ["ℹ️ Бонусы не предусмотрены"]
|
||||
|
||||
async def send_trial_activation_notification(
|
||||
self,
|
||||
@@ -52,7 +182,9 @@ class AdminNotificationService:
|
||||
try:
|
||||
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
@@ -60,6 +192,8 @@ class AdminNotificationService:
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
⏰ <b>Параметры триала:</b>
|
||||
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
|
||||
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
|
||||
@@ -102,7 +236,9 @@ class AdminNotificationService:
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
@@ -110,6 +246,8 @@ class AdminNotificationService:
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
@@ -214,7 +352,7 @@ class AdminNotificationService:
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
deposit_count_result = await db.execute(
|
||||
select(func.count())
|
||||
@@ -230,7 +368,14 @@ class AdminNotificationService:
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
subscription_result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = subscription_result.scalar_one_or_none()
|
||||
subscription_status = self._get_subscription_status(subscription)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
@@ -238,6 +383,8 @@ class AdminNotificationService:
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
💳 <b>Статус:</b> {topup_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Детали пополнения:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
@@ -249,7 +396,7 @@ class AdminNotificationService:
|
||||
➕ Изменение: +{settings.format_price(balance_change)}
|
||||
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
📱 <b>Подписка:</b> {self._get_subscription_status(user)}
|
||||
📱 <b>Подписка:</b> {subscription_status}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
@@ -266,21 +413,31 @@ class AdminNotificationService:
|
||||
subscription: Subscription,
|
||||
transaction: Transaction,
|
||||
extended_days: int,
|
||||
old_end_date: datetime
|
||||
old_end_date: datetime,
|
||||
*,
|
||||
new_end_date: datetime | None = None,
|
||||
balance_after: int | None = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
current_end_date = new_end_date or subscription.end_date
|
||||
current_balance = balance_after if balance_after is not None else user.balance_kopeks
|
||||
|
||||
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
@@ -289,23 +446,206 @@ class AdminNotificationService:
|
||||
📅 <b>Продление:</b>
|
||||
➕ Добавлено дней: {extended_days}
|
||||
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
|
||||
📱 <b>Текущие параметры:</b>
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(current_balance)}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def send_promocode_activation_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
promocode_data: Dict[str, Any],
|
||||
effect_description: str,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
type_display = self._get_promocode_type_display(promocode_data.get("type"))
|
||||
usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}"
|
||||
|
||||
message_lines = [
|
||||
"🎫 <b>АКТИВАЦИЯ ПРОМОКОДА</b>",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
"🎟️ <b>Промокод:</b>",
|
||||
f"🔖 Код: <code>{promocode_data.get('code')}</code>",
|
||||
f"🧾 Тип: {type_display}",
|
||||
f"📊 Использования: {usage_info}",
|
||||
]
|
||||
|
||||
balance_bonus = promocode_data.get("balance_bonus_kopeks", 0)
|
||||
if balance_bonus:
|
||||
message_lines.append(
|
||||
f"💰 Бонус на баланс: {settings.format_price(balance_bonus)}"
|
||||
)
|
||||
|
||||
subscription_days = promocode_data.get("subscription_days", 0)
|
||||
if subscription_days:
|
||||
message_lines.append(f"📅 Доп. дни подписки: {subscription_days}")
|
||||
|
||||
valid_until = promocode_data.get("valid_until")
|
||||
if valid_until:
|
||||
message_lines.append(
|
||||
f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
|
||||
if isinstance(valid_until, datetime)
|
||||
else f"⏳ Действует до: {valid_until}"
|
||||
)
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
"📝 <b>Эффект:</b>",
|
||||
effect_description.strip() or "✅ Промокод активирован",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об активации промокода: {e}")
|
||||
return False
|
||||
|
||||
async def send_campaign_link_visit_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
telegram_user: types.User,
|
||||
campaign: AdvertisingCampaign,
|
||||
user: Optional[User] = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
user_status = "🆕 Новый пользователь" if not user else "👥 Уже зарегистрирован"
|
||||
promo_block = (
|
||||
self._format_promo_group_block(await self._get_user_promo_group(db, user))
|
||||
if user
|
||||
else self._format_promo_group_block(None)
|
||||
)
|
||||
|
||||
full_name = telegram_user.full_name or telegram_user.username or str(telegram_user.id)
|
||||
username = f"@{telegram_user.username}" if telegram_user.username else "отсутствует"
|
||||
|
||||
message_lines = [
|
||||
"📣 <b>ПЕРЕХОД ПО РЕКЛАМНОЙ КАМПАНИИ</b>",
|
||||
"",
|
||||
f"🧾 <b>Кампания:</b> {campaign.name}",
|
||||
f"🆔 ID кампании: {campaign.id}",
|
||||
f"🔗 Start-параметр: <code>{campaign.start_parameter}</code>",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{telegram_user.id}</code>",
|
||||
f"📱 <b>Username:</b> {username}",
|
||||
user_status,
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
"🎯 <b>Бонус кампании:</b>",
|
||||
]
|
||||
|
||||
bonus_lines = self._format_campaign_bonus(campaign)
|
||||
message_lines.extend(bonus_lines)
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о переходе по кампании: {e}")
|
||||
return False
|
||||
|
||||
async def send_user_promo_group_change_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
old_group: Optional[PromoGroup],
|
||||
new_group: PromoGroup,
|
||||
*,
|
||||
reason: Optional[str] = None,
|
||||
initiator: Optional[User] = None,
|
||||
automatic: bool = False,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
title = "🤖 АВТОМАТИЧЕСКАЯ СМЕНА ПРОМОГРУППЫ" if automatic else "👥 СМЕНА ПРОМОГРУППЫ"
|
||||
initiator_line = None
|
||||
if initiator:
|
||||
initiator_line = (
|
||||
f"👮 <b>Инициатор:</b> {initiator.full_name} (ID: {initiator.telegram_id})"
|
||||
)
|
||||
elif automatic:
|
||||
initiator_line = "🤖 Автоматическое назначение"
|
||||
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"),
|
||||
]
|
||||
|
||||
if old_group and old_group.id != new_group.id:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
self._format_promo_group_block(
|
||||
old_group, title="Предыдущая промогруппа", icon="♻️"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if initiator_line:
|
||||
message_lines.extend(["", initiator_line])
|
||||
|
||||
if reason:
|
||||
message_lines.extend(["", f"📝 Причина: {reason}"])
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о смене промогруппы: {e}")
|
||||
return False
|
||||
|
||||
async def _send_message(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False) -> bool:
|
||||
if not self.chat_id:
|
||||
logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен")
|
||||
@@ -353,7 +693,7 @@ class AdminNotificationService:
|
||||
'yookassa': '💳 YooKassa (карта)',
|
||||
'tribute': '💎 Tribute (карта)',
|
||||
'mulenpay': '💳 Mulen Pay (карта)',
|
||||
'pal24': '💳 PayPalych (карта)',
|
||||
'pal24': '🏦 PayPalych (СБП)',
|
||||
'manual': '🛠️ Вручную (админ)',
|
||||
'balance': '💰 С баланса'
|
||||
}
|
||||
@@ -368,15 +708,14 @@ class AdminNotificationService:
|
||||
return "∞ Безлимит"
|
||||
return f"{traffic_gb} ГБ"
|
||||
|
||||
def _get_subscription_status(self, user: User) -> str:
|
||||
if not user.subscription:
|
||||
def _get_subscription_status(self, subscription: Optional[Subscription]) -> str:
|
||||
if not subscription:
|
||||
return "❌ Нет подписки"
|
||||
|
||||
sub = user.subscription
|
||||
if sub.is_trial:
|
||||
return f"🎯 Триал (до {sub.end_date.strftime('%d.%m')})"
|
||||
elif sub.is_active:
|
||||
return f"✅ Активна (до {sub.end_date.strftime('%d.%m')})"
|
||||
|
||||
if subscription.is_trial:
|
||||
return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
|
||||
elif subscription.is_active:
|
||||
return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
|
||||
else:
|
||||
return "❌ Неактивна"
|
||||
|
||||
@@ -744,50 +1083,64 @@ class AdminNotificationService:
|
||||
|
||||
try:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
update_types = {
|
||||
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
|
||||
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
|
||||
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы")
|
||||
}
|
||||
|
||||
|
||||
title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры"))
|
||||
|
||||
message = f"""{title}
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
🔧 <b>Изменение:</b>
|
||||
📋 Параметр: {param_name}"""
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
"🔧 <b>Изменение:</b>",
|
||||
f"📋 Параметр: {param_name}",
|
||||
]
|
||||
|
||||
if update_type == "servers":
|
||||
old_servers_info = await self._format_servers_detailed(old_value)
|
||||
new_servers_info = await self._format_servers_detailed(new_value)
|
||||
|
||||
message += f"""
|
||||
📉 Было: {old_servers_info}
|
||||
📈 Стало: {new_servers_info}"""
|
||||
message_lines.extend(
|
||||
[
|
||||
f"📉 Было: {old_servers_info}",
|
||||
f"📈 Стало: {new_servers_info}",
|
||||
]
|
||||
)
|
||||
else:
|
||||
message += f"""
|
||||
📉 Было: {self._format_update_value(old_value, update_type)}
|
||||
📈 Стало: {self._format_update_value(new_value, update_type)}"""
|
||||
message_lines.extend(
|
||||
[
|
||||
f"📉 Было: {self._format_update_value(old_value, update_type)}",
|
||||
f"📈 Стало: {self._format_update_value(new_value, update_type)}",
|
||||
]
|
||||
)
|
||||
|
||||
if price_paid > 0:
|
||||
message += f"\n💰 Доплачено: {settings.format_price(price_paid)}"
|
||||
message_lines.append(f"💰 Доплачено: {settings.format_price(price_paid)}")
|
||||
else:
|
||||
message += f"\n💸 Бесплатно"
|
||||
message_lines.append("💸 Бесплатно")
|
||||
|
||||
message += f"""
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
|
||||
f"💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}",
|
||||
f"🔗 <b>Рефер:</b> {referrer_info}",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Рефер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении подписки: {e}")
|
||||
|
||||
@@ -12,6 +12,7 @@ import aiofiles
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text, inspect
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
@@ -23,7 +24,8 @@ from app.database.models import (
|
||||
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
|
||||
CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage,
|
||||
MulenPayPayment, Pal24Payment
|
||||
MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken,
|
||||
server_squad_promo_groups
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -32,7 +34,7 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class BackupMetadata:
|
||||
timestamp: str
|
||||
version: str = "1.1"
|
||||
version: str = "1.2"
|
||||
database_type: str = "postgresql"
|
||||
backup_type: str = "full"
|
||||
tables_count: int = 0
|
||||
@@ -83,17 +85,23 @@ class BackupService:
|
||||
PromoCodeUse,
|
||||
ReferralEarning,
|
||||
SentNotification,
|
||||
DiscountOffer,
|
||||
BroadcastHistory,
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
SupportAuditLog,
|
||||
WebApiToken,
|
||||
]
|
||||
|
||||
if self._settings.include_logs:
|
||||
self.backup_models_ordered.append(MonitoringLog)
|
||||
|
||||
self.association_tables = {
|
||||
"server_squad_promo_groups": server_squad_promo_groups,
|
||||
}
|
||||
|
||||
def _load_settings(self) -> BackupSettings:
|
||||
return BackupSettings(
|
||||
auto_backup_enabled=os.getenv("BACKUP_AUTO_ENABLED", "true").lower() == "true",
|
||||
@@ -171,6 +179,7 @@ class BackupService:
|
||||
models_to_backup.append(MonitoringLog)
|
||||
|
||||
backup_data = {}
|
||||
association_data = {}
|
||||
total_records = 0
|
||||
|
||||
async for db in get_db():
|
||||
@@ -214,7 +223,11 @@ class BackupService:
|
||||
total_records += len(table_data)
|
||||
|
||||
logger.info(f"✅ Экспортировано {len(table_data)} записей из {table_name}")
|
||||
|
||||
|
||||
association_data = await self._export_association_tables(db)
|
||||
for records in association_data.values():
|
||||
total_records += len(records)
|
||||
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте данных: {e}")
|
||||
@@ -226,7 +239,7 @@ class BackupService:
|
||||
timestamp=datetime.utcnow().isoformat(),
|
||||
database_type="postgresql" if settings.is_postgresql() else "sqlite",
|
||||
backup_type="full",
|
||||
tables_count=len(models_to_backup),
|
||||
tables_count=len(models_to_backup) + len(association_data),
|
||||
total_records=total_records,
|
||||
compressed=compress,
|
||||
created_by=created_by,
|
||||
@@ -239,10 +252,17 @@ class BackupService:
|
||||
filename += ".gz"
|
||||
|
||||
backup_path = self.backup_dir / filename
|
||||
|
||||
|
||||
file_snapshots = await self._collect_file_snapshots()
|
||||
|
||||
backup_structure = {
|
||||
"metadata": asdict(metadata),
|
||||
"data": backup_data
|
||||
"data": backup_data,
|
||||
"associations": association_data,
|
||||
"files": file_snapshots,
|
||||
"config": {
|
||||
"backup_settings": asdict(self._settings)
|
||||
}
|
||||
}
|
||||
|
||||
if compress:
|
||||
@@ -271,7 +291,7 @@ class BackupService:
|
||||
size_mb = file_size / 1024 / 1024
|
||||
message = (f"✅ Бекап успешно создан!\n"
|
||||
f"📁 Файл: {filename}\n"
|
||||
f"📊 Таблиц: {len(models_to_backup)}\n"
|
||||
f"📊 Таблиц: {metadata.tables_count}\n"
|
||||
f"📈 Записей: {total_records:,}\n"
|
||||
f"💾 Размер: {size_mb:.2f} MB")
|
||||
|
||||
@@ -319,6 +339,8 @@ class BackupService:
|
||||
|
||||
metadata = backup_structure.get("metadata", {})
|
||||
backup_data = backup_structure.get("data", {})
|
||||
association_data = backup_structure.get("associations", {})
|
||||
file_snapshots = backup_structure.get("files", {})
|
||||
|
||||
if not backup_data:
|
||||
return False, "❌ Файл бекапа не содержит данных"
|
||||
@@ -377,6 +399,14 @@ class BackupService:
|
||||
|
||||
await self._update_user_referrals(db, backup_data)
|
||||
|
||||
assoc_tables, assoc_records = await self._restore_association_tables(
|
||||
db,
|
||||
association_data,
|
||||
clear_existing
|
||||
)
|
||||
restored_tables += assoc_tables
|
||||
restored_records += assoc_records
|
||||
|
||||
await db.commit()
|
||||
|
||||
break
|
||||
@@ -397,6 +427,11 @@ class BackupService:
|
||||
|
||||
if self.bot:
|
||||
await self._send_backup_notification("restore_success", message)
|
||||
|
||||
if file_snapshots:
|
||||
restored_files = await self._restore_file_snapshots(file_snapshots)
|
||||
if restored_files:
|
||||
logger.info(f"📁 Восстановлено файлов конфигурации: {restored_files}")
|
||||
|
||||
return True, message
|
||||
|
||||
@@ -543,6 +578,97 @@ class BackupService:
|
||||
return col.name
|
||||
return None
|
||||
|
||||
async def _export_association_tables(self, db: AsyncSession) -> Dict[str, List[Dict[str, Any]]]:
|
||||
association_data: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
for table_name, table_obj in self.association_tables.items():
|
||||
try:
|
||||
logger.info(f"📊 Экспортируем таблицу связей: {table_name}")
|
||||
result = await db.execute(select(table_obj))
|
||||
rows = result.mappings().all()
|
||||
association_data[table_name] = [dict(row) for row in rows]
|
||||
logger.info(
|
||||
f"✅ Экспортировано {len(rows)} связей из {table_name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка экспорта таблицы связей {table_name}: {e}")
|
||||
|
||||
return association_data
|
||||
|
||||
async def _restore_association_tables(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
association_data: Dict[str, List[Dict[str, Any]]],
|
||||
clear_existing: bool
|
||||
) -> Tuple[int, int]:
|
||||
if not association_data:
|
||||
return 0, 0
|
||||
|
||||
restored_tables = 0
|
||||
restored_records = 0
|
||||
|
||||
if "server_squad_promo_groups" in association_data:
|
||||
restored = await self._restore_server_squad_promo_groups(
|
||||
db,
|
||||
association_data["server_squad_promo_groups"],
|
||||
clear_existing
|
||||
)
|
||||
restored_tables += 1
|
||||
restored_records += restored
|
||||
|
||||
return restored_tables, restored_records
|
||||
|
||||
async def _restore_server_squad_promo_groups(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
records: List[Dict[str, Any]],
|
||||
clear_existing: bool
|
||||
) -> int:
|
||||
if not records:
|
||||
return 0
|
||||
|
||||
if clear_existing:
|
||||
await db.execute(server_squad_promo_groups.delete())
|
||||
|
||||
restored = 0
|
||||
|
||||
for record in records:
|
||||
server_id = record.get("server_squad_id")
|
||||
promo_id = record.get("promo_group_id")
|
||||
|
||||
if server_id is None or promo_id is None:
|
||||
logger.warning(
|
||||
"Пропущена некорректная запись server_squad_promo_groups: %s",
|
||||
record
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
server_squad_promo_groups.insert().values(
|
||||
server_squad_id=server_id,
|
||||
promo_group_id=promo_id
|
||||
)
|
||||
)
|
||||
restored += 1
|
||||
except IntegrityError:
|
||||
logger.debug(
|
||||
"Запись server_squad_promo_groups (%s, %s) уже существует",
|
||||
server_id,
|
||||
promo_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s",
|
||||
server_id,
|
||||
promo_id,
|
||||
e
|
||||
)
|
||||
await db.rollback()
|
||||
raise e
|
||||
|
||||
return restored
|
||||
|
||||
async def _restore_table_records(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -590,17 +716,18 @@ class BackupService:
|
||||
|
||||
async def _clear_database_tables(self, db: AsyncSession):
|
||||
tables_order = [
|
||||
"server_squad_promo_groups",
|
||||
"ticket_messages", "tickets", "support_audit_logs",
|
||||
"advertising_campaign_registrations", "advertising_campaigns",
|
||||
"subscription_servers", "sent_notifications",
|
||||
"user_messages", "broadcast_history", "subscription_conversions",
|
||||
"discount_offers", "user_messages", "broadcast_history", "subscription_conversions",
|
||||
"referral_earnings", "promocode_uses",
|
||||
"yookassa_payments", "cryptobot_payments",
|
||||
"mulenpay_payments", "pal24_payments",
|
||||
"transactions", "welcome_texts", "subscriptions",
|
||||
"promocodes", "users", "promo_groups",
|
||||
"server_squads", "squads", "service_rules",
|
||||
"system_settings", "monitoring_logs"
|
||||
"system_settings", "web_api_tokens", "monitoring_logs"
|
||||
]
|
||||
|
||||
for table_name in tables_order:
|
||||
@@ -610,6 +737,57 @@ class BackupService:
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось очистить таблицу {table_name}: {e}")
|
||||
|
||||
async def _collect_file_snapshots(self) -> Dict[str, Dict[str, Any]]:
|
||||
snapshots: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
app_config_path = settings.get_app_config_path()
|
||||
if app_config_path:
|
||||
path_obj = Path(app_config_path)
|
||||
if path_obj.exists() and path_obj.is_file():
|
||||
try:
|
||||
async with aiofiles.open(path_obj, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
snapshots["app_config"] = {
|
||||
"path": str(path_obj),
|
||||
"content": content,
|
||||
"modified_at": datetime.fromtimestamp(
|
||||
path_obj.stat().st_mtime
|
||||
).isoformat()
|
||||
}
|
||||
logger.info(
|
||||
"📁 Добавлен в бекап файл конфигурации: %s",
|
||||
path_obj
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка чтения файла конфигурации %s: %s",
|
||||
path_obj,
|
||||
e
|
||||
)
|
||||
|
||||
return snapshots
|
||||
|
||||
async def _restore_file_snapshots(self, file_snapshots: Dict[str, Dict[str, Any]]) -> int:
|
||||
restored_files = 0
|
||||
|
||||
if not file_snapshots:
|
||||
return restored_files
|
||||
|
||||
app_config_snapshot = file_snapshots.get("app_config")
|
||||
if app_config_snapshot:
|
||||
target_path = Path(settings.get_app_config_path())
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
async with aiofiles.open(target_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(app_config_snapshot.get("content", ""))
|
||||
restored_files += 1
|
||||
logger.info("📁 Файл app-config восстановлен по пути %s", target_path)
|
||||
except Exception as e:
|
||||
logger.error("Ошибка восстановления файла %s: %s", target_path, e)
|
||||
|
||||
return restored_files
|
||||
|
||||
async def get_backup_list(self) -> List[Dict[str, Any]]:
|
||||
backups = []
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import BroadcastHistory
|
||||
from app.handlers.admin.messages import (
|
||||
create_broadcast_keyboard,
|
||||
get_custom_users,
|
||||
get_target_users,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
VALID_MEDIA_TYPES = {"photo", "video", "document"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BroadcastMediaConfig:
|
||||
type: str
|
||||
file_id: str
|
||||
caption: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BroadcastConfig:
|
||||
target: str
|
||||
message_text: str
|
||||
selected_buttons: list[str]
|
||||
media: Optional[BroadcastMediaConfig] = None
|
||||
initiator_name: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _BroadcastTask:
|
||||
task: asyncio.Task
|
||||
cancel_event: asyncio.Event
|
||||
|
||||
|
||||
class BroadcastService:
|
||||
"""Handles broadcast execution triggered from the admin web API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._bot: Optional[Bot] = None
|
||||
self._tasks: dict[int, _BroadcastTask] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
self._bot = bot
|
||||
|
||||
def is_running(self, broadcast_id: int) -> bool:
|
||||
task_entry = self._tasks.get(broadcast_id)
|
||||
return bool(task_entry and not task_entry.task.done())
|
||||
|
||||
async def start_broadcast(self, broadcast_id: int, config: BroadcastConfig) -> None:
|
||||
if self._bot is None:
|
||||
logger.error("Невозможно запустить рассылку %s: бот не инициализирован", broadcast_id)
|
||||
await self._mark_failed(broadcast_id)
|
||||
return
|
||||
|
||||
cancel_event = asyncio.Event()
|
||||
|
||||
async with self._lock:
|
||||
if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done():
|
||||
logger.warning("Рассылка %s уже запущена", broadcast_id)
|
||||
return
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._run_broadcast(broadcast_id, config, cancel_event),
|
||||
name=f"broadcast-{broadcast_id}",
|
||||
)
|
||||
self._tasks[broadcast_id] = _BroadcastTask(task=task, cancel_event=cancel_event)
|
||||
task.add_done_callback(lambda _: self._tasks.pop(broadcast_id, None))
|
||||
|
||||
async def request_stop(self, broadcast_id: int) -> bool:
|
||||
async with self._lock:
|
||||
task_entry = self._tasks.get(broadcast_id)
|
||||
if not task_entry:
|
||||
return False
|
||||
|
||||
task_entry.cancel_event.set()
|
||||
return True
|
||||
|
||||
async def _run_broadcast(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
config: BroadcastConfig,
|
||||
cancel_event: asyncio.Event,
|
||||
) -> None:
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
try:
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
logger.error("Запись рассылки %s не найдена в БД", broadcast_id)
|
||||
return
|
||||
|
||||
broadcast.status = "in_progress"
|
||||
broadcast.sent_count = 0
|
||||
broadcast.failed_count = 0
|
||||
await session.commit()
|
||||
|
||||
recipients = await self._fetch_recipients(config.target)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
logger.error("Запись рассылки %s удалена до запуска", broadcast_id)
|
||||
return
|
||||
|
||||
broadcast.total_count = len(recipients)
|
||||
await session.commit()
|
||||
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
if not recipients:
|
||||
logger.info("Рассылка %s: получатели не найдены", broadcast_id)
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
return
|
||||
|
||||
keyboard = self._build_keyboard(config.selected_buttons)
|
||||
|
||||
for index, user in enumerate(recipients, start=1):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
sent_count += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed_count += 1
|
||||
logger.error(
|
||||
"Ошибка отправки рассылки %s пользователю %s: %s",
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
if index % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Критическая ошибка при выполнении рассылки %s: %s", broadcast_id, exc)
|
||||
await self._mark_failed(broadcast_id, sent_count, failed_count)
|
||||
|
||||
async def _fetch_recipients(self, target: str):
|
||||
async with AsyncSessionLocal() as session:
|
||||
if target.startswith("custom_"):
|
||||
criteria = target[len("custom_"):]
|
||||
return await get_custom_users(session, criteria)
|
||||
return await get_target_users(session, target)
|
||||
|
||||
def _build_keyboard(self, selected_buttons: Optional[list[str]]) -> Optional[InlineKeyboardMarkup]:
|
||||
if selected_buttons is None:
|
||||
selected_buttons = []
|
||||
return create_broadcast_keyboard(selected_buttons)
|
||||
|
||||
async def _deliver_message(
|
||||
self,
|
||||
telegram_id: int,
|
||||
config: BroadcastConfig,
|
||||
keyboard: Optional[InlineKeyboardMarkup],
|
||||
) -> None:
|
||||
if not self._bot:
|
||||
raise RuntimeError("Телеграм-бот не инициализирован")
|
||||
|
||||
if config.media and config.media.type in VALID_MEDIA_TYPES:
|
||||
caption = config.media.caption or config.message_text
|
||||
if config.media.type == "photo":
|
||||
await self._bot.send_photo(
|
||||
chat_id=telegram_id,
|
||||
photo=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
elif config.media.type == "video":
|
||||
await self._bot.send_video(
|
||||
chat_id=telegram_id,
|
||||
video=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
elif config.media.type == "document":
|
||||
await self._bot.send_document(
|
||||
chat_id=telegram_id,
|
||||
document=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return
|
||||
|
||||
await self._bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=config.message_text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
async def _mark_finished(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
*,
|
||||
cancelled: bool,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
return
|
||||
|
||||
broadcast.sent_count = sent_count
|
||||
broadcast.failed_count = failed_count
|
||||
broadcast.status = "cancelled" if cancelled else (
|
||||
"completed" if failed_count == 0 else "partial"
|
||||
)
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _mark_cancelled(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
) -> None:
|
||||
await self._mark_finished(
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
cancelled=True,
|
||||
)
|
||||
|
||||
async def _mark_failed(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int = 0,
|
||||
failed_count: int = 0,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
return
|
||||
|
||||
broadcast.sent_count = sent_count
|
||||
broadcast.failed_count = failed_count or broadcast.failed_count
|
||||
broadcast.status = "failed"
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
|
||||
broadcast_service = BroadcastService()
|
||||
|
||||
@@ -795,7 +795,12 @@ class MonitoringService:
|
||||
|
||||
if success:
|
||||
await extend_subscription(db, subscription, 30)
|
||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
||||
await self.subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="автопродление подписки",
|
||||
)
|
||||
|
||||
if self.bot:
|
||||
await self._send_autopay_success_notification(user, renewal_cost, 30)
|
||||
@@ -1325,10 +1330,17 @@ class MonitoringService:
|
||||
if len(title) > 60:
|
||||
title = title[:57] + '...'
|
||||
|
||||
# Детали пользователя: имя, Telegram ID и username
|
||||
full_name = ticket.user.full_name if ticket.user else "Unknown"
|
||||
telegram_id_display = ticket.user.telegram_id if ticket.user else "—"
|
||||
username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
|
||||
|
||||
text = (
|
||||
f"⏰ <b>Ожидание ответа на тикет превышено</b>\n\n"
|
||||
f"🆔 <b>ID:</b> <code>{ticket.id}</code>\n"
|
||||
f"👤 <b>User ID:</b> <code>{ticket.user_id}</code>\n"
|
||||
f"👤 <b>Пользователь:</b> {full_name}\n"
|
||||
f"🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n"
|
||||
f"📱 <b>Username:</b> @{username_display}\n"
|
||||
f"📝 <b>Заголовок:</b> {title or '—'}\n"
|
||||
f"⏱️ <b>Ожидает ответа:</b> {waited_minutes} мин\n"
|
||||
)
|
||||
|
||||
@@ -37,6 +37,7 @@ class Pal24Service:
|
||||
ttl_seconds: Optional[int] = None,
|
||||
custom_payload: Optional[Dict[str, Any]] = None,
|
||||
payer_email: Optional[str] = None,
|
||||
payment_method: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if not self.is_configured:
|
||||
raise Pal24APIError("Pal24 service is not configured")
|
||||
@@ -49,6 +50,8 @@ class Pal24Service:
|
||||
|
||||
if payer_email:
|
||||
extra_payload["payer_email"] = payer_email
|
||||
if payment_method:
|
||||
extra_payload["payment_method"] = payment_method
|
||||
|
||||
filtered_payload = {k: v for k, v in extra_payload.items() if v not in (None, {})}
|
||||
|
||||
|
||||
@@ -857,6 +857,7 @@ class PaymentService:
|
||||
ttl_seconds=ttl_seconds,
|
||||
custom_payload=custom_payload,
|
||||
payer_email=payer_email,
|
||||
payment_method="SBP",
|
||||
)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при создании счета: %s", error)
|
||||
@@ -873,6 +874,7 @@ class PaymentService:
|
||||
|
||||
link_url = response.get("link_url")
|
||||
link_page_url = response.get("link_page_url")
|
||||
primary_link = link_page_url or link_url
|
||||
|
||||
payment = await create_pal24_payment(
|
||||
db,
|
||||
@@ -884,8 +886,8 @@ class PaymentService:
|
||||
status=response.get("status", "NEW"),
|
||||
type_=response.get("type", "normal"),
|
||||
currency=response.get("currency", "RUB"),
|
||||
link_url=link_url,
|
||||
link_page_url=link_page_url,
|
||||
link_url=primary_link,
|
||||
link_page_url=link_page_url or link_url,
|
||||
ttl=ttl_seconds,
|
||||
metadata={
|
||||
"raw_response": response,
|
||||
@@ -896,8 +898,8 @@ class PaymentService:
|
||||
payment_info = {
|
||||
"bill_id": bill_id,
|
||||
"order_id": order_id,
|
||||
"link_url": link_url or link_page_url,
|
||||
"link_page_url": link_page_url,
|
||||
"link_url": primary_link,
|
||||
"link_page_url": link_page_url or link_url,
|
||||
"local_payment_id": payment.id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
}
|
||||
|
||||
@@ -2,15 +2,64 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.transaction import get_user_total_spent_kopeks
|
||||
from app.database.models import PromoGroup, User
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _notify_admins_about_auto_assignment(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
old_group: Optional[PromoGroup],
|
||||
new_group: PromoGroup,
|
||||
total_spent_kopeks: int,
|
||||
):
|
||||
if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False):
|
||||
return
|
||||
|
||||
bot_token = getattr(settings, "BOT_TOKEN", None)
|
||||
if not bot_token:
|
||||
logger.debug("BOT_TOKEN не настроен — пропускаем уведомление о промогруппе")
|
||||
return
|
||||
|
||||
bot = Bot(token=bot_token, default=DefaultBotProperties(parse_mode="HTML"))
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
reason = (
|
||||
f"Автоназначение за траты {settings.format_price(total_spent_kopeks)}"
|
||||
if hasattr(settings, "format_price")
|
||||
else f"Автоназначение за траты {total_spent_kopeks / 100:.2f}₽"
|
||||
)
|
||||
await notification_service.send_user_promo_group_change_notification(
|
||||
db,
|
||||
user,
|
||||
old_group,
|
||||
new_group,
|
||||
reason=reason,
|
||||
initiator=None,
|
||||
automatic=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о автоназначении промогруппы пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
await bot.session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _get_best_group_for_spending(
|
||||
db: AsyncSession,
|
||||
total_spent_kopeks: int,
|
||||
@@ -48,6 +97,14 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id)
|
||||
return None
|
||||
|
||||
old_group = None
|
||||
if user.promo_group_id:
|
||||
try:
|
||||
await db.refresh(user, attribute_names=["promo_group"])
|
||||
except Exception:
|
||||
pass
|
||||
old_group = getattr(user, "promo_group", None)
|
||||
|
||||
total_spent = await get_user_total_spent_kopeks(db, user_id)
|
||||
if total_spent <= 0:
|
||||
return None
|
||||
@@ -112,6 +169,15 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if target_group.id != previous_group_id:
|
||||
await _notify_admins_about_auto_assignment(
|
||||
db,
|
||||
user,
|
||||
old_group,
|
||||
target_group,
|
||||
total_spent,
|
||||
)
|
||||
|
||||
return target_group
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
|
||||
@@ -49,23 +49,34 @@ class PromoCodeService:
|
||||
return {"success": False, "error": "already_used_by_user"}
|
||||
|
||||
result_description = await self._apply_promocode_effects(db, user, promocode)
|
||||
|
||||
|
||||
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
await mark_user_as_had_paid_subscription(db, user)
|
||||
|
||||
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}")
|
||||
|
||||
|
||||
await create_promocode_use(db, promocode.id, user_id)
|
||||
|
||||
|
||||
promocode.current_uses += 1
|
||||
await db.commit()
|
||||
|
||||
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} активировал промокод {code}")
|
||||
|
||||
|
||||
promocode_data = {
|
||||
"code": promocode.code,
|
||||
"type": promocode.type,
|
||||
"balance_bonus_kopeks": promocode.balance_bonus_kopeks,
|
||||
"subscription_days": promocode.subscription_days,
|
||||
"max_uses": promocode.max_uses,
|
||||
"current_uses": promocode.current_uses,
|
||||
"valid_until": promocode.valid_until,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"description": result_description
|
||||
"description": result_description,
|
||||
"promocode": promocode_data,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -17,6 +17,8 @@ from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
)
|
||||
@@ -203,6 +205,10 @@ class ReportingService:
|
||||
f"• Активных сейчас: {totals['active_paid']}",
|
||||
f"• Новых за период: {period_stats['new_paid_subscriptions']}",
|
||||
"",
|
||||
"🎟️ <b>Тикеты поддержки</b>",
|
||||
f"• Активных сейчас: {totals['open_tickets']}",
|
||||
f"• Новых за период: {period_stats['new_tickets']}",
|
||||
"",
|
||||
"💰 <b>Платежи</b>",
|
||||
f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму "
|
||||
f"{self._format_amount(period_stats['subscription_payments_amount'])}",
|
||||
@@ -243,9 +249,22 @@ class ReportingService:
|
||||
|
||||
async def _collect_current_totals(self, session) -> dict:
|
||||
stats = await get_subscriptions_statistics(session)
|
||||
open_tickets_result = await session.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
Ticket.status.in_(
|
||||
[
|
||||
TicketStatus.OPEN.value,
|
||||
TicketStatus.ANSWERED.value,
|
||||
TicketStatus.PENDING.value,
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
open_tickets = int(open_tickets_result.scalar() or 0)
|
||||
return {
|
||||
"active_trials": stats.get("trial_subscriptions", 0) or 0,
|
||||
"active_paid": stats.get("paid_subscriptions", 0) or 0,
|
||||
"open_tickets": open_tickets,
|
||||
}
|
||||
|
||||
async def _collect_period_stats(
|
||||
@@ -315,6 +334,13 @@ class ReportingService:
|
||||
|
||||
total_payments_count = subscription_payments_count + deposits_count
|
||||
total_payments_amount = subscription_payments_amount + deposits_amount
|
||||
new_tickets_result = await session.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
Ticket.created_at >= start_utc,
|
||||
Ticket.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
new_tickets = int(new_tickets_result.scalar() or 0)
|
||||
|
||||
return {
|
||||
"new_trials": new_trials,
|
||||
@@ -325,6 +351,7 @@ class ReportingService:
|
||||
"deposits_amount": deposits_amount,
|
||||
"total_payments_count": total_payments_count,
|
||||
"total_payments_amount": total_payments_amount,
|
||||
"new_tickets": new_tickets,
|
||||
}
|
||||
|
||||
def _format_period_label(self, start: datetime, end: datetime) -> str:
|
||||
|
||||
@@ -87,9 +87,12 @@ class SubscriptionService:
|
||||
)
|
||||
|
||||
async def create_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
*,
|
||||
reset_traffic: bool = False,
|
||||
reset_reason: Optional[str] = None,
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
@@ -130,6 +133,14 @@ class SubscriptionService:
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
api,
|
||||
updated_user.uuid,
|
||||
user.telegram_id,
|
||||
reset_reason,
|
||||
)
|
||||
|
||||
else:
|
||||
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
|
||||
username = f"user_{user.telegram_id}"
|
||||
@@ -148,7 +159,15 @@ class SubscriptionService:
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
api,
|
||||
updated_user.uuid,
|
||||
user.telegram_id,
|
||||
reset_reason,
|
||||
)
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
@@ -172,7 +191,10 @@ class SubscriptionService:
|
||||
async def update_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
subscription: Subscription,
|
||||
*,
|
||||
reset_traffic: bool = False,
|
||||
reset_reason: Optional[str] = None,
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
@@ -210,6 +232,14 @@ class SubscriptionService:
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
api,
|
||||
user.remnawave_uuid,
|
||||
user.telegram_id,
|
||||
reset_reason,
|
||||
)
|
||||
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
await db.commit()
|
||||
@@ -219,16 +249,37 @@ class SubscriptionService:
|
||||
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
|
||||
logger.info(f"📊 Стратегия сброса трафика: {strategy_name}")
|
||||
return updated_user
|
||||
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"Ошибка RemnaWave API: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _reset_user_traffic(
|
||||
self,
|
||||
api: RemnaWaveAPI,
|
||||
user_uuid: str,
|
||||
telegram_id: int,
|
||||
reset_reason: Optional[str] = None,
|
||||
) -> None:
|
||||
if not user_uuid:
|
||||
return
|
||||
|
||||
try:
|
||||
await api.reset_user_traffic(user_uuid)
|
||||
reason_text = f" ({reset_reason})" if reset_reason else ""
|
||||
logger.info(
|
||||
f"🔄 Сброшен трафик RemnaWave для пользователя {telegram_id}{reason_text}"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"⚠️ Не удалось сбросить трафик RemnaWave для пользователя {telegram_id}: {exc}"
|
||||
)
|
||||
|
||||
async def disable_remnawave_user(self, user_uuid: str) -> bool:
|
||||
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
await api.disable_user(user_uuid)
|
||||
|
||||
@@ -4,6 +4,8 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
|
||||
|
||||
from app.database.universal_migration import ensure_default_web_api_token
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -58,48 +60,48 @@ class BotConfigurationService:
|
||||
EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"}
|
||||
|
||||
CATEGORY_TITLES: Dict[str, str] = {
|
||||
"SUPPORT": "Поддержка",
|
||||
"ADMIN_NOTIFICATIONS": "Уведомления администраторов",
|
||||
"ADMIN_REPORTS": "Автоотчеты",
|
||||
"CHANNEL": "Обязательная подписка на канал",
|
||||
"DATABASE": "База данных",
|
||||
"POSTGRES": "PostgreSQL",
|
||||
"SQLITE": "SQLite",
|
||||
"REDIS": "Redis",
|
||||
"REMNAWAVE": "Remnawave API",
|
||||
"TRIAL": "Триал подписка",
|
||||
"PAID_SUBSCRIPTION": "Платная подписка",
|
||||
"SUBSCRIPTIONS_GLOBAL": "Глобальные параметры подписок",
|
||||
"TRAFFIC": "Настройки трафика",
|
||||
"PERIODS": "Периоды подписки",
|
||||
"SUBSCRIPTION_PRICES": "Цены подписки",
|
||||
"TRAFFIC_PACKAGES": "Пакеты трафика",
|
||||
"DISCOUNTS": "Скидки промогрупп",
|
||||
"REFERRAL": "Реферальная система",
|
||||
"AUTOPAY": "Автопродление",
|
||||
"TELEGRAM": "Telegram Stars",
|
||||
"TRIBUTE": "Tribute",
|
||||
"YOOKASSA": "YooKassa",
|
||||
"CRYPTOBOT": "CryptoBot",
|
||||
"MULENPAY": "MulenPay",
|
||||
"PAL24": "PayPalych / Pal24",
|
||||
"PAYMENT": "Описания платежей",
|
||||
"INTERFACE_BRANDING": "Брендинг и логотип",
|
||||
"INTERFACE_SUBSCRIPTION": "Блок подписки",
|
||||
"CONNECT_BUTTON": "Кнопка «Подключиться»",
|
||||
"HAPP": "Happ CryptoLink",
|
||||
"SKIP": "Пропуски onboarding",
|
||||
"MONITORING": "Мониторинг",
|
||||
"NOTIFICATIONS": "Уведомления",
|
||||
"SERVER": "Статус серверов",
|
||||
"MAINTENANCE": "Технические работы",
|
||||
"LOCALIZATION": "Локализация",
|
||||
"ADDITIONAL": "Дополнительные настройки",
|
||||
"BACKUP": "Бекапы",
|
||||
"VERSION": "Проверка обновлений",
|
||||
"LOG": "Логирование",
|
||||
"WEBHOOK": "Вебхуки",
|
||||
"DEBUG": "Режим разработки",
|
||||
"SUPPORT": "💬 Ссылка на поддержку",
|
||||
"LOCALIZATION": "🌍 Языковые настройки",
|
||||
"MAINTENANCE": "🛠️ Режим обслуживания",
|
||||
"CHANNEL": "📣 Обязательная подписка",
|
||||
"ADMIN_NOTIFICATIONS": "🔔 Уведомления админам",
|
||||
"ADMIN_REPORTS": "📊 Автоматические отчеты",
|
||||
"TRIAL": "🎁 Триальная подписка",
|
||||
"PAID_SUBSCRIPTION": "💰 Платные подписки",
|
||||
"PERIODS": "📅 Периоды подписки",
|
||||
"SUBSCRIPTION_PRICES": "💵 Цены за периоды",
|
||||
"TRAFFIC": "🚦 Настройки трафика",
|
||||
"TRAFFIC_PACKAGES": "📦 Пакеты трафика",
|
||||
"DISCOUNTS": "🎯 Промо и скидки",
|
||||
"PAYMENT": "⚙️ Общие настройки платежей",
|
||||
"TELEGRAM": "⭐ Telegram Stars",
|
||||
"CRYPTOBOT": "💎 CryptoBot",
|
||||
"YOOKASSA": "💸 YooKassa",
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
"MULENPAY": "💰 MulenPay",
|
||||
"PAL24": "🏦 Pal24/PayPalych",
|
||||
"REMNAWAVE": "🔗 RemnaWave API",
|
||||
"REFERRAL": "🤝 Реферальная система",
|
||||
"AUTOPAY": "🔄 Автопродление",
|
||||
"INTERFACE_BRANDING": "🖼️ Визуальные настройки",
|
||||
"INTERFACE_SUBSCRIPTION": "🔗 Скрыть ссылку подписки",
|
||||
"CONNECT_BUTTON": "🚀 Кнопка «Подключиться»",
|
||||
"HAPP": "🅷 Happ настройки",
|
||||
"SKIP": "⚡ Быстрый старт",
|
||||
"ADDITIONAL": "📱 Приложения и DeepLinks",
|
||||
"DATABASE": "🗄️ Режим БД",
|
||||
"POSTGRES": "🐘 PostgreSQL",
|
||||
"SQLITE": "💾 SQLite",
|
||||
"REDIS": "🧠 Redis",
|
||||
"MONITORING": "📈 Общий мониторинг",
|
||||
"NOTIFICATIONS": "🔔 Уведомления пользователям",
|
||||
"SERVER": "🖥️ Статус серверов",
|
||||
"BACKUP": "💾 Система бэкапов",
|
||||
"VERSION": "🔄 Обновления",
|
||||
"LOG": "📝 Логирование",
|
||||
"WEBHOOK": "🌐 Webhook",
|
||||
"WEB_API": "🌐 Web API",
|
||||
"DEBUG": "🔧 Режим разработки",
|
||||
}
|
||||
|
||||
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
|
||||
@@ -110,7 +112,8 @@ class BotConfigurationService:
|
||||
"DEFAULT_TRAFFIC_LIMIT_GB": "PAID_SUBSCRIPTION",
|
||||
"MAX_DEVICES_LIMIT": "PAID_SUBSCRIPTION",
|
||||
"PRICE_PER_DEVICE": "PAID_SUBSCRIPTION",
|
||||
"DEFAULT_TRAFFIC_RESET_STRATEGY": "SUBSCRIPTIONS_GLOBAL",
|
||||
"DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC",
|
||||
"RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC",
|
||||
"TRAFFIC_SELECTION_MODE": "TRAFFIC",
|
||||
"FIXED_TRAFFIC_LIMIT_GB": "TRAFFIC",
|
||||
"AVAILABLE_SUBSCRIPTION_PERIODS": "PERIODS",
|
||||
@@ -158,6 +161,7 @@ class BotConfigurationService:
|
||||
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
|
||||
"INACTIVE_USER_DELETE_MONTHS": "MONITORING",
|
||||
"LANGUAGE_SELECTION_ENABLED": "LOCALIZATION",
|
||||
}
|
||||
|
||||
CATEGORY_PREFIX_OVERRIDES: Dict[str, str] = {
|
||||
@@ -193,6 +197,7 @@ class BotConfigurationService:
|
||||
"BACKUP_": "BACKUP",
|
||||
"WEBHOOK_": "WEBHOOK",
|
||||
"LOG_": "LOG",
|
||||
"WEB_API_": "WEB_API",
|
||||
"DEBUG": "DEBUG",
|
||||
}
|
||||
|
||||
@@ -541,6 +546,8 @@ class BotConfigurationService:
|
||||
cls._overrides_raw[key] = raw_value
|
||||
cls._apply_to_settings(key, parsed_value)
|
||||
|
||||
await cls._sync_default_web_api_token()
|
||||
|
||||
@classmethod
|
||||
async def reload(cls) -> None:
|
||||
cls._overrides_raw.clear()
|
||||
@@ -642,6 +649,9 @@ class BotConfigurationService:
|
||||
cls._overrides_raw[key] = raw_value
|
||||
cls._apply_to_settings(key, value)
|
||||
|
||||
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
|
||||
await cls._sync_default_web_api_token()
|
||||
|
||||
@classmethod
|
||||
async def reset_value(cls, db: AsyncSession, key: str) -> None:
|
||||
await delete_system_setting(db, key)
|
||||
@@ -649,6 +659,9 @@ class BotConfigurationService:
|
||||
original = cls.get_original_value(key)
|
||||
cls._apply_to_settings(key, original)
|
||||
|
||||
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
|
||||
await cls._sync_default_web_api_token()
|
||||
|
||||
@classmethod
|
||||
def _apply_to_settings(cls, key: str, value: Any) -> None:
|
||||
try:
|
||||
@@ -656,6 +669,18 @@ class BotConfigurationService:
|
||||
except Exception as error:
|
||||
logger.error("Не удалось применить значение %s=%s: %s", key, value, error)
|
||||
|
||||
@staticmethod
|
||||
async def _sync_default_web_api_token() -> None:
|
||||
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
|
||||
if not default_token:
|
||||
return
|
||||
|
||||
success = await ensure_default_web_api_token()
|
||||
if not success:
|
||||
logger.warning(
|
||||
"Не удалось синхронизировать бутстрап токен веб-API после обновления настроек",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_setting_summary(cls, key: str) -> Dict[str, Any]:
|
||||
definition = cls.get_definition(key)
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.database.models import (
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment,
|
||||
AdvertisingCampaign
|
||||
AdvertisingCampaign, PaymentMethod
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
@@ -203,7 +203,14 @@ class UserService:
|
||||
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}₽")
|
||||
success = True
|
||||
else:
|
||||
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
abs(amount_kopeks),
|
||||
description,
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.MANUAL,
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}₽")
|
||||
|
||||
@@ -231,15 +238,17 @@ class UserService:
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int
|
||||
) -> Tuple[bool, Optional[User], Optional[PromoGroup]]:
|
||||
) -> Tuple[bool, Optional[User], Optional[PromoGroup], Optional[PromoGroup]]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False, None, None
|
||||
return False, None, None, None
|
||||
|
||||
old_group = user.promo_group
|
||||
|
||||
promo_group = await get_promo_group_by_id(db, promo_group_id)
|
||||
if not promo_group:
|
||||
return False, None, None
|
||||
return False, None, None, old_group
|
||||
|
||||
user.promo_group_id = promo_group.id
|
||||
user.promo_group = promo_group
|
||||
@@ -254,12 +263,12 @@ class UserService:
|
||||
promo_group.name,
|
||||
)
|
||||
|
||||
return True, user, promo_group
|
||||
return True, user, promo_group, old_group
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}")
|
||||
return False, None, None
|
||||
return False, None, None, None
|
||||
|
||||
async def block_user(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud import web_api_token as crud
|
||||
from app.database.models import WebApiToken
|
||||
from app.database.universal_migration import ensure_default_web_api_token
|
||||
from app.utils.security import generate_api_token, hash_api_token
|
||||
|
||||
|
||||
class WebApiTokenService:
|
||||
"""Сервис для управления токенами административного веб-API."""
|
||||
|
||||
def __init__(self):
|
||||
self.algorithm = settings.WEB_API_TOKEN_HASH_ALGORITHM or "sha256"
|
||||
|
||||
def hash_token(self, token: str) -> str:
|
||||
return hash_api_token(token, self.algorithm) # type: ignore[arg-type]
|
||||
|
||||
async def authenticate(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
token_value: str,
|
||||
*,
|
||||
remote_ip: Optional[str] = None,
|
||||
) -> Optional[WebApiToken]:
|
||||
normalized_value = token_value.strip()
|
||||
if not normalized_value:
|
||||
return None
|
||||
|
||||
async def _load_token(value: str) -> Optional[WebApiToken]:
|
||||
token_hash = self.hash_token(value)
|
||||
return await crud.get_token_by_hash(db, token_hash)
|
||||
|
||||
token = await _load_token(normalized_value)
|
||||
|
||||
if not token:
|
||||
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
|
||||
if default_token and secrets.compare_digest(default_token, normalized_value):
|
||||
await ensure_default_web_api_token()
|
||||
token = await _load_token(default_token)
|
||||
|
||||
if not token or not token.is_active:
|
||||
return None
|
||||
|
||||
if token.expires_at and token.expires_at < datetime.utcnow():
|
||||
return None
|
||||
|
||||
token.last_used_at = datetime.utcnow()
|
||||
if remote_ip:
|
||||
token.last_used_ip = remote_ip
|
||||
await db.flush()
|
||||
return token
|
||||
|
||||
async def create_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
description: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
created_by: Optional[str] = None,
|
||||
token_value: Optional[str] = None,
|
||||
) -> Tuple[str, WebApiToken]:
|
||||
plain_token = token_value or generate_api_token()
|
||||
token_hash = self.hash_token(plain_token)
|
||||
|
||||
token = await crud.create_token(
|
||||
db,
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=plain_token[:12],
|
||||
description=description,
|
||||
expires_at=expires_at,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
return plain_token, token
|
||||
|
||||
async def revoke_token(self, db: AsyncSession, token: WebApiToken) -> WebApiToken:
|
||||
token.is_active = False
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
async def activate_token(self, db: AsyncSession, token: WebApiToken) -> WebApiToken:
|
||||
token.is_active = True
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
web_api_token_service = WebApiTokenService()
|
||||
@@ -1,6 +1,7 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
class RegistrationStates(StatesGroup):
|
||||
waiting_for_language = State()
|
||||
waiting_for_rules_accept = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
|
||||
@@ -8,22 +8,28 @@ from app.states import RegistrationStates
|
||||
|
||||
def is_registration_process(event: TelegramObject, current_state: Optional[str]) -> bool:
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_language.state,
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
registration_callbacks = [
|
||||
"rules_accept",
|
||||
"rules_decline",
|
||||
"rules_accept",
|
||||
"rules_decline",
|
||||
"referral_skip"
|
||||
]
|
||||
|
||||
language_select_prefix = "language_select:"
|
||||
|
||||
if current_state in registration_states:
|
||||
return True
|
||||
|
||||
if (isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and event.data in registration_callbacks):
|
||||
if (isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (
|
||||
event.data in registration_callbacks
|
||||
or event.data.startswith(language_select_prefix)
|
||||
)):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
@@ -27,6 +27,7 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
pass
|
||||
if LOGO_PATH.exists():
|
||||
try:
|
||||
# Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
except Exception:
|
||||
# Фоллбек, если Telegram ругается на caption: отправим как текст
|
||||
|
||||
@@ -48,8 +48,8 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
if settings.is_pal24_enabled():
|
||||
methods.append({
|
||||
"id": "pal24",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"name": "СБП",
|
||||
"icon": "🏦",
|
||||
"description": "через PayPalych",
|
||||
"callback": "topup_pal24"
|
||||
})
|
||||
|
||||
@@ -50,6 +50,13 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
|
||||
discount_value = amount * clamped_percent // 100
|
||||
discounted_amount = amount - discount_value
|
||||
|
||||
# Round the discounted price up to the nearest full ruble (100 kopeks)
|
||||
# to avoid undercharging users because of fractional kopeks.
|
||||
if discount_value >= 100 and discounted_amount % 100:
|
||||
discounted_amount += 100 - (discounted_amount % 100)
|
||||
discounted_amount = min(discounted_amount, amount)
|
||||
discount_value = amount - discounted_amount
|
||||
|
||||
logger.debug(
|
||||
"Применена скидка %s%%: %s → %s (скидка %s)",
|
||||
clamped_percent,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Утилиты безопасности и генерации ключей."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from typing import Literal
|
||||
|
||||
|
||||
HashAlgorithm = Literal["sha256", "sha384", "sha512"]
|
||||
|
||||
|
||||
def hash_api_token(token: str, algorithm: HashAlgorithm = "sha256") -> str:
|
||||
"""Возвращает хеш токена в формате hex."""
|
||||
normalized = (algorithm or "sha256").lower()
|
||||
if normalized not in {"sha256", "sha384", "sha512"}:
|
||||
raise ValueError(f"Unsupported hash algorithm: {algorithm}")
|
||||
|
||||
digest = getattr(hashlib, normalized)
|
||||
return digest(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_api_token(length: int = 48) -> str:
|
||||
"""Генерирует криптографически стойкий токен."""
|
||||
length = max(24, min(length, 128))
|
||||
return secrets.token_urlsafe(length)
|
||||
|
||||
|
||||
__all__ = ["hash_api_token", "generate_api_token", "HashAlgorithm"]
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, urlparse, urlunparse
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.models import Subscription, User
|
||||
@@ -141,3 +141,18 @@ def get_happ_cryptolink_redirect_link(subscription_link: Optional[str]) -> Optio
|
||||
return f"{template}{encoded_link}"
|
||||
|
||||
return f"{template}{encoded_link}"
|
||||
|
||||
|
||||
def convert_subscription_link_to_happ_scheme(subscription_link: Optional[str]) -> Optional[str]:
|
||||
if not subscription_link:
|
||||
return None
|
||||
|
||||
parsed_link = urlparse(subscription_link)
|
||||
|
||||
if parsed_link.scheme.lower() == "happ":
|
||||
return subscription_link
|
||||
|
||||
if not parsed_link.scheme:
|
||||
return subscription_link
|
||||
|
||||
return urlunparse(parsed_link._replace(scheme="happ"))
|
||||
|
||||
@@ -143,6 +143,23 @@ def sanitize_html(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def sanitize_telegram_name(name: Optional[str]) -> Optional[str]:
|
||||
"""Санитизация Telegram-имени для безопасной вставки в HTML и хранения.
|
||||
Заменяет угловые скобки и амперсанд на безопасные визуальные аналоги.
|
||||
"""
|
||||
if not name:
|
||||
return name
|
||||
try:
|
||||
return (
|
||||
name.replace('<', '‹')
|
||||
.replace('>', '›')
|
||||
.replace('&', '&')
|
||||
.strip()
|
||||
)
|
||||
except Exception:
|
||||
return name
|
||||
|
||||
|
||||
def validate_device_count(count: Union[str, int]) -> Optional[int]:
|
||||
try:
|
||||
count_int = int(count)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Пакет административного веб-API."""
|
||||
from .app import create_web_api_app
|
||||
from .server import WebAPIServer
|
||||
|
||||
__all__ = ["create_web_api_app", "WebAPIServer"]
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from .middleware import RequestLoggingMiddleware
|
||||
from .routes import (
|
||||
broadcasts,
|
||||
backups,
|
||||
campaigns,
|
||||
config,
|
||||
health,
|
||||
promocodes,
|
||||
promo_groups,
|
||||
remnawave,
|
||||
stats,
|
||||
subscriptions,
|
||||
tickets,
|
||||
tokens,
|
||||
transactions,
|
||||
users,
|
||||
)
|
||||
|
||||
|
||||
OPENAPI_TAGS = [
|
||||
{
|
||||
"name": "health",
|
||||
"description": "Мониторинг состояния административного API и связанных сервисов.",
|
||||
},
|
||||
{
|
||||
"name": "stats",
|
||||
"description": "Сводные показатели по пользователям, подпискам и платежам.",
|
||||
},
|
||||
{
|
||||
"name": "settings",
|
||||
"description": "Получение и изменение конфигурации бота из административной панели.",
|
||||
},
|
||||
{
|
||||
"name": "users",
|
||||
"description": "Управление пользователями, балансом и статусами подписок.",
|
||||
},
|
||||
{
|
||||
"name": "subscriptions",
|
||||
"description": "Создание, продление и настройка подписок бота.",
|
||||
},
|
||||
{
|
||||
"name": "support",
|
||||
"description": "Работа с тикетами поддержки, приоритетами и ограничениями на ответы.",
|
||||
},
|
||||
{
|
||||
"name": "transactions",
|
||||
"description": "История финансовых операций и пополнений баланса.",
|
||||
},
|
||||
{
|
||||
"name": "promo-groups",
|
||||
"description": "Создание и управление промо-группами и их участниками.",
|
||||
},
|
||||
{
|
||||
"name": "auth",
|
||||
"description": "Управление токенами доступа к административному API.",
|
||||
},
|
||||
{
|
||||
"name": "remnawave",
|
||||
"description": (
|
||||
"Интеграция с RemnaWave: статус панели, управление нодами, сквадами и синхронизацией "
|
||||
"данных между ботом и панелью."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def create_web_api_app() -> FastAPI:
|
||||
docs_config = settings.get_web_api_docs_config()
|
||||
|
||||
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"),
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
openapi_tags=OPENAPI_TAGS,
|
||||
swagger_ui_parameters={"persistAuthorization": True},
|
||||
)
|
||||
|
||||
allowed_origins = settings.get_web_api_allowed_origins()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"] if allowed_origins == ["*"] else allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
if settings.WEB_API_REQUEST_LOGGING:
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(stats.router, prefix="/stats", tags=["stats"])
|
||||
app.include_router(config.router, prefix="/settings", tags=["settings"])
|
||||
app.include_router(users.router, prefix="/users", tags=["users"])
|
||||
app.include_router(subscriptions.router, prefix="/subscriptions", tags=["subscriptions"])
|
||||
app.include_router(tickets.router, prefix="/tickets", tags=["support"])
|
||||
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
|
||||
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
|
||||
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
|
||||
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
|
||||
app.include_router(backups.router, prefix="/backups", tags=["backups"])
|
||||
app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"])
|
||||
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
|
||||
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
|
||||
|
||||
return app
|
||||
@@ -0,0 +1 @@
|
||||
"""Background utilities for Web API."""
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.services.backup_service import backup_service
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BackupTaskState:
|
||||
task_id: str
|
||||
status: str = "queued"
|
||||
message: Optional[str] = None
|
||||
file_path: Optional[str] = None
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class BackupTaskManager:
|
||||
def __init__(self) -> None:
|
||||
self._tasks: dict[str, BackupTaskState] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def enqueue(self, *, created_by: Optional[int]) -> BackupTaskState:
|
||||
task_id = uuid.uuid4().hex
|
||||
state = BackupTaskState(task_id=task_id, created_by=created_by)
|
||||
|
||||
async with self._lock:
|
||||
self._tasks[task_id] = state
|
||||
|
||||
asyncio.create_task(self._run_task(state))
|
||||
return state
|
||||
|
||||
async def _run_task(self, state: BackupTaskState) -> None:
|
||||
state.status = "running"
|
||||
state.updated_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
success, message, file_path = await backup_service.create_backup(
|
||||
created_by=state.created_by
|
||||
)
|
||||
state.message = message
|
||||
state.file_path = file_path
|
||||
state.status = "completed" if success else "failed"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
state.status = "failed"
|
||||
state.message = f"Unexpected error: {exc}"
|
||||
finally:
|
||||
state.updated_at = datetime.utcnow()
|
||||
|
||||
async def get(self, task_id: str) -> Optional[BackupTaskState]:
|
||||
async with self._lock:
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
async def list(self, *, active_only: bool = False) -> list[BackupTaskState]:
|
||||
async with self._lock:
|
||||
states = list(self._tasks.values())
|
||||
|
||||
if active_only:
|
||||
return [
|
||||
state
|
||||
for state in states
|
||||
if state.status in {"queued", "running"}
|
||||
]
|
||||
|
||||
return states
|
||||
|
||||
|
||||
backup_task_manager = BackupTaskManager()
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Security, status
|
||||
from fastapi.security import APIKeyHeader
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import WebApiToken
|
||||
from app.services.web_api_token_service import web_api_token_service
|
||||
|
||||
|
||||
api_key_header_scheme = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def require_api_token(
|
||||
request: Request,
|
||||
api_key_header: str | None = Security(api_key_header_scheme),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> WebApiToken:
|
||||
api_key = api_key_header
|
||||
|
||||
if not api_key:
|
||||
authorization = request.headers.get("Authorization")
|
||||
if authorization:
|
||||
scheme, _, credentials = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer" and credentials:
|
||||
api_key = credentials
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing API key",
|
||||
)
|
||||
|
||||
token = await web_api_token_service.authenticate(
|
||||
db,
|
||||
api_key,
|
||||
remote_ip=request.client.host if request.client else None,
|
||||
)
|
||||
|
||||
if not token:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired API key",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return token
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from time import monotonic
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
logger = logging.getLogger("web_api")
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""Логирование входящих запросов в административный API."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
start = monotonic()
|
||||
response: Response | None = None
|
||||
try:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
finally:
|
||||
duration_ms = (monotonic() - start) * 1000
|
||||
status = response.status_code if response else "error"
|
||||
logger.info(
|
||||
"%s %s -> %s (%.2f ms)",
|
||||
request.method,
|
||||
request.url.path,
|
||||
status,
|
||||
duration_ms,
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
from . import (
|
||||
config,
|
||||
health,
|
||||
promo_groups,
|
||||
remnawave,
|
||||
stats,
|
||||
subscriptions,
|
||||
tickets,
|
||||
tokens,
|
||||
transactions,
|
||||
users,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"config",
|
||||
"health",
|
||||
"promo_groups",
|
||||
"remnawave",
|
||||
"stats",
|
||||
"subscriptions",
|
||||
"tickets",
|
||||
"tokens",
|
||||
"transactions",
|
||||
"users",
|
||||
]
|
||||
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Security, status
|
||||
|
||||
from app.services.backup_service import backup_service
|
||||
|
||||
from ..background.backup_tasks import backup_task_manager
|
||||
from ..dependencies import require_api_token
|
||||
from ..schemas.backups import (
|
||||
BackupCreateResponse,
|
||||
BackupInfo,
|
||||
BackupListResponse,
|
||||
BackupStatusResponse,
|
||||
BackupTaskInfo,
|
||||
BackupTaskListResponse,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _parse_datetime(value: Optional[str]) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
if value.endswith("Z"):
|
||||
value = value.replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _to_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_backup(raw: dict) -> BackupInfo:
|
||||
timestamp = _parse_datetime(raw.get("timestamp"))
|
||||
tables_count = _to_int(raw.get("tables_count"))
|
||||
total_records = _to_int(raw.get("total_records"))
|
||||
file_size_bytes = _to_int(raw.get("file_size_bytes")) or 0
|
||||
file_size_mb = raw.get("file_size_mb")
|
||||
try:
|
||||
file_size_mb = float(file_size_mb)
|
||||
except (TypeError, ValueError):
|
||||
file_size_mb = round(file_size_bytes / 1024 / 1024, 2)
|
||||
|
||||
created_by = _to_int(raw.get("created_by"))
|
||||
|
||||
return BackupInfo(
|
||||
filename=str(raw.get("filename")),
|
||||
filepath=str(raw.get("filepath")),
|
||||
timestamp=timestamp,
|
||||
tables_count=tables_count,
|
||||
total_records=total_records,
|
||||
compressed=bool(raw.get("compressed", False)),
|
||||
file_size_bytes=file_size_bytes,
|
||||
file_size_mb=float(file_size_mb),
|
||||
created_by=created_by,
|
||||
database_type=raw.get("database_type"),
|
||||
version=raw.get("version"),
|
||||
error=raw.get("error"),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=BackupCreateResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
summary="Запустить создание резервной копии",
|
||||
)
|
||||
async def create_backup_endpoint(
|
||||
token: Any = Security(require_api_token),
|
||||
) -> BackupCreateResponse:
|
||||
created_by = getattr(token, "id", None)
|
||||
state = await backup_task_manager.enqueue(created_by=created_by)
|
||||
return BackupCreateResponse(task_id=state.task_id, status=state.status)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=BackupListResponse,
|
||||
summary="Список резервных копий",
|
||||
)
|
||||
async def list_backups(
|
||||
_: Any = Security(require_api_token),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> BackupListResponse:
|
||||
backups = await backup_service.get_backup_list()
|
||||
total = len(backups)
|
||||
|
||||
slice_backups = backups[offset : offset + limit]
|
||||
items = [_serialize_backup(raw) for raw in slice_backups]
|
||||
|
||||
return BackupListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/status/{task_id}",
|
||||
response_model=BackupStatusResponse,
|
||||
summary="Статус создания резервной копии",
|
||||
)
|
||||
async def get_backup_status(
|
||||
task_id: str,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> BackupStatusResponse:
|
||||
state = await backup_task_manager.get(task_id)
|
||||
if not state:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Task not found")
|
||||
|
||||
return BackupStatusResponse(
|
||||
task_id=state.task_id,
|
||||
status=state.status,
|
||||
message=state.message,
|
||||
file_path=state.file_path,
|
||||
created_by=state.created_by,
|
||||
created_at=state.created_at,
|
||||
updated_at=state.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tasks",
|
||||
response_model=BackupTaskListResponse,
|
||||
summary="Список фоновых задач бекапов",
|
||||
)
|
||||
async def list_backup_tasks(
|
||||
_: Any = Security(require_api_token),
|
||||
active_only: bool = Query(False, description="Вернуть только активные задачи"),
|
||||
) -> BackupTaskListResponse:
|
||||
states = await backup_task_manager.list(active_only=active_only)
|
||||
|
||||
items = [
|
||||
BackupTaskInfo(
|
||||
task_id=state.task_id,
|
||||
status=state.status,
|
||||
message=state.message,
|
||||
file_path=state.file_path,
|
||||
created_by=state.created_by,
|
||||
created_at=state.created_at,
|
||||
updated_at=state.updated_at,
|
||||
)
|
||||
for state in states
|
||||
]
|
||||
|
||||
return BackupTaskListResponse(items=items, total=len(items))
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import BroadcastHistory
|
||||
from app.services.broadcast_service import (
|
||||
BroadcastConfig,
|
||||
BroadcastMediaConfig,
|
||||
broadcast_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.broadcasts import (
|
||||
BroadcastCreateRequest,
|
||||
BroadcastListResponse,
|
||||
BroadcastResponse,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
return BroadcastResponse(
|
||||
id=broadcast.id,
|
||||
target_type=broadcast.target_type,
|
||||
message_text=broadcast.message_text,
|
||||
has_media=broadcast.has_media,
|
||||
media_type=broadcast.media_type,
|
||||
media_file_id=broadcast.media_file_id,
|
||||
media_caption=broadcast.media_caption,
|
||||
total_count=broadcast.total_count,
|
||||
sent_count=broadcast.sent_count,
|
||||
failed_count=broadcast.failed_count,
|
||||
status=broadcast.status,
|
||||
admin_id=broadcast.admin_id,
|
||||
admin_name=broadcast.admin_name,
|
||||
created_at=broadcast.created_at,
|
||||
completed_at=broadcast.completed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_broadcast(
|
||||
payload: BroadcastCreateRequest,
|
||||
token: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> BroadcastResponse:
|
||||
message_text = payload.message_text.strip()
|
||||
if not message_text:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Message text must not be empty")
|
||||
|
||||
media_payload = payload.media
|
||||
|
||||
broadcast = BroadcastHistory(
|
||||
target_type=payload.target,
|
||||
message_text=message_text,
|
||||
has_media=media_payload is not None,
|
||||
media_type=media_payload.type if media_payload else None,
|
||||
media_file_id=media_payload.file_id if media_payload else None,
|
||||
media_caption=media_payload.caption if media_payload else None,
|
||||
total_count=0,
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
status="queued",
|
||||
admin_id=None,
|
||||
admin_name=getattr(token, "name", None) or getattr(token, "created_by", None),
|
||||
)
|
||||
db.add(broadcast)
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
media_config = None
|
||||
if media_payload:
|
||||
media_config = BroadcastMediaConfig(
|
||||
type=media_payload.type,
|
||||
file_id=media_payload.file_id,
|
||||
caption=media_payload.caption or message_text,
|
||||
)
|
||||
|
||||
config = BroadcastConfig(
|
||||
target=payload.target,
|
||||
message_text=message_text,
|
||||
selected_buttons=payload.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=getattr(token, "name", None) or getattr(token, "created_by", None),
|
||||
)
|
||||
|
||||
await broadcast_service.start_broadcast(broadcast.id, config)
|
||||
await db.refresh(broadcast)
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
|
||||
@router.get("", response_model=BroadcastListResponse)
|
||||
async def list_broadcasts(
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> BroadcastListResponse:
|
||||
total = await db.scalar(select(func.count(BroadcastHistory.id))) or 0
|
||||
|
||||
result = await db.execute(
|
||||
select(BroadcastHistory)
|
||||
.order_by(BroadcastHistory.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
broadcasts = result.scalars().all()
|
||||
|
||||
return BroadcastListResponse(
|
||||
items=[_serialize_broadcast(item) for item in broadcasts],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{broadcast_id}/stop", response_model=BroadcastResponse)
|
||||
async def stop_broadcast(
|
||||
broadcast_id: int,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> BroadcastResponse:
|
||||
broadcast = await db.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Broadcast not found")
|
||||
|
||||
if broadcast.status not in {"queued", "in_progress", "cancelling"}:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Broadcast is not running")
|
||||
|
||||
is_running = await broadcast_service.request_stop(broadcast_id)
|
||||
|
||||
if is_running:
|
||||
broadcast.status = "cancelling"
|
||||
else:
|
||||
broadcast.status = "cancelled"
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.campaign import (
|
||||
create_campaign,
|
||||
delete_campaign,
|
||||
get_campaign_by_id,
|
||||
get_campaigns_count,
|
||||
get_campaigns_list,
|
||||
update_campaign,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.campaigns import (
|
||||
CampaignCreateRequest,
|
||||
CampaignListResponse,
|
||||
CampaignResponse,
|
||||
CampaignUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_campaign(campaign) -> CampaignResponse:
|
||||
registrations_attr = None
|
||||
if isinstance(getattr(campaign, "__dict__", None), dict):
|
||||
registrations_attr = campaign.__dict__.get("registrations")
|
||||
registrations = registrations_attr or []
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
return CampaignResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
start_parameter=campaign.start_parameter,
|
||||
bonus_type=campaign.bonus_type,
|
||||
balance_bonus_kopeks=campaign.balance_bonus_kopeks or 0,
|
||||
balance_bonus_rubles=round((campaign.balance_bonus_kopeks or 0) / 100, 2),
|
||||
subscription_duration_days=campaign.subscription_duration_days,
|
||||
subscription_traffic_gb=campaign.subscription_traffic_gb,
|
||||
subscription_device_limit=campaign.subscription_device_limit,
|
||||
subscription_squads=squads,
|
||||
is_active=campaign.is_active,
|
||||
created_by=campaign.created_by,
|
||||
created_at=campaign.created_at,
|
||||
updated_at=campaign.updated_at,
|
||||
registrations_count=len(registrations),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=CampaignResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Создать рекламную кампанию",
|
||||
)
|
||||
async def create_campaign_endpoint(
|
||||
payload: CampaignCreateRequest,
|
||||
token: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> CampaignResponse:
|
||||
created_by = getattr(token, "id", None)
|
||||
|
||||
try:
|
||||
campaign = await create_campaign(
|
||||
db,
|
||||
name=payload.name,
|
||||
start_parameter=payload.start_parameter,
|
||||
bonus_type=payload.bonus_type,
|
||||
created_by=created_by,
|
||||
balance_bonus_kopeks=payload.balance_bonus_kopeks,
|
||||
subscription_duration_days=payload.subscription_duration_days,
|
||||
subscription_traffic_gb=payload.subscription_traffic_gb,
|
||||
subscription_device_limit=payload.subscription_device_limit,
|
||||
subscription_squads=payload.subscription_squads,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
except IntegrityError as exc: # duplicate start_parameter
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Campaign with this start_parameter already exists",
|
||||
) from exc
|
||||
|
||||
return _serialize_campaign(campaign)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=CampaignListResponse,
|
||||
summary="Список рекламных кампаний",
|
||||
)
|
||||
async def list_campaigns(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
include_inactive: bool = Query(True, description="Включать неактивные кампании"),
|
||||
) -> CampaignListResponse:
|
||||
total = await get_campaigns_count(db, is_active=None if include_inactive else True)
|
||||
campaigns = await get_campaigns_list(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
|
||||
return CampaignListResponse(
|
||||
items=[_serialize_campaign(campaign) for campaign in campaigns],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{campaign_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Удалить рекламную кампанию",
|
||||
)
|
||||
async def delete_campaign_endpoint(
|
||||
campaign_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Campaign not found")
|
||||
|
||||
await delete_campaign(db, campaign)
|
||||
return None
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{campaign_id}",
|
||||
response_model=CampaignResponse,
|
||||
summary="Обновить рекламную кампанию",
|
||||
)
|
||||
async def update_campaign_endpoint(
|
||||
campaign_id: int,
|
||||
payload: CampaignUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> CampaignResponse:
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Campaign not found")
|
||||
|
||||
update_fields = payload.dict(exclude_unset=True)
|
||||
if not update_fields:
|
||||
return _serialize_campaign(campaign)
|
||||
|
||||
try:
|
||||
campaign = await update_campaign(db, campaign, **update_fields)
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
if "start_parameter" in str(exc.orig):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Campaign with this start_parameter already exists",
|
||||
) from exc
|
||||
raise
|
||||
|
||||
return _serialize_campaign(campaign)
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.config import (
|
||||
SettingCategoryRef,
|
||||
SettingCategorySummary,
|
||||
SettingChoice,
|
||||
SettingDefinition,
|
||||
SettingUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _coerce_value(key: str, value: Any) -> Any:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
|
||||
if value is None:
|
||||
if definition.is_optional:
|
||||
return None
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Value is required")
|
||||
|
||||
python_type = definition.python_type
|
||||
|
||||
try:
|
||||
if python_type is bool:
|
||||
if isinstance(value, bool):
|
||||
normalized = value
|
||||
elif isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"true", "1", "yes", "on", "да"}:
|
||||
normalized = True
|
||||
elif lowered in {"false", "0", "no", "off", "нет"}:
|
||||
normalized = False
|
||||
else:
|
||||
raise ValueError("invalid bool")
|
||||
else:
|
||||
raise ValueError("invalid bool")
|
||||
|
||||
elif python_type is int:
|
||||
normalized = int(value)
|
||||
elif python_type is float:
|
||||
normalized = float(value)
|
||||
else:
|
||||
normalized = str(value)
|
||||
except ValueError:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid value type") from None
|
||||
|
||||
choices = bot_configuration_service.get_choice_options(key)
|
||||
if choices:
|
||||
allowed_values = {option.value for option in choices}
|
||||
if normalized not in allowed_values:
|
||||
readable = ", ".join(bot_configuration_service.format_value(opt.value) for opt in choices)
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Value must be one of: {readable}",
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _serialize_definition(definition, include_choices: bool = True) -> SettingDefinition:
|
||||
current = bot_configuration_service.get_current_value(definition.key)
|
||||
original = bot_configuration_service.get_original_value(definition.key)
|
||||
has_override = bot_configuration_service.has_override(definition.key)
|
||||
|
||||
choices: list[SettingChoice] = []
|
||||
if include_choices:
|
||||
choices = [
|
||||
SettingChoice(
|
||||
value=option.value,
|
||||
label=option.label,
|
||||
description=option.description,
|
||||
)
|
||||
for option in bot_configuration_service.get_choice_options(definition.key)
|
||||
]
|
||||
|
||||
return SettingDefinition(
|
||||
key=definition.key,
|
||||
name=definition.display_name,
|
||||
category=SettingCategoryRef(
|
||||
key=definition.category_key,
|
||||
label=definition.category_label,
|
||||
),
|
||||
type=definition.type_label,
|
||||
is_optional=definition.is_optional,
|
||||
current=current,
|
||||
original=original,
|
||||
has_override=has_override,
|
||||
choices=choices,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[SettingCategorySummary])
|
||||
async def list_categories(
|
||||
_: object = Security(require_api_token),
|
||||
) -> list[SettingCategorySummary]:
|
||||
categories = bot_configuration_service.get_categories()
|
||||
return [
|
||||
SettingCategorySummary(key=key, label=label, items=count)
|
||||
for key, label, count in categories
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=list[SettingDefinition])
|
||||
async def list_settings(
|
||||
_: object = Security(require_api_token),
|
||||
category: Optional[str] = Query(default=None, alias="category_key"),
|
||||
) -> list[SettingDefinition]:
|
||||
items: list[SettingDefinition] = []
|
||||
if category:
|
||||
definitions = bot_configuration_service.get_settings_for_category(category)
|
||||
items.extend(_serialize_definition(defn) for defn in definitions)
|
||||
return items
|
||||
|
||||
for category_key, _, _ in bot_configuration_service.get_categories():
|
||||
definitions = bot_configuration_service.get_settings_for_category(category_key)
|
||||
items.extend(_serialize_definition(defn) for defn in definitions)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=SettingDefinition)
|
||||
async def get_setting(
|
||||
key: str,
|
||||
_: object = Security(require_api_token),
|
||||
) -> SettingDefinition:
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error: # pragma: no cover - защита от некорректного ключа
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@router.put("/{key}", response_model=SettingDefinition)
|
||||
async def update_setting(
|
||||
key: str,
|
||||
payload: SettingUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SettingDefinition:
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
value = _coerce_value(key, payload.value)
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
await db.commit()
|
||||
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@router.delete("/{key}", response_model=SettingDefinition)
|
||||
async def reset_setting(
|
||||
key: str,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SettingDefinition:
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
await db.commit()
|
||||
return _serialize_definition(definition)
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Security
|
||||
|
||||
from app.config import settings
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import require_api_token
|
||||
from ..schemas.health import HealthCheckResponse, HealthFeatureFlags
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", tags=["health"], response_model=HealthCheckResponse)
|
||||
async def health_check(_: object = Security(require_api_token)) -> HealthCheckResponse:
|
||||
return HealthCheckResponse(
|
||||
status="ok",
|
||||
api_version=settings.WEB_API_VERSION,
|
||||
bot_version=version_service.current_version,
|
||||
features=HealthFeatureFlags(
|
||||
monitoring=settings.MONITORING_INTERVAL > 0,
|
||||
maintenance=True,
|
||||
reporting=True,
|
||||
webhooks=bool(settings.WEBHOOK_URL),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.promo_group import (
|
||||
count_promo_group_members,
|
||||
count_promo_groups,
|
||||
create_promo_group,
|
||||
delete_promo_group,
|
||||
get_promo_group_by_id,
|
||||
get_promo_groups_with_counts,
|
||||
update_promo_group,
|
||||
)
|
||||
from app.database.models import PromoGroup
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.promo_groups import (
|
||||
PromoGroupCreateRequest,
|
||||
PromoGroupListResponse,
|
||||
PromoGroupResponse,
|
||||
PromoGroupUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _normalize_period_discounts(group: PromoGroup) -> dict[int, int]:
|
||||
raw = group.period_discounts or {}
|
||||
normalized: dict[int, int] = {}
|
||||
if isinstance(raw, dict):
|
||||
for key, value in raw.items():
|
||||
try:
|
||||
normalized[int(key)] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return normalized
|
||||
|
||||
|
||||
def _serialize(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse:
|
||||
return PromoGroupResponse(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
server_discount_percent=group.server_discount_percent,
|
||||
traffic_discount_percent=group.traffic_discount_percent,
|
||||
device_discount_percent=group.device_discount_percent,
|
||||
period_discounts=_normalize_period_discounts(group),
|
||||
auto_assign_total_spent_kopeks=group.auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=group.apply_discounts_to_addons,
|
||||
is_default=group.is_default,
|
||||
members_count=members_count,
|
||||
created_at=group.created_at,
|
||||
updated_at=group.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=PromoGroupListResponse)
|
||||
async def list_promo_groups(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PromoGroupListResponse:
|
||||
total = await count_promo_groups(db)
|
||||
groups_with_counts = await get_promo_groups_with_counts(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return PromoGroupListResponse(
|
||||
items=[_serialize(group, members_count=count) for group, count in groups_with_counts],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{group_id}", response_model=PromoGroupResponse)
|
||||
async def get_promo_group(
|
||||
group_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoGroupResponse:
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found")
|
||||
|
||||
members_count = await count_promo_group_members(db, group_id)
|
||||
return _serialize(group, members_count=members_count)
|
||||
|
||||
|
||||
@router.post("", response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promo_group_endpoint(
|
||||
payload: PromoGroupCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoGroupResponse:
|
||||
try:
|
||||
group = await create_promo_group(
|
||||
db,
|
||||
name=payload.name,
|
||||
server_discount_percent=payload.server_discount_percent,
|
||||
traffic_discount_percent=payload.traffic_discount_percent,
|
||||
device_discount_percent=payload.device_discount_percent,
|
||||
period_discounts=payload.period_discounts,
|
||||
auto_assign_total_spent_kopeks=payload.auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=payload.apply_discounts_to_addons,
|
||||
is_default=payload.is_default,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Promo group with this name already exists",
|
||||
) from exc
|
||||
return _serialize(group, members_count=0)
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=PromoGroupResponse)
|
||||
async def update_promo_group_endpoint(
|
||||
group_id: int,
|
||||
payload: PromoGroupUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoGroupResponse:
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found")
|
||||
|
||||
try:
|
||||
group = await update_promo_group(
|
||||
db,
|
||||
group,
|
||||
name=payload.name,
|
||||
server_discount_percent=payload.server_discount_percent,
|
||||
traffic_discount_percent=payload.traffic_discount_percent,
|
||||
device_discount_percent=payload.device_discount_percent,
|
||||
period_discounts=payload.period_discounts,
|
||||
auto_assign_total_spent_kopeks=payload.auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=payload.apply_discounts_to_addons,
|
||||
is_default=payload.is_default,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Promo group with this name already exists",
|
||||
) from exc
|
||||
members_count = await count_promo_group_members(db, group_id)
|
||||
return _serialize(group, members_count=members_count)
|
||||
|
||||
|
||||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_promo_group_endpoint(
|
||||
group_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found")
|
||||
|
||||
success = await delete_promo_group(db, group)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Cannot delete default promo group")
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,301 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.promocode import (
|
||||
create_promocode,
|
||||
delete_promocode,
|
||||
get_promocode_by_code,
|
||||
get_promocode_statistics,
|
||||
get_promocodes_count,
|
||||
get_promocodes_list,
|
||||
update_promocode,
|
||||
)
|
||||
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.promocodes import (
|
||||
PromoCodeCreateRequest,
|
||||
PromoCodeDetailResponse,
|
||||
PromoCodeListResponse,
|
||||
PromoCodeRecentUse,
|
||||
PromoCodeResponse,
|
||||
PromoCodeUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _normalize_datetime(value: Optional[datetime]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if value.tzinfo is not None and value.utcoffset() is not None:
|
||||
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if value.tzinfo is not None:
|
||||
return value.replace(tzinfo=None)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_promocode(promocode: PromoCode) -> PromoCodeResponse:
|
||||
promo_type = PromoCodeType(promocode.type)
|
||||
return PromoCodeResponse(
|
||||
id=promocode.id,
|
||||
code=promocode.code,
|
||||
type=promo_type,
|
||||
balance_bonus_kopeks=promocode.balance_bonus_kopeks,
|
||||
balance_bonus_rubles=round(promocode.balance_bonus_kopeks / 100, 2),
|
||||
subscription_days=promocode.subscription_days,
|
||||
max_uses=promocode.max_uses,
|
||||
current_uses=promocode.current_uses,
|
||||
uses_left=promocode.uses_left,
|
||||
is_active=promocode.is_active,
|
||||
is_valid=promocode.is_valid,
|
||||
valid_from=promocode.valid_from,
|
||||
valid_until=promocode.valid_until,
|
||||
created_by=promocode.created_by,
|
||||
created_at=promocode.created_at,
|
||||
updated_at=promocode.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_recent_use(use: PromoCodeUse) -> PromoCodeRecentUse:
|
||||
return PromoCodeRecentUse(
|
||||
id=use.id,
|
||||
user_id=use.user_id,
|
||||
user_username=getattr(use, "user_username", None),
|
||||
user_full_name=getattr(use, "user_full_name", None),
|
||||
user_telegram_id=getattr(use, "user_telegram_id", None),
|
||||
used_at=use.used_at,
|
||||
)
|
||||
|
||||
|
||||
def _validate_create_payload(payload: PromoCodeCreateRequest) -> None:
|
||||
code = payload.code.strip()
|
||||
if not code:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Code must not be empty")
|
||||
|
||||
normalized_valid_from = _normalize_datetime(payload.valid_from)
|
||||
normalized_valid_until = _normalize_datetime(payload.valid_until)
|
||||
|
||||
if payload.type == PromoCodeType.BALANCE and payload.balance_bonus_kopeks <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Balance bonus must be positive for balance promo codes")
|
||||
|
||||
if payload.type in {PromoCodeType.SUBSCRIPTION_DAYS, PromoCodeType.TRIAL_SUBSCRIPTION} and payload.subscription_days <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription days must be positive for this promo code type")
|
||||
|
||||
if normalized_valid_from and normalized_valid_until and normalized_valid_from > normalized_valid_until:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_from cannot be greater than valid_until")
|
||||
|
||||
|
||||
def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCode) -> None:
|
||||
if payload.code is not None and not payload.code.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Code must not be empty")
|
||||
|
||||
if payload.type is not None:
|
||||
new_type = payload.type
|
||||
else:
|
||||
new_type = PromoCodeType(promocode.type)
|
||||
|
||||
balance_bonus = (
|
||||
payload.balance_bonus_kopeks
|
||||
if payload.balance_bonus_kopeks is not None
|
||||
else promocode.balance_bonus_kopeks
|
||||
)
|
||||
subscription_days = (
|
||||
payload.subscription_days
|
||||
if payload.subscription_days is not None
|
||||
else promocode.subscription_days
|
||||
)
|
||||
|
||||
if new_type == PromoCodeType.BALANCE and balance_bonus <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Balance bonus must be positive for balance promo codes")
|
||||
|
||||
if new_type in {PromoCodeType.SUBSCRIPTION_DAYS, PromoCodeType.TRIAL_SUBSCRIPTION} and subscription_days <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription days must be positive for this promo code type")
|
||||
|
||||
valid_from = (
|
||||
_normalize_datetime(payload.valid_from)
|
||||
if payload.valid_from is not None
|
||||
else promocode.valid_from
|
||||
)
|
||||
valid_until = (
|
||||
_normalize_datetime(payload.valid_until)
|
||||
if payload.valid_until is not None
|
||||
else promocode.valid_until
|
||||
)
|
||||
|
||||
if valid_from and valid_until and valid_from > valid_until:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_from cannot be greater than valid_until")
|
||||
|
||||
if payload.max_uses is not None and payload.max_uses != 0 and payload.max_uses < promocode.current_uses:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "max_uses cannot be less than current uses")
|
||||
|
||||
|
||||
@router.get("", response_model=PromoCodeListResponse)
|
||||
async def list_promocodes(
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
is_active: Optional[bool] = Query(default=None),
|
||||
) -> PromoCodeListResponse:
|
||||
total = await get_promocodes_count(db, is_active=is_active) or 0
|
||||
promocodes = await get_promocodes_list(db, offset=offset, limit=limit, is_active=is_active)
|
||||
|
||||
return PromoCodeListResponse(
|
||||
items=[_serialize_promocode(promocode) for promocode in promocodes],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{promocode_id}", response_model=PromoCodeDetailResponse)
|
||||
async def get_promocode(
|
||||
promocode_id: int,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoCodeDetailResponse:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
if not promocode:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
|
||||
|
||||
stats = await get_promocode_statistics(db, promocode_id)
|
||||
base = _serialize_promocode(promocode)
|
||||
recent_uses = [
|
||||
_serialize_recent_use(use)
|
||||
for use in stats.get("recent_uses", [])
|
||||
]
|
||||
|
||||
return PromoCodeDetailResponse(
|
||||
**base.dict(),
|
||||
total_uses=stats.get("total_uses", 0),
|
||||
today_uses=stats.get("today_uses", 0),
|
||||
recent_uses=recent_uses,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=PromoCodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promocode_endpoint(
|
||||
payload: PromoCodeCreateRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoCodeResponse:
|
||||
_validate_create_payload(payload)
|
||||
|
||||
normalized_code = payload.code.strip().upper()
|
||||
normalized_valid_from = _normalize_datetime(payload.valid_from)
|
||||
normalized_valid_until = _normalize_datetime(payload.valid_until)
|
||||
|
||||
existing = await get_promocode_by_code(db, normalized_code)
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo code with this code already exists")
|
||||
|
||||
creator_id = (
|
||||
payload.created_by
|
||||
if payload.created_by is not None and payload.created_by > 0
|
||||
else None
|
||||
)
|
||||
|
||||
promocode = await create_promocode(
|
||||
db,
|
||||
code=normalized_code,
|
||||
type=payload.type,
|
||||
balance_bonus_kopeks=payload.balance_bonus_kopeks,
|
||||
subscription_days=payload.subscription_days,
|
||||
max_uses=payload.max_uses,
|
||||
valid_until=normalized_valid_until,
|
||||
created_by=creator_id,
|
||||
)
|
||||
|
||||
update_fields = {}
|
||||
if normalized_valid_from is not None:
|
||||
update_fields["valid_from"] = normalized_valid_from
|
||||
if payload.is_active is not None and payload.is_active != promocode.is_active:
|
||||
update_fields["is_active"] = payload.is_active
|
||||
if normalized_valid_until is not None:
|
||||
update_fields["valid_until"] = normalized_valid_until
|
||||
|
||||
if update_fields:
|
||||
promocode = await update_promocode(db, promocode, **update_fields)
|
||||
|
||||
return _serialize_promocode(promocode)
|
||||
|
||||
|
||||
@router.patch("/{promocode_id}", response_model=PromoCodeResponse)
|
||||
async def update_promocode_endpoint(
|
||||
promocode_id: int,
|
||||
payload: PromoCodeUpdateRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoCodeResponse:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
if not promocode:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
|
||||
|
||||
_validate_update_payload(payload, promocode)
|
||||
|
||||
updates: dict[str, Any] = {}
|
||||
|
||||
if payload.code is not None:
|
||||
normalized_code = payload.code.strip().upper()
|
||||
if normalized_code != promocode.code:
|
||||
existing = await get_promocode_by_code(db, normalized_code)
|
||||
if existing and existing.id != promocode_id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo code with this code already exists")
|
||||
updates["code"] = normalized_code
|
||||
|
||||
if payload.type is not None:
|
||||
updates["type"] = payload.type.value
|
||||
|
||||
if payload.balance_bonus_kopeks is not None:
|
||||
updates["balance_bonus_kopeks"] = payload.balance_bonus_kopeks
|
||||
|
||||
if payload.subscription_days is not None:
|
||||
updates["subscription_days"] = payload.subscription_days
|
||||
|
||||
if payload.max_uses is not None:
|
||||
updates["max_uses"] = payload.max_uses
|
||||
|
||||
if payload.valid_from is not None:
|
||||
updates["valid_from"] = _normalize_datetime(payload.valid_from)
|
||||
|
||||
if payload.valid_until is not None:
|
||||
updates["valid_until"] = _normalize_datetime(payload.valid_until)
|
||||
|
||||
if payload.is_active is not None:
|
||||
updates["is_active"] = payload.is_active
|
||||
|
||||
if not updates:
|
||||
return _serialize_promocode(promocode)
|
||||
|
||||
promocode = await update_promocode(db, promocode, **updates)
|
||||
return _serialize_promocode(promocode)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{promocode_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
)
|
||||
async def delete_promocode_endpoint(
|
||||
promocode_id: int,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
if not promocode:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
|
||||
|
||||
success = await delete_promocode(db, promocode)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Failed to delete promo code")
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,456 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.remnawave import (
|
||||
RemnaWaveConnectionStatus,
|
||||
RemnaWaveGenericSyncResponse,
|
||||
RemnaWaveInboundsResponse,
|
||||
RemnaWaveNode,
|
||||
RemnaWaveNodeActionRequest,
|
||||
RemnaWaveNodeActionResponse,
|
||||
RemnaWaveNodeListResponse,
|
||||
RemnaWaveNodeStatisticsResponse,
|
||||
RemnaWaveNodeUsageResponse,
|
||||
RemnaWaveOperationResponse,
|
||||
RemnaWaveSquad,
|
||||
RemnaWaveSquadActionRequest,
|
||||
RemnaWaveSquadCreateRequest,
|
||||
RemnaWaveSquadListResponse,
|
||||
RemnaWaveSquadUpdateRequest,
|
||||
RemnaWaveStatusResponse,
|
||||
RemnaWaveSystemStatsResponse,
|
||||
RemnaWaveSyncFromPanelRequest,
|
||||
RemnaWaveUserTrafficResponse,
|
||||
)
|
||||
|
||||
try: # pragma: no cover - импорт может не работать без optional-зависимостей
|
||||
from app.services.remnawave_service import ( # type: ignore
|
||||
RemnaWaveConfigurationError,
|
||||
RemnaWaveService,
|
||||
)
|
||||
except Exception: # pragma: no cover - при ошибке импорта скрываем функционал
|
||||
RemnaWaveConfigurationError = None # type: ignore[assignment]
|
||||
RemnaWaveService = None # type: ignore[assignment]
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - только для типов в IDE
|
||||
from app.services.remnawave_service import RemnaWaveService as RemnaWaveServiceType
|
||||
else:
|
||||
RemnaWaveServiceType = Any
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service() -> "RemnaWaveServiceType":
|
||||
if RemnaWaveService is None: # pragma: no cover - зависимость не доступна
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="RemnaWave сервис недоступен",
|
||||
)
|
||||
|
||||
return RemnaWaveService()
|
||||
|
||||
|
||||
def _ensure_service_configured(service: "RemnaWaveServiceType") -> None:
|
||||
if RemnaWaveService is None: # pragma: no cover - зависимость не доступна
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="RemnaWave сервис недоступен",
|
||||
)
|
||||
|
||||
if not service.is_configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=service.configuration_error or "RemnaWave API не настроен",
|
||||
)
|
||||
|
||||
|
||||
def _serialize_node(node_data: Dict[str, Any]) -> RemnaWaveNode:
|
||||
return RemnaWaveNode(
|
||||
uuid=node_data.get("uuid", ""),
|
||||
name=node_data.get("name", ""),
|
||||
address=node_data.get("address", ""),
|
||||
country_code=node_data.get("country_code"),
|
||||
is_connected=bool(node_data.get("is_connected")),
|
||||
is_disabled=bool(node_data.get("is_disabled")),
|
||||
is_node_online=bool(node_data.get("is_node_online")),
|
||||
is_xray_running=bool(node_data.get("is_xray_running")),
|
||||
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"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_last_updated(value: Any) -> Optional[datetime]:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/status", response_model=RemnaWaveStatusResponse)
|
||||
async def get_remnawave_status(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveStatusResponse:
|
||||
service = _get_service()
|
||||
|
||||
connection_info: Optional[RemnaWaveConnectionStatus] = None
|
||||
connection_result = await service.test_api_connection()
|
||||
|
||||
if connection_result:
|
||||
connection_info = RemnaWaveConnectionStatus(**connection_result)
|
||||
|
||||
return RemnaWaveStatusResponse(
|
||||
is_configured=service.is_configured,
|
||||
configuration_error=service.configuration_error,
|
||||
connection=connection_info,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/system", response_model=RemnaWaveSystemStatsResponse)
|
||||
async def get_system_statistics(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveSystemStatsResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
stats = await service.get_system_statistics()
|
||||
if not stats or "system" not in stats:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, "Не удалось получить статистику RemnaWave")
|
||||
|
||||
stats["last_updated"] = _parse_last_updated(stats.get("last_updated"))
|
||||
return RemnaWaveSystemStatsResponse(**stats)
|
||||
|
||||
|
||||
@router.get("/nodes", response_model=RemnaWaveNodeListResponse)
|
||||
async def list_nodes(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveNodeListResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
nodes = await service.get_all_nodes()
|
||||
serialized = [_serialize_node(node) for node in nodes]
|
||||
return RemnaWaveNodeListResponse(items=serialized, total=len(serialized))
|
||||
|
||||
|
||||
@router.get("/nodes/realtime", response_model=List[Dict[str, Any]])
|
||||
async def get_nodes_realtime_usage(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> List[Dict[str, Any]]:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
return await service.get_nodes_realtime_usage()
|
||||
|
||||
|
||||
@router.get("/nodes/{node_uuid}", response_model=RemnaWaveNode)
|
||||
async def get_node_details(
|
||||
node_uuid: str,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveNode:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
node = await service.get_node_details(node_uuid)
|
||||
if not node:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Нода не найдена")
|
||||
return _serialize_node(node)
|
||||
|
||||
|
||||
@router.get("/nodes/{node_uuid}/statistics", response_model=RemnaWaveNodeStatisticsResponse)
|
||||
async def get_node_statistics(
|
||||
node_uuid: str,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveNodeStatisticsResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
stats = await service.get_node_statistics(node_uuid)
|
||||
if not stats or not stats.get("node"):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Не удалось получить информацию по ноде")
|
||||
|
||||
node_data = _serialize_node(stats["node"])
|
||||
usage_history = stats.get("usage_history") or []
|
||||
realtime = stats.get("realtime")
|
||||
last_updated = _parse_last_updated(stats.get("last_updated"))
|
||||
|
||||
return RemnaWaveNodeStatisticsResponse(
|
||||
node=node_data,
|
||||
realtime=realtime,
|
||||
usage_history=usage_history,
|
||||
last_updated=last_updated,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/nodes/{node_uuid}/usage", response_model=RemnaWaveNodeUsageResponse)
|
||||
async def get_node_usage_range(
|
||||
node_uuid: str,
|
||||
start: Optional[datetime] = Query(default=None),
|
||||
end: Optional[datetime] = Query(default=None),
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveNodeUsageResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
end_dt = end or datetime.utcnow()
|
||||
start_dt = start or (end_dt - timedelta(days=7))
|
||||
|
||||
if start_dt >= end_dt:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Некорректный диапазон дат")
|
||||
|
||||
usage = await service.get_node_user_usage_by_range(node_uuid, start_dt, end_dt)
|
||||
return RemnaWaveNodeUsageResponse(items=usage or [])
|
||||
|
||||
|
||||
@router.post("/nodes/{node_uuid}/actions", response_model=RemnaWaveNodeActionResponse)
|
||||
async def manage_node(
|
||||
node_uuid: str,
|
||||
payload: RemnaWaveNodeActionRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveNodeActionResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
success = await service.manage_node(node_uuid, payload.action)
|
||||
detail = None
|
||||
if success:
|
||||
if payload.action == "enable":
|
||||
detail = "Нода включена"
|
||||
elif payload.action == "disable":
|
||||
detail = "Нода отключена"
|
||||
elif payload.action == "restart":
|
||||
detail = "Команда перезапуска отправлена"
|
||||
else:
|
||||
detail = "Не удалось выполнить действие"
|
||||
|
||||
return RemnaWaveNodeActionResponse(success=success, detail=detail)
|
||||
|
||||
|
||||
@router.post("/nodes/restart", response_model=RemnaWaveNodeActionResponse)
|
||||
async def restart_all_nodes(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveNodeActionResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
success = await service.restart_all_nodes()
|
||||
detail = "Команда перезапуска отправлена" if success else "Не удалось перезапустить ноды"
|
||||
return RemnaWaveNodeActionResponse(success=success, detail=detail)
|
||||
|
||||
|
||||
@router.get("/squads", response_model=RemnaWaveSquadListResponse)
|
||||
async def list_squads(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveSquadListResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
squads = await service.get_all_squads()
|
||||
serialized = [RemnaWaveSquad(**squad) for squad in squads]
|
||||
return RemnaWaveSquadListResponse(items=serialized, total=len(serialized))
|
||||
|
||||
|
||||
@router.get("/squads/{squad_uuid}", response_model=RemnaWaveSquad)
|
||||
async def get_squad_details(
|
||||
squad_uuid: str,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveSquad:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
squad = await service.get_squad_details(squad_uuid)
|
||||
if not squad:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад не найден")
|
||||
return RemnaWaveSquad(**squad)
|
||||
|
||||
|
||||
@router.post("/squads", response_model=RemnaWaveOperationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_squad(
|
||||
payload: RemnaWaveSquadCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveOperationResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
success = await service.create_squad(payload.name, payload.inbound_uuids)
|
||||
detail = "Сквад успешно создан" if success else "Не удалось создать сквад"
|
||||
return RemnaWaveOperationResponse(success=success, detail=detail)
|
||||
|
||||
|
||||
@router.patch("/squads/{squad_uuid}", response_model=RemnaWaveOperationResponse)
|
||||
async def update_squad(
|
||||
squad_uuid: str,
|
||||
payload: RemnaWaveSquadUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveOperationResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
success = False
|
||||
detail = "Необходимо указать новые данные"
|
||||
|
||||
if payload.name is not None or payload.inbound_uuids is not None:
|
||||
success = await service.update_squad(
|
||||
squad_uuid,
|
||||
name=payload.name,
|
||||
inbounds=payload.inbound_uuids,
|
||||
)
|
||||
detail = "Сквад обновлен" if success else "Не удалось обновить сквад"
|
||||
|
||||
return RemnaWaveOperationResponse(success=success, detail=detail)
|
||||
|
||||
|
||||
@router.post("/squads/{squad_uuid}/actions", response_model=RemnaWaveOperationResponse)
|
||||
async def squad_actions(
|
||||
squad_uuid: str,
|
||||
payload: RemnaWaveSquadActionRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveOperationResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
action = payload.action
|
||||
success = False
|
||||
detail = "Неизвестное действие"
|
||||
|
||||
if action == "add_all_users":
|
||||
success = await service.add_all_users_to_squad(squad_uuid)
|
||||
detail = "Пользователи добавлены" if success else "Не удалось добавить пользователей"
|
||||
elif action == "remove_all_users":
|
||||
success = await service.remove_all_users_from_squad(squad_uuid)
|
||||
detail = "Пользователи удалены" if success else "Не удалось удалить пользователей"
|
||||
elif action == "delete":
|
||||
success = await service.delete_squad(squad_uuid)
|
||||
detail = "Сквад удален" if success else "Не удалось удалить сквад"
|
||||
elif action == "rename":
|
||||
if not payload.name:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Необходимо указать новое имя")
|
||||
success = await service.rename_squad(squad_uuid, payload.name)
|
||||
detail = "Сквад переименован" if success else "Не удалось переименовать сквад"
|
||||
elif action == "update_inbounds":
|
||||
if not payload.inbound_uuids:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Необходимо указать inbound_uuids")
|
||||
success = await service.update_squad_inbounds(squad_uuid, payload.inbound_uuids)
|
||||
detail = "Инбаунды обновлены" if success else "Не удалось обновить инбаунды"
|
||||
|
||||
return RemnaWaveOperationResponse(success=success, detail=detail)
|
||||
|
||||
|
||||
@router.get("/inbounds", response_model=RemnaWaveInboundsResponse)
|
||||
async def list_inbounds(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveInboundsResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
inbounds = await service.get_all_inbounds()
|
||||
return RemnaWaveInboundsResponse(items=inbounds or [])
|
||||
|
||||
|
||||
@router.get("/users/{telegram_id}/traffic", response_model=RemnaWaveUserTrafficResponse)
|
||||
async def get_user_traffic(
|
||||
telegram_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveUserTrafficResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
stats = await service.get_user_traffic_stats(telegram_id)
|
||||
if not stats:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Пользователь не найден в RemnaWave")
|
||||
|
||||
return RemnaWaveUserTrafficResponse(telegram_id=telegram_id, **stats)
|
||||
|
||||
|
||||
@router.post("/sync/from-panel", response_model=RemnaWaveGenericSyncResponse)
|
||||
async def sync_from_panel(
|
||||
payload: RemnaWaveSyncFromPanelRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveGenericSyncResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
try:
|
||||
stats = await service.sync_users_from_panel(db, payload.mode)
|
||||
detail = "Синхронизация из панели выполнена"
|
||||
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
||||
except Exception as exc: # pragma: no cover - точный тип зависит от импорта
|
||||
if RemnaWaveConfigurationError and isinstance(exc, RemnaWaveConfigurationError):
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
|
||||
raise
|
||||
|
||||
|
||||
@router.post("/sync/to-panel", response_model=RemnaWaveGenericSyncResponse)
|
||||
async def sync_to_panel(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveGenericSyncResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
stats = await service.sync_users_to_panel(db)
|
||||
detail = "Синхронизация в панель выполнена"
|
||||
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
||||
|
||||
|
||||
@router.post("/sync/subscriptions/validate", response_model=RemnaWaveGenericSyncResponse)
|
||||
async def validate_and_fix_subscriptions(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveGenericSyncResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
stats = await service.validate_and_fix_subscriptions(db)
|
||||
detail = "Подписки проверены"
|
||||
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
||||
|
||||
|
||||
@router.post("/sync/subscriptions/cleanup", response_model=RemnaWaveGenericSyncResponse)
|
||||
async def cleanup_orphaned_subscriptions(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveGenericSyncResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
stats = await service.cleanup_orphaned_subscriptions(db)
|
||||
detail = "Очистка завершена"
|
||||
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
||||
|
||||
|
||||
@router.post("/sync/subscriptions/statuses", response_model=RemnaWaveGenericSyncResponse)
|
||||
async def sync_subscription_statuses(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveGenericSyncResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
stats = await service.sync_subscription_statuses(db)
|
||||
detail = "Статусы подписок синхронизированы"
|
||||
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
||||
|
||||
|
||||
@router.get("/sync/recommendations", response_model=RemnaWaveGenericSyncResponse)
|
||||
async def get_sync_recommendations(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveGenericSyncResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
data = await service.get_sync_recommendations(db)
|
||||
detail = "Рекомендации получены"
|
||||
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=data)
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Security
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
UserStatus,
|
||||
)
|
||||
|
||||
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]:
|
||||
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)
|
||||
) or 0
|
||||
blocked_users = await db.scalar(
|
||||
select(func.count()).select_from(User).where(User.status == UserStatus.BLOCKED.value)
|
||||
) or 0
|
||||
|
||||
total_balance_kopeks = await db.scalar(
|
||||
select(func.coalesce(func.sum(User.balance_kopeks), 0))
|
||||
) or 0
|
||||
|
||||
active_subscriptions = await db.scalar(
|
||||
select(func.count()).select_from(Subscription).where(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
) or 0
|
||||
|
||||
expired_subscriptions = await db.scalar(
|
||||
select(func.count()).select_from(Subscription).where(
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
)
|
||||
) or 0
|
||||
|
||||
pending_tickets = await db.scalar(
|
||||
select(func.count()).select_from(Ticket).where(
|
||||
Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value])
|
||||
)
|
||||
) or 0
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_transactions = await db.scalar(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
func.date(Transaction.created_at) == today,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
)
|
||||
) or 0
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"active": active_users,
|
||||
"blocked": blocked_users,
|
||||
"balance_kopeks": int(total_balance_kopeks),
|
||||
"balance_rubles": round(total_balance_kopeks / 100, 2),
|
||||
},
|
||||
"subscriptions": {
|
||||
"active": active_subscriptions,
|
||||
"expired": expired_subscriptions,
|
||||
},
|
||||
"support": {
|
||||
"open_tickets": pending_tickets,
|
||||
},
|
||||
"payments": {
|
||||
"today_kopeks": int(today_transactions),
|
||||
"today_rubles": round(today_transactions / 100, 2),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import (
|
||||
add_subscription_devices,
|
||||
add_subscription_squad,
|
||||
add_subscription_traffic,
|
||||
create_paid_subscription,
|
||||
create_trial_subscription,
|
||||
extend_subscription,
|
||||
get_subscription_by_user_id,
|
||||
remove_subscription_squad,
|
||||
)
|
||||
from app.database.models import Subscription, SubscriptionStatus
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.subscriptions import (
|
||||
SubscriptionCreateRequest,
|
||||
SubscriptionDevicesRequest,
|
||||
SubscriptionExtendRequest,
|
||||
SubscriptionResponse,
|
||||
SubscriptionSquadRequest,
|
||||
SubscriptionTrafficRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
|
||||
return SubscriptionResponse(
|
||||
id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
status=subscription.status,
|
||||
actual_status=subscription.actual_status,
|
||||
is_trial=subscription.is_trial,
|
||||
start_date=subscription.start_date,
|
||||
end_date=subscription.end_date,
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_used_gb=subscription.traffic_used_gb,
|
||||
device_limit=subscription.device_limit,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
autopay_days_before=subscription.autopay_days_before,
|
||||
subscription_url=subscription.subscription_url,
|
||||
subscription_crypto_link=subscription.subscription_crypto_link,
|
||||
connected_squads=list(subscription.connected_squads or []),
|
||||
created_at=subscription.created_at,
|
||||
updated_at=subscription.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription:
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
|
||||
return subscription
|
||||
|
||||
|
||||
@router.get("", response_model=list[SubscriptionResponse])
|
||||
async def list_subscriptions(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
status_filter: Optional[SubscriptionStatus] = Query(default=None, alias="status"),
|
||||
user_id: Optional[int] = Query(default=None),
|
||||
is_trial: Optional[bool] = Query(default=None),
|
||||
) -> list[SubscriptionResponse]:
|
||||
query = select(Subscription).options(selectinload(Subscription.user))
|
||||
|
||||
if status_filter:
|
||||
query = query.where(Subscription.status == status_filter.value)
|
||||
if user_id:
|
||||
query = query.where(Subscription.user_id == user_id)
|
||||
if is_trial is not None:
|
||||
query = query.where(Subscription.is_trial.is_(is_trial))
|
||||
|
||||
query = query.order_by(Subscription.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
subscriptions = result.scalars().all()
|
||||
return [_serialize_subscription(sub) for sub in subscriptions]
|
||||
|
||||
|
||||
@router.get("/{subscription_id}", response_model=SubscriptionResponse)
|
||||
async def get_subscription(
|
||||
subscription_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("", response_model=SubscriptionResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_subscription(
|
||||
payload: SubscriptionCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
existing = await get_subscription_by_user_id(db, payload.user_id)
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription")
|
||||
|
||||
if payload.is_trial:
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
duration_days=payload.duration_days,
|
||||
traffic_limit_gb=payload.traffic_limit_gb,
|
||||
device_limit=payload.device_limit,
|
||||
squad_uuid=payload.squad_uuid,
|
||||
)
|
||||
else:
|
||||
if payload.duration_days is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions")
|
||||
subscription = await create_paid_subscription(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
duration_days=payload.duration_days,
|
||||
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
connected_squads=payload.connected_squads or [],
|
||||
)
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/extend", response_model=SubscriptionResponse)
|
||||
async def extend_subscription_endpoint(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionExtendRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await extend_subscription(db, subscription, payload.days)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/traffic", response_model=SubscriptionResponse)
|
||||
async def add_subscription_traffic_endpoint(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionTrafficRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await add_subscription_traffic(db, subscription, payload.gb)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/devices", response_model=SubscriptionResponse)
|
||||
async def add_subscription_devices_endpoint(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionDevicesRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await add_subscription_devices(db, subscription, payload.devices)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/squads", response_model=SubscriptionResponse)
|
||||
async def add_subscription_squad_endpoint(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionSquadRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
if not payload.squad_uuid:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "squad_uuid is required")
|
||||
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await add_subscription_squad(db, subscription, payload.squad_uuid)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.delete("/{subscription_id}/squads/{squad_uuid}", response_model=SubscriptionResponse)
|
||||
async def remove_subscription_squad_endpoint(
|
||||
subscription_id: int,
|
||||
squad_uuid: str,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await remove_subscription_squad(db, subscription, squad_uuid)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.ticket import TicketCRUD
|
||||
from app.database.models import Ticket, TicketMessage, TicketStatus
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.tickets import (
|
||||
TicketMessageResponse,
|
||||
TicketPriorityUpdateRequest,
|
||||
TicketReplyBlockRequest,
|
||||
TicketResponse,
|
||||
TicketStatusUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
|
||||
return TicketMessageResponse(
|
||||
id=message.id,
|
||||
user_id=message.user_id,
|
||||
message_text=message.message_text,
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=message.has_media,
|
||||
media_type=message.media_type,
|
||||
media_caption=message.media_caption,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_ticket(ticket: Ticket, include_messages: bool = False) -> TicketResponse:
|
||||
messages = []
|
||||
if include_messages:
|
||||
messages = sorted(ticket.messages, key=lambda m: m.created_at)
|
||||
|
||||
return TicketResponse(
|
||||
id=ticket.id,
|
||||
user_id=ticket.user_id,
|
||||
title=ticket.title,
|
||||
status=ticket.status,
|
||||
priority=ticket.priority,
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at,
|
||||
closed_at=ticket.closed_at,
|
||||
user_reply_block_permanent=ticket.user_reply_block_permanent,
|
||||
user_reply_block_until=ticket.user_reply_block_until,
|
||||
messages=[_serialize_message(message) for message in messages],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TicketResponse])
|
||||
async def list_tickets(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
status_filter: Optional[TicketStatus] = Query(default=None, alias="status"),
|
||||
priority: Optional[str] = Query(default=None),
|
||||
user_id: Optional[int] = Query(default=None),
|
||||
) -> list[TicketResponse]:
|
||||
status_value = status_filter.value if status_filter else None
|
||||
|
||||
if user_id:
|
||||
tickets = await TicketCRUD.get_user_tickets(
|
||||
db,
|
||||
user_id=user_id,
|
||||
status=status_value,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
else:
|
||||
tickets = await TicketCRUD.get_all_tickets(
|
||||
db,
|
||||
status=status_value,
|
||||
priority=priority,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return [_serialize_ticket(ticket) for ticket in tickets]
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=TicketResponse)
|
||||
async def get_ticket(
|
||||
ticket_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
if not ticket:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/status", response_model=TicketResponse)
|
||||
async def update_ticket_status(
|
||||
ticket_id: int,
|
||||
payload: TicketStatusUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
try:
|
||||
status_value = TicketStatus(payload.status).value
|
||||
except ValueError as error:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid ticket status") from error
|
||||
|
||||
closed_at = datetime.utcnow() if status_value == TicketStatus.CLOSED.value else None
|
||||
success = await TicketCRUD.update_ticket_status(db, ticket_id, status_value, closed_at)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/priority", response_model=TicketResponse)
|
||||
async def update_ticket_priority(
|
||||
ticket_id: int,
|
||||
payload: TicketPriorityUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
allowed_priorities = {"low", "normal", "high", "urgent"}
|
||||
if payload.priority not in allowed_priorities:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid priority")
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
if not ticket:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
|
||||
ticket.priority = payload.priority
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/reply-block", response_model=TicketResponse)
|
||||
async def update_reply_block(
|
||||
ticket_id: int,
|
||||
payload: TicketReplyBlockRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
until = payload.until
|
||||
if not payload.permanent and until and until <= datetime.utcnow():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Block expiration must be in the future")
|
||||
|
||||
success = await TicketCRUD.set_user_reply_block(
|
||||
db,
|
||||
ticket_id,
|
||||
permanent=payload.permanent,
|
||||
until=until,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
|
||||
|
||||
@router.delete("/{ticket_id}/reply-block", response_model=TicketResponse)
|
||||
async def clear_reply_block(
|
||||
ticket_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
success = await TicketCRUD.set_user_reply_block(
|
||||
db,
|
||||
ticket_id,
|
||||
permanent=False,
|
||||
until=None,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.web_api_token import (
|
||||
delete_token,
|
||||
get_token_by_id,
|
||||
list_tokens,
|
||||
)
|
||||
from app.database.models import WebApiToken
|
||||
from app.services.web_api_token_service import web_api_token_service
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.tokens import TokenCreateRequest, TokenCreateResponse, TokenResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize(token: WebApiToken) -> TokenResponse:
|
||||
return TokenResponse(
|
||||
id=token.id,
|
||||
name=token.name,
|
||||
prefix=token.token_prefix,
|
||||
description=token.description,
|
||||
is_active=token.is_active,
|
||||
created_at=token.created_at,
|
||||
updated_at=token.updated_at,
|
||||
expires_at=token.expires_at,
|
||||
last_used_at=token.last_used_at,
|
||||
last_used_ip=token.last_used_ip,
|
||||
created_by=token.created_by,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TokenResponse])
|
||||
async def get_tokens(
|
||||
_: WebApiToken = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[TokenResponse]:
|
||||
tokens = await list_tokens(db, include_inactive=True)
|
||||
return [_serialize(token) for token in tokens]
|
||||
|
||||
|
||||
@router.post("", response_model=TokenCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_token(
|
||||
payload: TokenCreateRequest,
|
||||
actor: WebApiToken = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TokenCreateResponse:
|
||||
token_value, token = await web_api_token_service.create_token(
|
||||
db,
|
||||
name=payload.name.strip(),
|
||||
description=payload.description,
|
||||
expires_at=payload.expires_at,
|
||||
created_by=actor.name,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
base = _serialize(token).model_dump()
|
||||
base["token"] = token_value
|
||||
return TokenCreateResponse(**base)
|
||||
|
||||
|
||||
@router.post("/{token_id}/revoke", response_model=TokenResponse)
|
||||
async def revoke_token(
|
||||
token_id: int,
|
||||
_: WebApiToken = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TokenResponse:
|
||||
token = await get_token_by_id(db, token_id)
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found")
|
||||
|
||||
await web_api_token_service.revoke_token(db, token)
|
||||
await db.commit()
|
||||
return _serialize(token)
|
||||
|
||||
|
||||
@router.post("/{token_id}/activate", response_model=TokenResponse)
|
||||
async def activate_token(
|
||||
token_id: int,
|
||||
_: WebApiToken = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TokenResponse:
|
||||
token = await get_token_by_id(db, token_id)
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found")
|
||||
|
||||
await web_api_token_service.activate_token(db, token)
|
||||
await db.commit()
|
||||
return _serialize(token)
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_token_endpoint(
|
||||
token_id: int,
|
||||
_: WebApiToken = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
token = await get_token_by_id(db, token_id)
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found")
|
||||
|
||||
await delete_token(db, token)
|
||||
await db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Security
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Transaction
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.transactions import TransactionListResponse, TransactionResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize(transaction: Transaction) -> TransactionResponse:
|
||||
return TransactionResponse(
|
||||
id=transaction.id,
|
||||
user_id=transaction.user_id,
|
||||
type=transaction.type,
|
||||
amount_kopeks=transaction.amount_kopeks,
|
||||
amount_rubles=round(transaction.amount_kopeks / 100, 2),
|
||||
description=transaction.description,
|
||||
payment_method=transaction.payment_method,
|
||||
external_id=transaction.external_id,
|
||||
is_completed=transaction.is_completed,
|
||||
created_at=transaction.created_at,
|
||||
completed_at=transaction.completed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=TransactionListResponse)
|
||||
async def list_transactions(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user_id: Optional[int] = Query(default=None),
|
||||
type_filter: Optional[str] = Query(default=None, alias="type"),
|
||||
payment_method: Optional[str] = Query(default=None),
|
||||
is_completed: Optional[bool] = Query(default=None),
|
||||
date_from: Optional[datetime] = Query(default=None),
|
||||
date_to: Optional[datetime] = Query(default=None),
|
||||
) -> TransactionListResponse:
|
||||
base_query = select(Transaction)
|
||||
conditions = []
|
||||
|
||||
if user_id:
|
||||
conditions.append(Transaction.user_id == user_id)
|
||||
if type_filter:
|
||||
conditions.append(Transaction.type == type_filter)
|
||||
if payment_method:
|
||||
conditions.append(Transaction.payment_method == payment_method)
|
||||
if is_completed is not None:
|
||||
conditions.append(Transaction.is_completed.is_(is_completed))
|
||||
if date_from:
|
||||
conditions.append(Transaction.created_at >= date_from)
|
||||
if date_to:
|
||||
conditions.append(Transaction.created_at <= date_to)
|
||||
|
||||
if conditions:
|
||||
base_query = base_query.where(and_(*conditions))
|
||||
|
||||
total_query = base_query.with_only_columns(func.count()).order_by(None)
|
||||
total = await db.scalar(total_query) or 0
|
||||
|
||||
result = await db.execute(
|
||||
base_query.order_by(Transaction.created_at.desc()).offset(offset).limit(limit)
|
||||
)
|
||||
transactions = result.scalars().all()
|
||||
|
||||
return TransactionListResponse(
|
||||
items=[_serialize(tx) for tx in transactions],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.user import (
|
||||
add_user_balance,
|
||||
create_user,
|
||||
get_user_by_id,
|
||||
get_user_by_referral_code,
|
||||
get_user_by_telegram_id,
|
||||
update_user,
|
||||
)
|
||||
from app.database.models import PromoGroup, Subscription, User, UserStatus
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.users import (
|
||||
BalanceUpdateRequest,
|
||||
PromoGroupSummary,
|
||||
SubscriptionSummary,
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
UserResponse,
|
||||
UserUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_promo_group(group: Optional[PromoGroup]) -> Optional[PromoGroupSummary]:
|
||||
if not group:
|
||||
return None
|
||||
return PromoGroupSummary(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
server_discount_percent=group.server_discount_percent,
|
||||
traffic_discount_percent=group.traffic_discount_percent,
|
||||
device_discount_percent=group.device_discount_percent,
|
||||
apply_discounts_to_addons=getattr(group, "apply_discounts_to_addons", True),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[SubscriptionSummary]:
|
||||
if not subscription:
|
||||
return None
|
||||
|
||||
return SubscriptionSummary(
|
||||
id=subscription.id,
|
||||
status=subscription.status,
|
||||
actual_status=subscription.actual_status,
|
||||
is_trial=subscription.is_trial,
|
||||
start_date=subscription.start_date,
|
||||
end_date=subscription.end_date,
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_used_gb=subscription.traffic_used_gb,
|
||||
device_limit=subscription.device_limit,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
autopay_days_before=subscription.autopay_days_before,
|
||||
subscription_url=subscription.subscription_url,
|
||||
subscription_crypto_link=subscription.subscription_crypto_link,
|
||||
connected_squads=list(subscription.connected_squads or []),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_user(user: User) -> UserResponse:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
status=user.status,
|
||||
language=user.language,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_rubles=round(user.balance_kopeks / 100, 2),
|
||||
referral_code=user.referral_code,
|
||||
referred_by_id=user.referred_by_id,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_made_first_topup=user.has_made_first_topup,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
last_activity=user.last_activity,
|
||||
promo_group=_serialize_promo_group(promo_group),
|
||||
subscription=_serialize_subscription(subscription),
|
||||
)
|
||||
|
||||
|
||||
def _apply_search_filter(query, search: str):
|
||||
search_lower = f"%{search.lower()}%"
|
||||
conditions = [
|
||||
func.lower(User.username).like(search_lower),
|
||||
func.lower(User.first_name).like(search_lower),
|
||||
func.lower(User.last_name).like(search_lower),
|
||||
func.lower(User.referral_code).like(search_lower),
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
conditions.append(User.id == int(search))
|
||||
|
||||
return query.where(or_(*conditions))
|
||||
|
||||
|
||||
@router.get("", response_model=UserListResponse)
|
||||
async def list_users(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
status_filter: Optional[UserStatus] = Query(default=None, alias="status"),
|
||||
promo_group_id: Optional[int] = Query(default=None),
|
||||
search: Optional[str] = Query(default=None),
|
||||
) -> UserListResponse:
|
||||
base_query = (
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
)
|
||||
|
||||
if status_filter:
|
||||
base_query = base_query.where(User.status == status_filter.value)
|
||||
|
||||
if promo_group_id:
|
||||
base_query = base_query.where(User.promo_group_id == promo_group_id)
|
||||
|
||||
if search:
|
||||
base_query = _apply_search_filter(base_query, search)
|
||||
|
||||
total_query = base_query.with_only_columns(func.count()).order_by(None)
|
||||
total = await db.scalar(total_query) or 0
|
||||
|
||||
result = await db.execute(
|
||||
base_query.order_by(User.created_at.desc()).offset(offset).limit(limit)
|
||||
)
|
||||
users = result.scalars().unique().all()
|
||||
|
||||
return UserListResponse(
|
||||
items=[_serialize_user(user) for user in users],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserResponse:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
return _serialize_user(user)
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user_endpoint(
|
||||
payload: UserCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserResponse:
|
||||
existing = await get_user_by_telegram_id(db, payload.telegram_id)
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User with this telegram_id already exists")
|
||||
|
||||
user = await create_user(
|
||||
db,
|
||||
telegram_id=payload.telegram_id,
|
||||
username=payload.username,
|
||||
first_name=payload.first_name,
|
||||
last_name=payload.last_name,
|
||||
language=payload.language,
|
||||
referred_by_id=payload.referred_by_id,
|
||||
)
|
||||
|
||||
if payload.promo_group_id and payload.promo_group_id != user.promo_group_id:
|
||||
promo_group = await get_promo_group_by_id(db, payload.promo_group_id)
|
||||
if not promo_group:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found")
|
||||
user = await update_user(db, user, promo_group_id=promo_group.id)
|
||||
|
||||
user = await get_user_by_id(db, user.id)
|
||||
return _serialize_user(user)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserResponse)
|
||||
async def update_user_endpoint(
|
||||
user_id: int,
|
||||
payload: UserUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserResponse:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
updates: dict[str, Any] = {}
|
||||
|
||||
if payload.username is not None:
|
||||
updates["username"] = payload.username
|
||||
if payload.first_name is not None:
|
||||
updates["first_name"] = payload.first_name
|
||||
if payload.last_name is not None:
|
||||
updates["last_name"] = payload.last_name
|
||||
if payload.language is not None:
|
||||
updates["language"] = payload.language
|
||||
if payload.has_had_paid_subscription is not None:
|
||||
updates["has_had_paid_subscription"] = payload.has_had_paid_subscription
|
||||
if payload.has_made_first_topup is not None:
|
||||
updates["has_made_first_topup"] = payload.has_made_first_topup
|
||||
|
||||
if payload.status is not None:
|
||||
try:
|
||||
status_value = UserStatus(payload.status).value
|
||||
except ValueError as error:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid status") from error
|
||||
updates["status"] = status_value
|
||||
|
||||
if payload.promo_group_id is not None:
|
||||
promo_group = await get_promo_group_by_id(db, payload.promo_group_id)
|
||||
if not promo_group:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found")
|
||||
updates["promo_group_id"] = promo_group.id
|
||||
|
||||
if payload.referral_code is not None and payload.referral_code != user.referral_code:
|
||||
existing_code_owner = await get_user_by_referral_code(db, payload.referral_code)
|
||||
if existing_code_owner and existing_code_owner.id != user.id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Referral code already in use")
|
||||
updates["referral_code"] = payload.referral_code
|
||||
|
||||
if not updates:
|
||||
return _serialize_user(user)
|
||||
|
||||
user = await update_user(db, user, **updates)
|
||||
user = await get_user_by_id(db, user.id)
|
||||
return _serialize_user(user)
|
||||
|
||||
|
||||
@router.post("/{user_id}/balance", response_model=UserResponse)
|
||||
async def update_balance(
|
||||
user_id: int,
|
||||
payload: BalanceUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserResponse:
|
||||
if payload.amount_kopeks == 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Amount must be non-zero")
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks=payload.amount_kopeks,
|
||||
description=payload.description or "Корректировка через веб-API",
|
||||
create_transaction=payload.create_transaction,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to update balance")
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
return _serialize_user(user)
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BackupCreateResponse(BaseModel):
|
||||
task_id: str
|
||||
status: str = Field(..., description="Текущий статус задачи")
|
||||
|
||||
|
||||
class BackupInfo(BaseModel):
|
||||
filename: str
|
||||
filepath: str
|
||||
timestamp: Optional[datetime] = None
|
||||
tables_count: Optional[int] = None
|
||||
total_records: Optional[int] = None
|
||||
compressed: bool
|
||||
file_size_bytes: int
|
||||
file_size_mb: float
|
||||
created_by: Optional[int] = None
|
||||
database_type: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class BackupListResponse(BaseModel):
|
||||
items: list[BackupInfo]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class BackupStatusResponse(BaseModel):
|
||||
task_id: str
|
||||
status: str
|
||||
message: Optional[str] = None
|
||||
file_path: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Полный путь до созданного бекапа, если задача завершена",
|
||||
)
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class BackupTaskInfo(BackupStatusResponse):
|
||||
pass
|
||||
|
||||
|
||||
class BackupTaskListResponse(BaseModel):
|
||||
items: list[BackupTaskInfo]
|
||||
total: int
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from app.keyboards.admin import BROADCAST_BUTTONS, DEFAULT_BROADCAST_BUTTONS
|
||||
|
||||
|
||||
class BroadcastMedia(BaseModel):
|
||||
type: str = Field(pattern=r"^(photo|video|document)$")
|
||||
file_id: str
|
||||
caption: Optional[str] = None
|
||||
|
||||
|
||||
class BroadcastCreateRequest(BaseModel):
|
||||
target: str
|
||||
message_text: str = Field(..., min_length=1, max_length=4000)
|
||||
selected_buttons: list[str] = Field(
|
||||
default_factory=lambda: list(DEFAULT_BROADCAST_BUTTONS)
|
||||
)
|
||||
media: Optional[BroadcastMedia] = None
|
||||
|
||||
_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: str) -> str:
|
||||
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")
|
||||
|
||||
@validator("selected_buttons", pre=True)
|
||||
def validate_selected_buttons(cls, value):
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, (list, tuple)):
|
||||
raise TypeError("selected_buttons must be an array")
|
||||
|
||||
seen = set()
|
||||
ordered: list[str] = []
|
||||
for raw_button in value:
|
||||
button = str(raw_button).strip()
|
||||
if not button:
|
||||
continue
|
||||
if button not in BROADCAST_BUTTONS:
|
||||
raise ValueError(f"Unsupported button '{button}'")
|
||||
if button in seen:
|
||||
continue
|
||||
ordered.append(button)
|
||||
seen.add(button)
|
||||
return ordered
|
||||
|
||||
|
||||
class BroadcastResponse(BaseModel):
|
||||
id: int
|
||||
target_type: str
|
||||
message_text: str
|
||||
has_media: bool
|
||||
media_type: Optional[str] = None
|
||||
media_file_id: Optional[str] = None
|
||||
media_caption: Optional[str] = None
|
||||
total_count: int
|
||||
sent_count: int
|
||||
failed_count: int
|
||||
status: str
|
||||
admin_id: Optional[int] = None
|
||||
admin_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class BroadcastListResponse(BaseModel):
|
||||
items: list[BroadcastResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
CampaignBonusType = Annotated[Literal["balance", "subscription"], Field(description="Тип бонуса кампании")]
|
||||
|
||||
|
||||
class CampaignBase(BaseModel):
|
||||
name: str = Field(..., max_length=255)
|
||||
start_parameter: str = Field(..., max_length=64, description="Start parameter для deep-link (уникальный)")
|
||||
bonus_type: CampaignBonusType
|
||||
balance_bonus_kopeks: int = Field(0, ge=0)
|
||||
subscription_duration_days: Optional[int] = Field(None, ge=0)
|
||||
subscription_traffic_gb: Optional[int] = Field(None, ge=0)
|
||||
subscription_device_limit: Optional[int] = Field(None, ge=0)
|
||||
subscription_squads: list[str] = Field(default_factory=list)
|
||||
|
||||
@validator("name", "start_parameter")
|
||||
def strip_strings(cls, value: str) -> str: # noqa: D401,B902
|
||||
return value.strip()
|
||||
|
||||
|
||||
class CampaignCreateRequest(CampaignBase):
|
||||
is_active: bool = True
|
||||
|
||||
@validator("balance_bonus_kopeks")
|
||||
def validate_balance_bonus(cls, value: int, values: dict) -> int: # noqa: D401,B902
|
||||
if values.get("bonus_type") == "balance" and value <= 0:
|
||||
raise ValueError("balance_bonus_kopeks must be positive for balance bonus")
|
||||
return value
|
||||
|
||||
@validator("subscription_duration_days")
|
||||
def validate_subscription_bonus(cls, value: Optional[int], values: dict): # noqa: D401,B902
|
||||
if values.get("bonus_type") == "subscription":
|
||||
if value is None or value <= 0:
|
||||
raise ValueError("subscription_duration_days must be positive for subscription bonus")
|
||||
return value
|
||||
|
||||
|
||||
class CampaignResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
start_parameter: str
|
||||
bonus_type: CampaignBonusType
|
||||
balance_bonus_kopeks: int
|
||||
balance_bonus_rubles: float
|
||||
subscription_duration_days: Optional[int] = None
|
||||
subscription_traffic_gb: Optional[int] = None
|
||||
subscription_device_limit: Optional[int] = None
|
||||
subscription_squads: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
registrations_count: int = 0
|
||||
|
||||
|
||||
class CampaignListResponse(BaseModel):
|
||||
items: list[CampaignResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class CampaignUpdateRequest(BaseModel):
|
||||
name: Optional[str] = Field(None, max_length=255)
|
||||
start_parameter: Optional[str] = Field(None, max_length=64)
|
||||
bonus_type: Optional[CampaignBonusType] = None
|
||||
balance_bonus_kopeks: Optional[int] = Field(None, ge=0)
|
||||
subscription_duration_days: Optional[int] = Field(None, ge=0)
|
||||
subscription_traffic_gb: Optional[int] = Field(None, ge=0)
|
||||
subscription_device_limit: Optional[int] = Field(None, ge=0)
|
||||
subscription_squads: Optional[list[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@validator("name", "start_parameter", pre=True)
|
||||
def strip_optional_strings(cls, value: Optional[str]): # noqa: D401,B902
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return value
|
||||
|
||||
@validator("balance_bonus_kopeks")
|
||||
def validate_balance_bonus(cls, value: Optional[int], values: dict): # noqa: D401,B902
|
||||
bonus_type = values.get("bonus_type")
|
||||
if bonus_type == "balance" and value is not None and value <= 0:
|
||||
raise ValueError("balance_bonus_kopeks must be positive for balance bonus")
|
||||
return value
|
||||
|
||||
@validator("subscription_duration_days")
|
||||
def validate_subscription_bonus(cls, value: Optional[int], values: dict): # noqa: D401,B902
|
||||
bonus_type = values.get("bonus_type")
|
||||
if bonus_type == "subscription" and value is not None and value <= 0:
|
||||
raise ValueError("subscription_duration_days must be positive for subscription bonus")
|
||||
return value
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SettingCategorySummary(BaseModel):
|
||||
"""Краткое описание категории настройки."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
items: int
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingCategoryRef(BaseModel):
|
||||
"""Ссылка на категорию, к которой относится настройка."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingChoice(BaseModel):
|
||||
"""Вариант значения для настройки с выбором."""
|
||||
|
||||
value: Any
|
||||
label: str
|
||||
description: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingDefinition(BaseModel):
|
||||
"""Полное описание настройки и её текущего состояния."""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
category: SettingCategoryRef
|
||||
type: str
|
||||
is_optional: bool
|
||||
current: Any | None = Field(default=None)
|
||||
original: Any | None = Field(default=None)
|
||||
has_override: bool
|
||||
choices: list[SettingChoice] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingUpdateRequest(BaseModel):
|
||||
"""Запрос на обновление значения настройки."""
|
||||
|
||||
value: Any
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class HealthFeatureFlags(BaseModel):
|
||||
"""Флаги доступности функций административного API."""
|
||||
|
||||
monitoring: bool
|
||||
maintenance: bool
|
||||
reporting: bool
|
||||
webhooks: bool
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class HealthCheckResponse(BaseModel):
|
||||
"""Ответ на health-check административного API."""
|
||||
|
||||
status: str
|
||||
api_version: str
|
||||
bot_version: str | None
|
||||
features: HealthFeatureFlags
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
def _normalize_period_discounts(value: Optional[Dict[object, object]]) -> Optional[Dict[int, int]]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
normalized: Dict[int, int] = {}
|
||||
if isinstance(value, dict):
|
||||
for raw_key, raw_value in value.items():
|
||||
try:
|
||||
key = int(raw_key)
|
||||
normalized[key] = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
return normalized or None
|
||||
|
||||
|
||||
class PromoGroupResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
server_discount_percent: int
|
||||
traffic_discount_percent: int
|
||||
device_discount_percent: int
|
||||
period_discounts: Dict[int, int] = Field(default_factory=dict)
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None
|
||||
apply_discounts_to_addons: bool
|
||||
is_default: bool
|
||||
members_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class _PromoGroupBase(BaseModel):
|
||||
period_discounts: Optional[Dict[int, int]] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Словарь скидок по длительности подписки. Ключ — количество месяцев, "
|
||||
"значение — скидка в процентах. Например: {1: 10, 6: 20}."
|
||||
),
|
||||
example={1: 10, 6: 20},
|
||||
)
|
||||
|
||||
@validator("period_discounts", pre=True)
|
||||
def validate_period_discounts(cls, value): # noqa: D401,B902
|
||||
return _normalize_period_discounts(value)
|
||||
|
||||
|
||||
class PromoGroupCreateRequest(_PromoGroupBase):
|
||||
name: str
|
||||
server_discount_percent: int = 0
|
||||
traffic_discount_percent: int = 0
|
||||
device_discount_percent: int = 0
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None
|
||||
apply_discounts_to_addons: bool = True
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class PromoGroupUpdateRequest(_PromoGroupBase):
|
||||
name: Optional[str] = None
|
||||
server_discount_percent: Optional[int] = None
|
||||
traffic_discount_percent: Optional[int] = None
|
||||
device_discount_percent: Optional[int] = None
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None
|
||||
apply_discounts_to_addons: Optional[bool] = None
|
||||
is_default: Optional[bool] = None
|
||||
|
||||
|
||||
class PromoGroupListResponse(BaseModel):
|
||||
items: list[PromoGroupResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.database.models import PromoCodeType
|
||||
|
||||
|
||||
class PromoCodeResponse(BaseModel):
|
||||
id: int
|
||||
code: str
|
||||
type: PromoCodeType
|
||||
balance_bonus_kopeks: int
|
||||
balance_bonus_rubles: float
|
||||
subscription_days: int
|
||||
max_uses: int
|
||||
current_uses: int
|
||||
uses_left: int
|
||||
is_active: bool
|
||||
is_valid: bool
|
||||
valid_from: datetime
|
||||
valid_until: Optional[datetime] = None
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PromoCodeListResponse(BaseModel):
|
||||
items: list[PromoCodeResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PromoCodeCreateRequest(BaseModel):
|
||||
code: str
|
||||
type: PromoCodeType
|
||||
balance_bonus_kopeks: int = 0
|
||||
subscription_days: int = 0
|
||||
max_uses: int = Field(default=1, ge=0)
|
||||
valid_from: Optional[datetime] = None
|
||||
valid_until: Optional[datetime] = None
|
||||
is_active: bool = True
|
||||
created_by: Optional[int] = None
|
||||
|
||||
|
||||
class PromoCodeUpdateRequest(BaseModel):
|
||||
code: Optional[str] = None
|
||||
type: Optional[PromoCodeType] = None
|
||||
balance_bonus_kopeks: Optional[int] = None
|
||||
subscription_days: Optional[int] = None
|
||||
max_uses: Optional[int] = Field(default=None, ge=0)
|
||||
valid_from: Optional[datetime] = None
|
||||
valid_until: Optional[datetime] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class PromoCodeRecentUse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
user_username: Optional[str] = None
|
||||
user_full_name: Optional[str] = None
|
||||
user_telegram_id: Optional[int] = None
|
||||
used_at: datetime
|
||||
|
||||
|
||||
class PromoCodeDetailResponse(PromoCodeResponse):
|
||||
total_uses: int
|
||||
today_uses: int
|
||||
recent_uses: list[PromoCodeRecentUse] = Field(default_factory=list)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RemnaWaveConnectionStatus(BaseModel):
|
||||
status: str
|
||||
message: str
|
||||
api_url: Optional[str] = None
|
||||
status_code: Optional[int] = None
|
||||
system_info: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class RemnaWaveStatusResponse(BaseModel):
|
||||
is_configured: bool
|
||||
configuration_error: Optional[str] = None
|
||||
connection: Optional[RemnaWaveConnectionStatus] = None
|
||||
|
||||
|
||||
class RemnaWaveNode(BaseModel):
|
||||
uuid: str
|
||||
name: str
|
||||
address: str
|
||||
country_code: Optional[str] = None
|
||||
is_connected: bool
|
||||
is_disabled: bool
|
||||
is_node_online: bool
|
||||
is_xray_running: bool
|
||||
users_online: Optional[int] = None
|
||||
traffic_used_bytes: Optional[int] = None
|
||||
traffic_limit_bytes: Optional[int] = None
|
||||
|
||||
|
||||
class RemnaWaveNodeListResponse(BaseModel):
|
||||
items: List[RemnaWaveNode]
|
||||
total: int
|
||||
|
||||
|
||||
class RemnaWaveNodeActionRequest(BaseModel):
|
||||
action: Literal["enable", "disable", "restart"]
|
||||
|
||||
|
||||
class RemnaWaveNodeActionResponse(BaseModel):
|
||||
success: bool
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
class RemnaWaveNodeStatisticsResponse(BaseModel):
|
||||
node: RemnaWaveNode
|
||||
realtime: Optional[Dict[str, Any]] = None
|
||||
usage_history: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
last_updated: Optional[datetime] = None
|
||||
|
||||
|
||||
class RemnaWaveNodeUsageResponse(BaseModel):
|
||||
items: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RemnaWaveBandwidth(BaseModel):
|
||||
realtime_download: int
|
||||
realtime_upload: int
|
||||
realtime_total: int
|
||||
|
||||
|
||||
class RemnaWaveTrafficPeriod(BaseModel):
|
||||
current: int
|
||||
previous: int
|
||||
difference: Optional[str] = None
|
||||
|
||||
|
||||
class RemnaWaveTrafficPeriods(BaseModel):
|
||||
last_2_days: RemnaWaveTrafficPeriod
|
||||
last_7_days: RemnaWaveTrafficPeriod
|
||||
last_30_days: RemnaWaveTrafficPeriod
|
||||
current_month: RemnaWaveTrafficPeriod
|
||||
current_year: RemnaWaveTrafficPeriod
|
||||
|
||||
|
||||
class RemnaWaveSystemSummary(BaseModel):
|
||||
users_online: int
|
||||
total_users: int
|
||||
active_connections: int
|
||||
nodes_online: int
|
||||
users_last_day: int
|
||||
users_last_week: int
|
||||
users_never_online: int
|
||||
total_user_traffic: int
|
||||
|
||||
|
||||
class RemnaWaveServerInfo(BaseModel):
|
||||
cpu_cores: int
|
||||
cpu_physical_cores: int
|
||||
memory_total: int
|
||||
memory_used: int
|
||||
memory_free: int
|
||||
memory_available: int
|
||||
uptime_seconds: int
|
||||
|
||||
|
||||
class RemnaWaveSystemStatsResponse(BaseModel):
|
||||
system: RemnaWaveSystemSummary
|
||||
users_by_status: Dict[str, int]
|
||||
server_info: RemnaWaveServerInfo
|
||||
bandwidth: RemnaWaveBandwidth
|
||||
traffic_periods: RemnaWaveTrafficPeriods
|
||||
nodes_realtime: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
nodes_weekly: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
last_updated: Optional[datetime] = None
|
||||
|
||||
|
||||
class RemnaWaveSquad(BaseModel):
|
||||
uuid: str
|
||||
name: str
|
||||
members_count: int
|
||||
inbounds_count: int
|
||||
inbounds: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RemnaWaveSquadListResponse(BaseModel):
|
||||
items: List[RemnaWaveSquad]
|
||||
total: int
|
||||
|
||||
|
||||
class RemnaWaveSquadCreateRequest(BaseModel):
|
||||
name: str
|
||||
inbound_uuids: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RemnaWaveSquadUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
inbound_uuids: Optional[List[str]] = None
|
||||
|
||||
|
||||
class RemnaWaveSquadActionRequest(BaseModel):
|
||||
action: Literal["add_all_users", "remove_all_users", "delete", "rename", "update_inbounds"]
|
||||
name: Optional[str] = None
|
||||
inbound_uuids: Optional[List[str]] = None
|
||||
|
||||
|
||||
class RemnaWaveOperationResponse(BaseModel):
|
||||
success: bool
|
||||
detail: Optional[str] = None
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class RemnaWaveInboundsResponse(BaseModel):
|
||||
items: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RemnaWaveUserTrafficResponse(BaseModel):
|
||||
telegram_id: int
|
||||
used_traffic_bytes: int
|
||||
used_traffic_gb: float
|
||||
lifetime_used_traffic_bytes: int
|
||||
lifetime_used_traffic_gb: float
|
||||
traffic_limit_bytes: int
|
||||
traffic_limit_gb: float
|
||||
subscription_url: Optional[str] = None
|
||||
|
||||
|
||||
class RemnaWaveSyncFromPanelRequest(BaseModel):
|
||||
mode: Literal["all", "new_only", "update_only"] = "all"
|
||||
|
||||
|
||||
class RemnaWaveGenericSyncResponse(BaseModel):
|
||||
success: bool
|
||||
detail: Optional[str] = None
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SubscriptionResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
status: str
|
||||
actual_status: str
|
||||
is_trial: bool
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
traffic_limit_gb: int
|
||||
traffic_used_gb: float
|
||||
device_limit: int
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: int
|
||||
subscription_url: Optional[str] = None
|
||||
subscription_crypto_link: Optional[str] = None
|
||||
connected_squads: List[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class SubscriptionCreateRequest(BaseModel):
|
||||
user_id: int
|
||||
is_trial: bool = False
|
||||
duration_days: Optional[int] = None
|
||||
traffic_limit_gb: Optional[int] = None
|
||||
device_limit: Optional[int] = None
|
||||
squad_uuid: Optional[str] = None
|
||||
connected_squads: Optional[List[str]] = None
|
||||
|
||||
|
||||
class SubscriptionExtendRequest(BaseModel):
|
||||
days: int = Field(..., gt=0)
|
||||
|
||||
|
||||
class SubscriptionTrafficRequest(BaseModel):
|
||||
gb: int = Field(..., gt=0)
|
||||
|
||||
|
||||
class SubscriptionDevicesRequest(BaseModel):
|
||||
devices: int = Field(..., gt=0)
|
||||
|
||||
|
||||
class SubscriptionSquadRequest(BaseModel):
|
||||
squad_uuid: str
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TicketMessageResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
message_text: str
|
||||
is_from_admin: bool
|
||||
has_media: bool
|
||||
media_type: Optional[str] = None
|
||||
media_caption: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TicketResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
title: str
|
||||
status: str
|
||||
priority: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: Optional[datetime] = None
|
||||
user_reply_block_permanent: bool
|
||||
user_reply_block_until: Optional[datetime] = None
|
||||
messages: List[TicketMessageResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TicketStatusUpdateRequest(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class TicketPriorityUpdateRequest(BaseModel):
|
||||
priority: str
|
||||
|
||||
|
||||
class TicketReplyBlockRequest(BaseModel):
|
||||
permanent: bool = False
|
||||
until: Optional[datetime] = None
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
prefix: str = Field(..., description="Первые символы токена для идентификации")
|
||||
description: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
last_used_at: Optional[datetime] = None
|
||||
last_used_ip: Optional[str] = None
|
||||
created_by: Optional[str] = None
|
||||
|
||||
|
||||
class TokenCreateRequest(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TokenCreateResponse(TokenResponse):
|
||||
token: str = Field(..., description="Полное значение токена (возвращается один раз)")
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TransactionResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
type: str
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
description: Optional[str] = None
|
||||
payment_method: Optional[str] = None
|
||||
external_id: Optional[str] = None
|
||||
is_completed: bool
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TransactionListResponse(BaseModel):
|
||||
items: list[TransactionResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PromoGroupSummary(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
server_discount_percent: int
|
||||
traffic_discount_percent: int
|
||||
device_discount_percent: int
|
||||
apply_discounts_to_addons: bool = True
|
||||
|
||||
|
||||
class SubscriptionSummary(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
actual_status: str
|
||||
is_trial: bool
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
traffic_limit_gb: int
|
||||
traffic_used_gb: float
|
||||
device_limit: int
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: int
|
||||
subscription_url: Optional[str] = None
|
||||
subscription_crypto_link: Optional[str] = None
|
||||
connected_squads: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
status: str
|
||||
language: str
|
||||
balance_kopeks: int
|
||||
balance_rubles: float
|
||||
referral_code: Optional[str] = None
|
||||
referred_by_id: Optional[int] = None
|
||||
has_had_paid_subscription: bool
|
||||
has_made_first_topup: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
last_activity: Optional[datetime] = None
|
||||
promo_group: Optional[PromoGroupSummary] = None
|
||||
subscription: Optional[SubscriptionSummary] = None
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
items: List[UserResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
language: str = "ru"
|
||||
referred_by_id: Optional[int] = None
|
||||
promo_group_id: Optional[int] = None
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
promo_group_id: Optional[int] = None
|
||||
referral_code: Optional[str] = None
|
||||
has_had_paid_subscription: Optional[bool] = None
|
||||
has_made_first_topup: Optional[bool] = None
|
||||
|
||||
|
||||
class BalanceUpdateRequest(BaseModel):
|
||||
amount_kopeks: int
|
||||
description: Optional[str] = Field(default="Корректировка через веб-API")
|
||||
create_transaction: bool = True
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import uvicorn
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from .app import create_web_api_app
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebAPIServer:
|
||||
"""Асинхронный uvicorn-сервер для административного API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._app = create_web_api_app()
|
||||
|
||||
workers = max(1, int(settings.WEB_API_WORKERS or 1))
|
||||
if workers > 1:
|
||||
logger.warning("WEB_API_WORKERS > 1 не поддерживается в embed-режиме, используем 1")
|
||||
workers = 1
|
||||
|
||||
self._config = uvicorn.Config(
|
||||
app=self._app,
|
||||
host=settings.WEB_API_HOST,
|
||||
port=int(settings.WEB_API_PORT or 8080),
|
||||
log_level=settings.LOG_LEVEL.lower(),
|
||||
workers=workers,
|
||||
lifespan="on",
|
||||
)
|
||||
self._server = uvicorn.Server(self._config)
|
||||
self._task: Optional[asyncio.Task[None]] = None
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
logger.info("🌐 Административное веб-API уже запущено")
|
||||
return
|
||||
|
||||
async def _serve() -> None:
|
||||
try:
|
||||
await self._server.serve()
|
||||
except Exception as error: # pragma: no cover - логируем ошибки сервера
|
||||
logger.exception("❌ Ошибка работы веб-API: %s", error)
|
||||
|
||||
logger.info(
|
||||
"🌐 Запуск административного API на %s:%s",
|
||||
settings.WEB_API_HOST,
|
||||
settings.WEB_API_PORT,
|
||||
)
|
||||
self._task = asyncio.create_task(_serve(), name="web-api-server")
|
||||
|
||||
started_attr = getattr(self._server, "started", None)
|
||||
started_event = getattr(self._server, "started_event", None)
|
||||
|
||||
if isinstance(started_attr, asyncio.Event):
|
||||
await started_attr.wait()
|
||||
elif isinstance(started_event, asyncio.Event):
|
||||
await started_event.wait()
|
||||
else:
|
||||
while not getattr(self._server, "started", False):
|
||||
if self._task.done():
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if self._task.done() and self._task.exception():
|
||||
raise self._task.exception()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._task:
|
||||
return
|
||||
|
||||
logger.info("🛑 Остановка административного API")
|
||||
self._server.should_exit = True
|
||||
await self._task
|
||||
self._task = None
|
||||
@@ -72,6 +72,7 @@ services:
|
||||
# Логотип для сообщений
|
||||
- ./vpn_logo.png:/app/vpn_logo.png:ro
|
||||
ports:
|
||||
- "${WEB_API_PORT:-8080}:8080"
|
||||
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
|
||||
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
|
||||
networks:
|
||||
|
||||
@@ -72,6 +72,7 @@ services:
|
||||
# Логотип для сообщений
|
||||
- ./vpn_logo.png:/app/vpn_logo.png:ro
|
||||
ports:
|
||||
- "${WEB_API_PORT:-8080}:8080"
|
||||
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
|
||||
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
|
||||
networks:
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Интеграция веб-админки
|
||||
|
||||
Этот документ описывает запуск встроенного административного веб-API бота и типовой сценарий интеграции c внешней веб-админкой.
|
||||
API разворачивается вместе с ботом, использует FastAPI и защищено токенами доступа.
|
||||
|
||||
## 1. Обзор архитектуры
|
||||
|
||||
- Веб-API запускается в том же процессе, что и бот, через встроенный `uvicorn` сервер.
|
||||
- Авторизация выполняется по токену: `X-API-Key` или `Authorization: Bearer <token>`.
|
||||
- Все эндпоинты работают поверх HTTPS/HTTP и возвращают структуры в формате JSON.
|
||||
- Встроенный механизм миграций создаёт таблицу `web_api_tokens` и бутстрап-токен, если указан в конфигурации.
|
||||
|
||||
## 2. Настройка окружения
|
||||
|
||||
Добавьте переменные в `.env` (или другую систему конфигурации):
|
||||
|
||||
| Переменная | Назначение | Значение по умолчанию / пример |
|
||||
|------------|------------|---------------------------------|
|
||||
| `WEB_API_ENABLED` | Включает веб-API. | `true`
|
||||
| `WEB_API_HOST` | IP/hostname, на котором слушает API. | `0.0.0.0`
|
||||
| `WEB_API_PORT` | Порт веб-API. | `8080`
|
||||
| `WEB_API_ALLOWED_ORIGINS` | Список доменов для CORS, через запятую. `*` разрешит всё. | `https://admin.example.com`
|
||||
| `WEB_API_DOCS_ENABLED` | Включить `/docs` и `/openapi.json`. В проде лучше `false`. | `false`
|
||||
| `WEB_API_WORKERS` | Количество воркеров uvicorn. В embed-режиме всегда приводится к `1`. | `1`
|
||||
| `WEB_API_REQUEST_LOGGING` | Логировать каждый запрос API. | `true`
|
||||
| `WEB_API_DEFAULT_TOKEN` | Бутстрап-токен, который будет создан при миграции. | `super-secret-token`
|
||||
| `WEB_API_DEFAULT_TOKEN_NAME` | Отображаемое имя созданного токена. | `Bootstrap Token`
|
||||
| `WEB_API_TOKEN_HASH_ALGORITHM` | Алгоритм хеширования токенов (`sha256`, `sha512`, ...). | `sha256`
|
||||
|
||||
> ⚠️ Если вы храните конфигурацию в Kubernetes/Ansible/других системах — не забудьте обновить секреты, чтобы бот видел эти переменные.
|
||||
|
||||
### Включение Swagger (интерактивной документации)
|
||||
|
||||
Чтобы открыть интерфейс Swagger UI на `/docs`, убедитесь, что одновременно заданы две переменные окружения:
|
||||
|
||||
1. `WEB_API_ENABLED=true` — включает само веб-API.
|
||||
2. `WEB_API_DOCS_ENABLED=true` — публикует `/docs`, `/redoc` и `/openapi.json`.
|
||||
|
||||
После изменения значений перезапустите бота. Интерфейс будет доступен по адресу `http://<WEB_API_HOST>:<WEB_API_PORT>/docs`.
|
||||
|
||||
## 3. Подготовка базы данных
|
||||
|
||||
1. Убедитесь, что настройки БД верны (`DATABASE_URL` или параметры PostgreSQL/SQLite).
|
||||
2. При старте бота автоматически запускается универсальная миграция `run_universal_migration`, которая:
|
||||
- создаёт таблицу `web_api_tokens`, если её нет;
|
||||
- активирует токен из `WEB_API_DEFAULT_TOKEN`, если он задан.
|
||||
3. Если нужно запустить миграцию вручную, выполните:
|
||||
|
||||
```bash
|
||||
python -c "import asyncio; from app.database.universal_migration import run_universal_migration; asyncio.run(run_universal_migration())"
|
||||
```
|
||||
|
||||
Или просто запустите `python main.py` — бот выполнит ту же процедуру автоматически.
|
||||
|
||||
## 4. Запуск веб-API
|
||||
|
||||
```bash
|
||||
# Создаём .env и включаем веб-API
|
||||
cp .env.example .env
|
||||
nano .env # проставьте WEB_API_* переменные и BOT_TOKEN
|
||||
|
||||
# Запускаем бота (локально)
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
В Docker достаточно пробросить порт `WEB_API_PORT` из контейнера бота. После запуска API будет доступно по адресу `http://<WEB_API_HOST>:<WEB_API_PORT>`.
|
||||
|
||||
## 5. Аутентификация и токены
|
||||
|
||||
- Первый токен удобно задать через `WEB_API_DEFAULT_TOKEN`. Он появится в таблице при запуске миграции и будет автоматически
|
||||
пересоздан/активирован после изменения значения через интерфейс настроек.
|
||||
- Для управления токенами используйте эндпоинты `/tokens`:
|
||||
- `GET /tokens` — список токенов.
|
||||
- `POST /tokens` — создать новый токен. Возвращает открытое значение один раз.
|
||||
- `POST /tokens/{id}/revoke` и `/activate` — управление статусом.
|
||||
- `DELETE /tokens/{id}` — удаление.
|
||||
- Заголовок авторизации можно передавать двумя способами:
|
||||
|
||||
```http
|
||||
X-API-Key: <ваш_токен>
|
||||
# или
|
||||
Authorization: Bearer <ваш_токен>
|
||||
```
|
||||
|
||||
Пример запроса на создание токена:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8080/tokens" \
|
||||
-H "X-API-Key: super-secret-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Web admin", "description": "UI token"}'
|
||||
```
|
||||
|
||||
## 6. Основные эндпоинты
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
|-------|------|------------|
|
||||
| `GET` | `/health` | Статус API, версия бота, флаги включённых сервисов.
|
||||
| `GET` | `/stats/overview` | Сводная статистика по пользователям, подпискам, платежам и тикетам.
|
||||
| `GET` | `/settings/categories` | Категории системных настроек.
|
||||
| `GET` | `/settings` | Полный список настроек (с текущими и дефолтными значениями).
|
||||
| `GET` | `/settings/{key}` | Получить одну настройку.
|
||||
| `PUT` | `/settings/{key}` | Обновить значение настройки.
|
||||
| `DELETE` | `/settings/{key}` | Сбросить настройку к значению по умолчанию.
|
||||
| `GET` | `/users` | Список пользователей с фильтрами и пагинацией.
|
||||
| `GET` | `/users/{id}` | Детали пользователя.
|
||||
| `POST` | `/users` | Создать пользователя (например, для ручной выдачи доступа).
|
||||
| `PATCH` | `/users/{id}` | Обновить профиль пользователя или статус.
|
||||
| `POST` | `/users/{id}/balance` | Корректировка баланса с созданием транзакции.
|
||||
| `GET` | `/subscriptions` | Список подписок с фильтрами.
|
||||
| `POST` | `/subscriptions` | Создать триальную или платную подписку.
|
||||
| `POST` | `/subscriptions/{id}/extend` | Продлить подписку на N дней.
|
||||
| `POST` | `/subscriptions/{id}/traffic` | Добавить трафик (ГБ).
|
||||
| `POST` | `/subscriptions/{id}/devices` | Добавить устройства.
|
||||
| `POST` | `/subscriptions/{id}/squads` | Привязать сквад.
|
||||
| `DELETE` | `/subscriptions/{id}/squads/{uuid}` | Удалить сквад.
|
||||
| `GET` | `/transactions` | История транзакций.
|
||||
| `GET` | `/tickets` | Список тикетов поддержки.
|
||||
| `GET` | `/tickets/{id}` | Тикет с перепиской.
|
||||
| `POST` | `/tickets/{id}/status` | Изменить статус тикета.
|
||||
| `POST` | `/tickets/{id}/priority` | Изменить приоритет.
|
||||
| `POST` | `/tickets/{id}/reply-block` | Заблокировать ответы пользователя.
|
||||
| `DELETE` | `/tickets/{id}/reply-block` | Снять блокировку.
|
||||
| `GET` | `/promo-groups` | Список промо-групп с количеством участников.
|
||||
| `POST` | `/promo-groups` | Создать промо-группу.
|
||||
| `PATCH` | `/promo-groups/{id}` | Обновить промо-группу.
|
||||
| `DELETE` | `/promo-groups/{id}` | Удалить промо-группу.
|
||||
| `GET` | `/tokens` | Управление токенами доступа.
|
||||
|
||||
### RemnaWave интеграция
|
||||
|
||||
После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота:
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
|-------|------|------------|
|
||||
| `GET` | `/remnawave/status` | Проверка конфигурации и доступности RemnaWave API. |
|
||||
| `GET` | `/remnawave/system` | Агрегированная статистика по пользователям, нодам и трафику. |
|
||||
| `GET` | `/remnawave/nodes` | Список нод и их текущее состояние. |
|
||||
| `GET` | `/remnawave/nodes/realtime` | Текущая загрузка нод (realtime-метрики RemnaWave). |
|
||||
| `GET` | `/remnawave/nodes/{uuid}` | Детальная информация по конкретной ноде. |
|
||||
| `GET` | `/remnawave/nodes/{uuid}/statistics` | Агрегированная статистика и история нагрузок по ноде. |
|
||||
| `GET` | `/remnawave/nodes/{uuid}/usage` | История использования ноды пользователями за выбранный период. |
|
||||
| `POST` | `/remnawave/nodes/{uuid}/actions` | Включение, отключение или перезапуск ноды. |
|
||||
| `POST` | `/remnawave/nodes/restart` | Массовый перезапуск всех нод в RemnaWave. |
|
||||
| `GET` | `/remnawave/squads` | Список внутренних сквадов с составом и статистикой. |
|
||||
| `GET` | `/remnawave/squads/{uuid}` | Детали выбранного сквада. |
|
||||
| `POST` | `/remnawave/squads` | Создание нового сквада и привязка inbounds. |
|
||||
| `PATCH` | `/remnawave/squads/{uuid}` | Обновление имени или состава inbounds сквада. |
|
||||
| `POST` | `/remnawave/squads/{uuid}/actions` | Массовые операции: добавить/удалить всех, переименовать, обновить inbounds, удалить. |
|
||||
| `GET` | `/remnawave/inbounds` | Список доступных inbounds в панели RemnaWave. |
|
||||
| `GET` | `/remnawave/users/{telegram_id}/traffic` | Использование трафика конкретного пользователя RemnaWave. |
|
||||
| `POST` | `/remnawave/sync/from-panel` | Синхронизация пользователей и подписок из панели в бота. |
|
||||
| `POST` | `/remnawave/sync/to-panel` | Обратная синхронизация данных бота в панель. |
|
||||
| `POST` | `/remnawave/sync/subscriptions/validate` | Проверка и восстановление подписок в RemnaWave. |
|
||||
| `POST` | `/remnawave/sync/subscriptions/cleanup` | Очистка «осиротевших» подписок и пользователей в RemnaWave. |
|
||||
| `POST` | `/remnawave/sync/subscriptions/statuses` | Приведение статусов подписок в боте и панели к единому виду. |
|
||||
| `GET` | `/remnawave/sync/recommendations` | Рекомендации по синхронизации: что добавить, обновить или удалить. |
|
||||
|
||||
|
||||
> Все списковые эндпоинты поддерживают пагинацию (`limit`, `offset`) и фильтры, описанные в OpenAPI спецификации. Если `WEB_API_DOCS_ENABLED=true`, документация доступна по `/docs`. В ответах `/settings` поле `choices` всегда массив: пустой список означает отсутствие предопределённых значений.
|
||||
|
||||
## 7. Сценарий интеграции веб-админки
|
||||
|
||||
1. **Health-check** — перед авторизацией UI вызывает `GET /health`, чтобы отобразить статус и версию бота.
|
||||
2. **Настройки UI** — подгружает категории через `GET /settings/categories`, далее выводит форму со значениями из `GET /settings`.
|
||||
3. **Статистика дашборда** — `GET /stats/overview` для карточек с показателями.
|
||||
4. **Раздел пользователи** — `GET /users` с поиском (`search`), фильтрами по статусу или промо-группе. Для детальной карточки использовать `GET /users/{id}`.
|
||||
5. **Операции с подпиской** — использовать `POST /subscriptions/{id}/...` эндпоинты для продления, выдачи трафика и устройств.
|
||||
6. **Поддержка** — список тикетов (`GET /tickets`), изменение статуса (`POST /tickets/{id}/status`), блокировка ответов (`POST /tickets/{id}/reply-block`).
|
||||
7. **История операций** — `GET /transactions` с фильтрами по пользователю, типу и периоду.
|
||||
|
||||
## 8. CORS, безопасность и логирование
|
||||
|
||||
- Разрешённые домены указываются в `WEB_API_ALLOWED_ORIGINS`. Для нескольких доменов перечислите их через запятую.
|
||||
- Для продакшена рекомендуется отключить публичную документацию (`WEB_API_DOCS_ENABLED=false`).
|
||||
- `WEB_API_REQUEST_LOGGING=true` добавляет middleware, которое логирует метод, путь и статус ответа. Используйте его для аудита или отключите в продакшене, если хватает reverse-proxy логов.
|
||||
- Все токены хранятся в базе в хешированном виде. Не храните открытые значения в коде.
|
||||
|
||||
## 9. Диагностика проблем
|
||||
|
||||
| Симптом | Возможная причина | Что проверить |
|
||||
|---------|------------------|---------------|
|
||||
| 401 Unauthorized | Неверный или просроченный токен. | Пересоздайте токен через `/tokens` и обновите UI. |
|
||||
| 403/404 при работе с настройками | Неверный ключ настройки. | Получите список доступных ключей через `GET /settings`. |
|
||||
| 422 Unprocessable Entity | Неверный тип данных в теле запроса. | Проверьте типы (числа, булевы, строки) и формат JSON. |
|
||||
| API не стартует | Порт занят или неверные переменные окружения. | Проверьте логи контейнера бота и значения `WEB_API_HOST/PORT`. |
|
||||
|
||||
## 10. Рекомендации по эксплуатации
|
||||
|
||||
- Регулярно ревизируйте список активных токенов и отключайте неиспользуемые.
|
||||
- Для внешних админок размещайте API за reverse-proxy (nginx, Caddy, Traefik) с TLS.
|
||||
- Включите мониторинг доступности (например, curl на `/health`) в систему наблюдения.
|
||||
- Обновляйте бота и админку синхронно, чтобы использовать новые поля API.
|
||||
+12
-7
@@ -49,6 +49,7 @@
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
|
||||
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Insufficient funds</b>\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.",
|
||||
"LANGUAGE_PROMPT": "🌐 Choose the interface language:",
|
||||
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
|
||||
"LOADING": "⏳ Loading...",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
|
||||
@@ -69,7 +70,7 @@
|
||||
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable",
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
|
||||
"PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)",
|
||||
"PAYMENT_CARD_PAL24": "💳 Bank card (PayPalych)",
|
||||
"PAYMENT_CARD_PAL24": "🏦 SBP (PayPalych)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
|
||||
@@ -81,10 +82,10 @@
|
||||
"MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.",
|
||||
"MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay",
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Mulen Pay payment</b>\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with Mulen Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
||||
"PAL24_TOPUP_PROMPT": "💳 <b>PayPalych payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed by the secure PayPalych platform.",
|
||||
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
|
||||
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
|
||||
"PAL24_PAY_BUTTON": "💳 Pay with PayPalych",
|
||||
"PAL24_PAYMENT_INSTRUCTIONS": "💳 <b>PayPalych payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with PayPalych’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
||||
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
|
||||
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>PayPalych (SBP) payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
|
||||
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀",
|
||||
"REFERRAL_ANALYTICS_BUTTON": "📊 Analytics",
|
||||
@@ -412,14 +413,17 @@
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Connect subscription</b>\n\n📱 Tap the button below to open the app:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ The subscription link is available via the buttons below or in the “My subscription” section.",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Connect subscription</b>\n\n🔗 <b>Subscription link:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Connect subscription</b>\n\nℹ️ The subscription link is available via the buttons below or in the “My subscription” section.\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>Connect via Happ</b>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_LINK": "<a href=\"{subscription_link}\">🔓 Open link in Happ</a>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually: <code>{subscription_link}</code>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually:",
|
||||
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.",
|
||||
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Subscription link:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Step 1 - Install:</b>",
|
||||
@@ -498,6 +502,7 @@
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Balance top-up methods</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
|
||||
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||
@@ -506,8 +511,8 @@
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card (Mulen Pay)</b>",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "💳 <b>Bank card (PayPalych)</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via PayPalych",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||
|
||||
+12
-7
@@ -228,6 +228,7 @@
|
||||
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
|
||||
"INVALID_AMOUNT": "❌ Неверная сумма",
|
||||
"LANGUAGE_PROMPT": "🌐 Выберите язык интерфейса:",
|
||||
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
|
||||
"LOADING": "⏳ Загрузка...",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n",
|
||||
@@ -259,7 +260,7 @@
|
||||
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны",
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
|
||||
"PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)",
|
||||
"PAYMENT_CARD_PAL24": "💳 Банковская карта (PayPalych)",
|
||||
"PAYMENT_CARD_PAL24": "🏦 СБП (PayPalych)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
|
||||
@@ -271,10 +272,10 @@
|
||||
"MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
"MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay",
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Mulen Pay</b>\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"PAL24_TOPUP_PROMPT": "💳 <b>Оплата через PayPalych</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через защищенную платформу PayPalych.",
|
||||
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
|
||||
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
"PAL24_PAY_BUTTON": "💳 Оплатить через PayPalych",
|
||||
"PAL24_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через PayPalych</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
||||
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
|
||||
"PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
@@ -412,14 +413,17 @@
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🔗 <b>Ссылка подписки:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Подключить подписку</b>\n\nℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>Подключение через Happ</b>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_LINK": "<a href=\"{subscription_link}\">🔓 Открыть ссылку в Happ</a>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную: <code>{subscription_link}</code>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную:",
|
||||
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
|
||||
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Ссылка подписки:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Шаг 1 - Установка:</b>",
|
||||
@@ -498,6 +502,7 @@
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Способы пополнения баланса</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
|
||||
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||
@@ -506,8 +511,8 @@
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта (Mulen Pay)</b>",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "💳 <b>Банковская карта (PayPalych)</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через PayPalych",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.services.backup_service import backup_service
|
||||
from app.services.reporting_service import reporting_service
|
||||
from app.localization.loader import ensure_locale_templates
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.broadcast_service import broadcast_service
|
||||
|
||||
|
||||
class GracefulExit:
|
||||
@@ -63,6 +64,7 @@ async def main():
|
||||
maintenance_task = None
|
||||
version_check_task = None
|
||||
polling_task = None
|
||||
web_api_server = None
|
||||
|
||||
try:
|
||||
logger.info("📊 Инициализация базы данных...")
|
||||
@@ -95,10 +97,11 @@ async def main():
|
||||
|
||||
logger.info("🤖 Настройка бота...")
|
||||
bot, dp = await setup_bot()
|
||||
|
||||
|
||||
monitoring_service.bot = bot
|
||||
maintenance_service.set_bot(bot)
|
||||
|
||||
broadcast_service.set_bot(bot)
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
admin_notification_service = AdminNotificationService(bot)
|
||||
version_service.bot = bot
|
||||
@@ -182,6 +185,22 @@ async def main():
|
||||
else:
|
||||
logger.info("ℹ️ Проверка версий отключена")
|
||||
|
||||
if settings.is_web_api_enabled():
|
||||
try:
|
||||
from app.webapi import WebAPIServer
|
||||
|
||||
web_api_server = WebAPIServer()
|
||||
await web_api_server.start()
|
||||
logger.info(
|
||||
"🌐 Административное веб-API запущено: http://%s:%s",
|
||||
settings.WEB_API_HOST,
|
||||
settings.WEB_API_PORT,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Не удалось запустить веб-API: {error}")
|
||||
else:
|
||||
logger.info("ℹ️ Веб-API отключено")
|
||||
|
||||
logger.info("📄 Запуск polling...")
|
||||
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
|
||||
|
||||
@@ -320,6 +339,13 @@ async def main():
|
||||
if webhook_server:
|
||||
logger.info("ℹ️ Остановка webhook сервера...")
|
||||
await webhook_server.stop()
|
||||
|
||||
if web_api_server:
|
||||
try:
|
||||
await web_api_server.stop()
|
||||
logger.info("✅ Административное веб-API остановлено")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка остановки веб-API: {error}")
|
||||
|
||||
if 'bot' in locals():
|
||||
try:
|
||||
|
||||
+3
-1
@@ -6,12 +6,14 @@ SQLAlchemy==2.0.43
|
||||
alembic==1.16.5
|
||||
aiosqlite==0.21.0
|
||||
|
||||
# Дополнительные зависимости
|
||||
# Дополнительные зависимости
|
||||
pydantic==2.11.9
|
||||
pydantic-settings==2.10.1
|
||||
python-dotenv==1.1.1
|
||||
redis==5.0.1
|
||||
PyYAML==6.0.2
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.32.1
|
||||
|
||||
# YooKassa SDK
|
||||
yookassa==3.7.0
|
||||
|
||||
Reference in New Issue
Block a user