Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ad9fd8ae7 | |||
| f9cfaa4bc1 | |||
| e1cc7a2e23 | |||
| 7a0faa4b0a | |||
| a87d067133 | |||
| 42759854c2 | |||
| b2027df7f3 | |||
| ce865a9b2a | |||
| 7dcd11b964 | |||
| 6482284cd8 | |||
| 7f8fbf069b | |||
| 3e6064756e | |||
| 1d1a9e0d78 | |||
| bc6e57765e | |||
| 16031023cb | |||
| 151ce092b9 | |||
| 01d3659ad2 | |||
| 7dd0f5f7b0 | |||
| cfd79f4b23 | |||
| 0cd355a878 | |||
| 344ff65710 | |||
| 1b47f2aa99 | |||
| 1d01a77457 | |||
| aa669fa3cd | |||
| e487776510 | |||
| 9d038f7443 | |||
| 02ebcd368e | |||
| 7e1a2998af | |||
| 0a5383eb80 | |||
| 763d8f2aaa | |||
| ce11ec7c0e | |||
| 1bb0a5c000 | |||
| 2cb6dfb4aa | |||
| 837473b274 | |||
| aab32299ac | |||
| 2a3f1bac76 | |||
| f4ed274623 | |||
| 32c28aedea | |||
| 69927a4db2 | |||
| bdc1df737b | |||
| c8c79ea807 | |||
| d75fc0c60f | |||
| 931b282f5b | |||
| 10bc00d429 | |||
| dd24b7ffde | |||
| 5fa627bd7f | |||
| b8671ef07d | |||
| 7a30aa23dc | |||
| a87d52f2bc | |||
| 5919cfff16 | |||
| e743689b34 | |||
| 37dd5ede9f | |||
| 930aabc166 | |||
| f70ad92efe | |||
| 01fd7a317d | |||
| 507df0af33 | |||
| af91423dec | |||
| 893ff0e8f1 | |||
| cde838864f | |||
| 2eb2661848 | |||
| d7a935906e | |||
| b81400105f | |||
| 8c3e71bdfd | |||
| 025d50675f | |||
| 5f540d907e | |||
| c6175255a5 | |||
| 71facd9624 | |||
| 89a5f5c6f7 | |||
| 7b60be1ec7 | |||
| ed314ca19b | |||
| 0b714893e7 | |||
| 5ca0f5fc27 | |||
| 31fa7b7072 | |||
| 86d7003c33 | |||
| 753d77b4c2 | |||
| 4add298aab | |||
| e76f2f3d50 | |||
| dcb0002f4a | |||
| 332c20fc45 | |||
| 305a3c4490 | |||
| a9ecd5f620 | |||
| 2f0594e361 | |||
| 65b36c3a5f | |||
| f8ef2b9f5a | |||
| 70a283d1f1 | |||
| f2a5032f15 | |||
| 0f5fcdd067 | |||
| 80388be54f | |||
| 47f0b77138 | |||
| 162e7da350 | |||
| 07e1e08b1d | |||
| fc955bd3b2 | |||
| 1b504063d2 | |||
| 174f9b8517 | |||
| 3706d5ceb4 | |||
| 3577054d79 | |||
| afd4fe8d1d | |||
| 1409a0ab8d | |||
| 9df8ca52d6 | |||
| 0902efc007 | |||
| 0b4fea02a1 | |||
| 01580aae74 | |||
| 7e022a1eca | |||
| f31c64602c | |||
| 1e56e4eaa6 |
+24
-4
@@ -37,6 +37,7 @@ SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланс
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
|
||||
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
# Режим базы данных: "auto", "postgresql", "sqlite"
|
||||
@@ -136,9 +137,8 @@ FIXED_TRAFFIC_LIMIT_GB=100
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
|
||||
AVAILABLE_RENEWAL_PERIODS=30,90,180
|
||||
|
||||
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
|
||||
# Включить упрощённую покупку из меню
|
||||
SIMPLE_SUBSCRIPTION_ENABLED=false
|
||||
# ===== ПРОСТАЯ ПОКУПКА ПОДПИСКИ =====
|
||||
SIMPLE_SUBSCRIPTION_ENABLED=true
|
||||
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
|
||||
# Сколько устройств выдаётся в рамках простой подписки
|
||||
@@ -173,6 +173,8 @@ PRICE_PER_DEVICE=10000
|
||||
DEVICES_SELECTION_ENABLED=true
|
||||
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT=0
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW=false
|
||||
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_PROGRAM_ENABLED=true
|
||||
@@ -181,6 +183,7 @@ REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
|
||||
|
||||
# Уведомления
|
||||
REFERRAL_NOTIFICATIONS_ENABLED=true
|
||||
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
@@ -223,7 +226,7 @@ YOOKASSA_VAT_CODE=1
|
||||
# 1 - НДС не облагается
|
||||
# 2 - НДС 0%
|
||||
# 3 - НДС 10%
|
||||
# 4 - НДС 20%
|
||||
# 4 - НДС 20%/22%
|
||||
# 5 - НДС 10/110
|
||||
# 6 - НДС 20/120
|
||||
|
||||
@@ -275,6 +278,15 @@ PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
|
||||
# Интервал (в минутах) между автоматическими проверками пополнений
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
|
||||
|
||||
# ===== НАЛОГОВАЯ СЛУЖБА (NaloGO) =====
|
||||
# Автоматическая отправка чеков в налоговую при пополнении баланса
|
||||
NALOGO_ENABLED=false
|
||||
NALOGO_RECEIPTS_ENABLED=false
|
||||
NALOGO_INN= # ИНН самозанятого
|
||||
NALOGO_PASSWORD= # Пароль от личного кабинета налоговой
|
||||
NALOGO_DEVICE_ID= # Опционально: ID устройства для авторизации
|
||||
NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токенами
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
@@ -494,6 +506,14 @@ WEBHOOK_ENQUEUE_TIMEOUT=0.1
|
||||
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
|
||||
BOT_RUN_MODE=polling # polling, webhook или both
|
||||
|
||||
# ===== КОНКУРСНАЯ СИСТЕМА =====
|
||||
CONTESTS_ENABLED=false
|
||||
CONTESTS_BUTTON_VISIBLE=false
|
||||
|
||||
# ===== КНОПКА АКТИВАЦИИ =====
|
||||
ACTIVATE_BUTTON_VISIBLE=false
|
||||
# ACTIVATE_BUTTON_TEXT=активировать
|
||||
|
||||
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
|
||||
WEB_API_ENABLED=false
|
||||
WEB_API_HOST=0.0.0.0
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.9.1-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.2-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.9.1-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.2-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.9.1-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.2-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.9.1-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.2-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.9.1-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.2-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.9.1-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.2-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.9.1"
|
||||
ARG VERSION="v2.9.2"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
"Commons Clause" License Condition v1.0
|
||||
|
||||
The Software is provided to you by the Licensor under the License,
|
||||
as defined below, subject to the following condition.
|
||||
|
||||
Without limiting other conditions in the License, the grant of rights
|
||||
under the License will not include, and the License does not grant to
|
||||
you, the right to Sell the Software.
|
||||
|
||||
For purposes of the foregoing, "Sell" means practicing any or all of
|
||||
the rights granted to you under the License to provide to third parties,
|
||||
for a fee or other consideration (including without limitation fees for
|
||||
hosting or consulting/support services related to the Software), a
|
||||
product or service whose value derives, entirely or substantially, from
|
||||
the functionality of the Software.
|
||||
|
||||
Any license notice or attribution required by the License must also
|
||||
include this Commons Clause License Condition notice.
|
||||
|
||||
Software: remnawave-bedolaga-telegram-bot
|
||||
License: MIT
|
||||
Licensor: Fr1ngg
|
||||
|
||||
---
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Fr1ngg
|
||||
|
||||
+13
@@ -12,6 +12,7 @@ from app.middlewares.throttling import ThrottlingMiddleware
|
||||
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.middlewares.button_stats import ButtonStatsMiddleware
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
@@ -47,6 +48,8 @@ from app.handlers.admin import (
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
campaigns as admin_campaigns,
|
||||
contests as admin_contests,
|
||||
daily_contests as admin_daily_contests,
|
||||
promo_offers as admin_promo_offers,
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates,
|
||||
@@ -63,6 +66,7 @@ from app.handlers.admin import (
|
||||
payments as admin_payments,
|
||||
trials as admin_trials,
|
||||
)
|
||||
from app.handlers import contests as user_contests
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
from app.utils.message_patch import patch_message_methods
|
||||
@@ -124,6 +128,12 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.pre_checkout_query.middleware(display_name_middleware)
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
# Middleware для автоматического логирования кликов по кнопкам
|
||||
if settings.MENU_LAYOUT_ENABLED:
|
||||
button_stats_middleware = ButtonStatsMiddleware()
|
||||
dp.callback_query.middleware(button_stats_middleware)
|
||||
logger.info("📊 ButtonStatsMiddleware активирован")
|
||||
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
@@ -162,6 +172,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_polls.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_contests.register_handlers(dp)
|
||||
admin_daily_contests.register_handlers(dp)
|
||||
admin_promo_offers.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
admin_user_messages.register_handlers(dp)
|
||||
@@ -182,6 +194,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_blacklist.register_blacklist_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_contests.register_handlers(dp)
|
||||
user_polls.register_handlers(dp)
|
||||
simple_subscription.register_simple_subscription_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
+55
-8
@@ -54,6 +54,7 @@ class Settings(BaseSettings):
|
||||
CHANNEL_SUB_ID: Optional[str] = None
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
CHANNEL_IS_REQUIRED_SUB: bool = False
|
||||
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
|
||||
|
||||
DATABASE_URL: Optional[str] = None
|
||||
|
||||
@@ -156,11 +157,27 @@ class Settings(BaseSettings):
|
||||
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
|
||||
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
|
||||
|
||||
# Конкурсы (глобальный флаг, будет расширяться под разные типы)
|
||||
CONTESTS_ENABLED: bool = False
|
||||
CONTESTS_BUTTON_VISIBLE: bool = False
|
||||
# Для обратной совместимости со старыми конфигами
|
||||
REFERRAL_CONTESTS_ENABLED: bool = False
|
||||
|
||||
BLACKLIST_CHECK_ENABLED: bool = False
|
||||
BLACKLIST_GITHUB_URL: Optional[str] = None
|
||||
BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24
|
||||
BLACKLIST_IGNORE_ADMINS: bool = True
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = True
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
|
||||
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
|
||||
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
|
||||
|
||||
# Настройки конструктора меню (API)
|
||||
MENU_LAYOUT_ENABLED: bool = False # Включить управление меню через API
|
||||
|
||||
# Настройки мониторинга трафика
|
||||
TRAFFIC_MONITORING_ENABLED: bool = False
|
||||
TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки
|
||||
@@ -169,6 +186,8 @@ class Settings(BaseSettings):
|
||||
|
||||
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||
|
||||
ENABLE_AUTOPAY: bool = False
|
||||
|
||||
DEFAULT_AUTOPAY_ENABLED: bool = False
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
|
||||
@@ -215,14 +234,19 @@ class Settings(BaseSettings):
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
||||
|
||||
NALOGO_ENABLED: bool = False
|
||||
NALOGO_RECEIPTS_ENABLED: bool = False
|
||||
NALOGO_INN: Optional[str] = None
|
||||
NALOGO_PASSWORD: Optional[str] = None
|
||||
NALOGO_DEVICE_ID: Optional[str] = None
|
||||
NALOGO_STORAGE_PATH: str = "./nalogo_tokens.json"
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
|
||||
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
|
||||
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW: bool = False
|
||||
ACTIVATE_BUTTON_VISIBLE: bool = False
|
||||
ACTIVATE_BUTTON_TEXT: str = "активировать"
|
||||
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
|
||||
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
|
||||
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
|
||||
@@ -988,6 +1012,11 @@ class Settings(BaseSettings):
|
||||
self.YOOKASSA_SHOP_ID is not None and
|
||||
self.YOOKASSA_SECRET_KEY is not None)
|
||||
|
||||
def is_nalogo_enabled(self) -> bool:
|
||||
return (self.NALOGO_ENABLED and
|
||||
self.NALOGO_INN is not None and
|
||||
self.NALOGO_PASSWORD is not None)
|
||||
|
||||
def is_support_topup_enabled(self) -> bool:
|
||||
return bool(self.SUPPORT_TOPUP_ENABLED)
|
||||
|
||||
@@ -1190,6 +1219,16 @@ class Settings(BaseSettings):
|
||||
return False
|
||||
return self.HIDE_SUBSCRIPTION_LINK
|
||||
|
||||
def is_contests_enabled(self) -> bool:
|
||||
if getattr(self, "CONTESTS_ENABLED", False):
|
||||
return True
|
||||
# legacy fallback
|
||||
return bool(getattr(self, "REFERRAL_CONTESTS_ENABLED", False))
|
||||
|
||||
def is_referral_contests_enabled(self) -> bool:
|
||||
# kept for backward compatibility
|
||||
return self.is_contests_enabled()
|
||||
|
||||
def get_happ_cryptolink_redirect_template(self) -> Optional[str]:
|
||||
template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip()
|
||||
return template or None
|
||||
@@ -1311,10 +1350,18 @@ class Settings(BaseSettings):
|
||||
except (ValueError, AttributeError):
|
||||
return [30, 90, 180]
|
||||
|
||||
def get_balance_payment_description(self, amount_kopeks: int) -> str:
|
||||
def get_balance_payment_description(self, amount_kopeks: int, telegram_user_id: Optional[int] = None) -> str:
|
||||
# Базовое описание
|
||||
description = f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}"
|
||||
|
||||
# Если передан user_id, добавляем его
|
||||
if telegram_user_id is not None:
|
||||
description += f" (ID {telegram_user_id})"
|
||||
|
||||
# Формируем финальную строку по шаблону
|
||||
return self.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=self.PAYMENT_SERVICE_NAME,
|
||||
description=f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}"
|
||||
description=description
|
||||
)
|
||||
|
||||
def get_subscription_payment_description(self, period_days: int, amount_kopeks: int) -> str:
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import and_, delete, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ContestTemplate, ContestRound, ContestAttempt, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Templates
|
||||
async def get_template_by_id(db: AsyncSession, template_id: int) -> Optional[ContestTemplate]:
|
||||
result = await db.execute(
|
||||
select(ContestTemplate).where(ContestTemplate.id == template_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_template_by_slug(db: AsyncSession, slug: str) -> Optional[ContestTemplate]:
|
||||
result = await db.execute(
|
||||
select(ContestTemplate).where(ContestTemplate.slug == slug)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_templates(db: AsyncSession, enabled_only: bool = True) -> List[ContestTemplate]:
|
||||
query = select(ContestTemplate).order_by(ContestTemplate.id)
|
||||
if enabled_only:
|
||||
query = query.where(ContestTemplate.is_enabled.is_(True))
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def upsert_template(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
prize_days: int = 1,
|
||||
max_winners: int = 1,
|
||||
attempts_per_user: int = 1,
|
||||
times_per_day: int = 1,
|
||||
schedule_times: Optional[str] = None,
|
||||
cooldown_hours: int = 24,
|
||||
payload: Optional[dict] = None,
|
||||
is_enabled: Optional[bool] = None,
|
||||
) -> ContestTemplate:
|
||||
template = await get_template_by_slug(db, slug)
|
||||
if not template:
|
||||
template = ContestTemplate(slug=slug)
|
||||
db.add(template)
|
||||
|
||||
template.name = name
|
||||
template.description = description
|
||||
template.prize_days = prize_days
|
||||
template.max_winners = max_winners
|
||||
template.attempts_per_user = attempts_per_user
|
||||
template.times_per_day = times_per_day
|
||||
template.schedule_times = schedule_times
|
||||
template.cooldown_hours = cooldown_hours
|
||||
template.payload = payload or {}
|
||||
if is_enabled is not None:
|
||||
template.is_enabled = is_enabled
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
|
||||
|
||||
async def update_template_fields(
|
||||
db: AsyncSession,
|
||||
template: ContestTemplate,
|
||||
**fields: object,
|
||||
) -> ContestTemplate:
|
||||
for key, value in fields.items():
|
||||
if hasattr(template, key):
|
||||
setattr(template, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
|
||||
|
||||
# Rounds
|
||||
async def create_round(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
template: ContestTemplate,
|
||||
starts_at: datetime,
|
||||
ends_at: datetime,
|
||||
payload: dict,
|
||||
) -> ContestRound:
|
||||
round_obj = ContestRound(
|
||||
template_id=template.id,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
status="active",
|
||||
payload=payload,
|
||||
max_winners=template.max_winners,
|
||||
attempts_per_user=template.attempts_per_user,
|
||||
)
|
||||
db.add(round_obj)
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
return round_obj
|
||||
|
||||
|
||||
async def get_active_rounds(db: AsyncSession) -> List[ContestRound]:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
.where(
|
||||
and_(
|
||||
ContestRound.status == "active",
|
||||
ContestRound.starts_at <= now,
|
||||
ContestRound.ends_at >= now,
|
||||
)
|
||||
)
|
||||
.order_by(ContestRound.starts_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> Optional[ContestRound]:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
.where(
|
||||
and_(
|
||||
ContestRound.template_id == template_id,
|
||||
ContestRound.status == "active",
|
||||
ContestRound.starts_at <= now,
|
||||
ContestRound.ends_at >= now,
|
||||
)
|
||||
)
|
||||
.order_by(desc(ContestRound.starts_at))
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def finish_round(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
|
||||
round_obj.status = "finished"
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
return round_obj
|
||||
|
||||
|
||||
async def increment_winner_count(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
|
||||
round_obj.winners_count += 1
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
return round_obj
|
||||
|
||||
|
||||
# Attempts
|
||||
async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> Optional[ContestAttempt]:
|
||||
result = await db.execute(
|
||||
select(ContestAttempt).where(
|
||||
and_(
|
||||
ContestAttempt.round_id == round_id,
|
||||
ContestAttempt.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_attempt(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
round_id: int,
|
||||
user_id: int,
|
||||
answer: Optional[str],
|
||||
is_winner: bool,
|
||||
) -> ContestAttempt:
|
||||
attempt = ContestAttempt(
|
||||
round_id=round_id,
|
||||
user_id=user_id,
|
||||
answer=answer,
|
||||
is_winner=is_winner,
|
||||
)
|
||||
db.add(attempt)
|
||||
await db.commit()
|
||||
await db.refresh(attempt)
|
||||
return attempt
|
||||
|
||||
|
||||
async def clear_attempts(db: AsyncSession, round_id: int) -> int:
|
||||
result = await db.execute(delete(ContestAttempt).where(ContestAttempt.round_id == round_id))
|
||||
deleted_count = result.rowcount
|
||||
await db.commit()
|
||||
return deleted_count
|
||||
|
||||
|
||||
async def list_winners(db: AsyncSession, round_id: int) -> Sequence[Tuple[User, ContestAttempt]]:
|
||||
result = await db.execute(
|
||||
select(User, ContestAttempt)
|
||||
.join(ContestAttempt, ContestAttempt.user_id == User.id)
|
||||
.where(
|
||||
and_(
|
||||
ContestAttempt.round_id == round_id,
|
||||
ContestAttempt.is_winner.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.all()
|
||||
@@ -0,0 +1,299 @@
|
||||
import logging
|
||||
from datetime import datetime, date, time, timezone
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import and_, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, aliased
|
||||
|
||||
from app.database.models import (
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
User,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_referral_contest(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
description: Optional[str],
|
||||
prize_text: Optional[str],
|
||||
contest_type: str,
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
daily_summary_time: time,
|
||||
daily_summary_times: Optional[str] = None,
|
||||
timezone_name: str,
|
||||
created_by: Optional[int] = None,
|
||||
) -> ReferralContest:
|
||||
contest = ReferralContest(
|
||||
title=title,
|
||||
description=description,
|
||||
prize_text=prize_text,
|
||||
contest_type=contest_type,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
daily_summary_time=daily_summary_time,
|
||||
daily_summary_times=daily_summary_times,
|
||||
timezone=timezone_name or "UTC",
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(contest)
|
||||
await db.commit()
|
||||
await db.refresh(contest)
|
||||
return contest
|
||||
|
||||
|
||||
async def list_referral_contests(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
contest_type: Optional[str] = None,
|
||||
) -> List[ReferralContest]:
|
||||
query = (
|
||||
select(ReferralContest)
|
||||
.options(selectinload(ReferralContest.creator))
|
||||
.order_by(desc(ReferralContest.start_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if contest_type:
|
||||
query = query.where(ReferralContest.contest_type == contest_type)
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_referral_contests_count(db: AsyncSession, contest_type: Optional[str] = None) -> int:
|
||||
query = select(func.count(ReferralContest.id))
|
||||
if contest_type:
|
||||
query = query.where(ReferralContest.contest_type == contest_type)
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_referral_contest(db: AsyncSession, contest_id: int) -> Optional[ReferralContest]:
|
||||
result = await db.execute(
|
||||
select(ReferralContest)
|
||||
.options(
|
||||
selectinload(ReferralContest.creator),
|
||||
selectinload(ReferralContest.events),
|
||||
)
|
||||
.where(ReferralContest.id == contest_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_referral_contest(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
**fields: object,
|
||||
) -> ReferralContest:
|
||||
for key, value in fields.items():
|
||||
if hasattr(contest, key):
|
||||
setattr(contest, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(contest)
|
||||
return contest
|
||||
|
||||
|
||||
async def toggle_referral_contest(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
is_active: bool,
|
||||
) -> ReferralContest:
|
||||
contest.is_active = is_active
|
||||
return await update_referral_contest(db, contest)
|
||||
|
||||
|
||||
async def get_contests_for_events(
|
||||
db: AsyncSession,
|
||||
now_utc: datetime,
|
||||
*,
|
||||
contest_types: Optional[List[str]] = None,
|
||||
) -> List[ReferralContest]:
|
||||
query = select(ReferralContest).where(
|
||||
and_(
|
||||
ReferralContest.is_active.is_(True),
|
||||
ReferralContest.start_at <= now_utc,
|
||||
ReferralContest.end_at >= now_utc,
|
||||
)
|
||||
)
|
||||
if contest_types:
|
||||
query = query.where(ReferralContest.contest_type.in_(contest_types))
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_contests_for_summaries(db: AsyncSession) -> List[ReferralContest]:
|
||||
result = await db.execute(
|
||||
select(ReferralContest).where(ReferralContest.is_active.is_(True))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def add_contest_event(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
contest_id: int,
|
||||
referrer_id: int,
|
||||
referral_id: int,
|
||||
amount_kopeks: int = 0,
|
||||
event_type: str = "subscription_purchase",
|
||||
) -> Optional[ReferralContestEvent]:
|
||||
existing = await db.execute(
|
||||
select(ReferralContestEvent).where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.referral_id == referral_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return None
|
||||
|
||||
event = ReferralContestEvent(
|
||||
contest_id=contest_id,
|
||||
referrer_id=referrer_id,
|
||||
referral_id=referral_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
event_type=event_type,
|
||||
occurred_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
async def get_contest_leaderboard(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
) -> Sequence[Tuple[User, int, int]]:
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
return []
|
||||
|
||||
query = (
|
||||
select(
|
||||
User,
|
||||
func.count(ReferralContestEvent.id).label("referral_count"),
|
||||
func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0).label("total_amount"),
|
||||
)
|
||||
.join(User, User.id == ReferralContestEvent.referrer_id)
|
||||
.where(ReferralContestEvent.contest_id == contest_id)
|
||||
.group_by(User.id)
|
||||
.order_by(desc("referral_count"), desc("total_amount"), User.id)
|
||||
)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
result = await db.execute(query)
|
||||
leaderboard = result.all()
|
||||
|
||||
return leaderboard
|
||||
|
||||
|
||||
async def get_contest_participants(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
) -> Sequence[Tuple[User, int]]:
|
||||
result = await db.execute(
|
||||
select(User, func.count(ReferralContestEvent.id).label("referral_count"))
|
||||
.join(User, User.id == ReferralContestEvent.referrer_id)
|
||||
.where(ReferralContestEvent.contest_id == contest_id)
|
||||
.group_by(User.id)
|
||||
)
|
||||
return result.all()
|
||||
|
||||
|
||||
async def get_referrer_score(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
referrer_id: int,
|
||||
*,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
) -> int:
|
||||
query = select(func.count(ReferralContestEvent.id)).where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.referrer_id == referrer_id,
|
||||
)
|
||||
)
|
||||
if start:
|
||||
query = query.where(ReferralContestEvent.occurred_at >= start)
|
||||
if end:
|
||||
query = query.where(ReferralContestEvent.occurred_at < end)
|
||||
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_contest_events_count(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
*,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
) -> int:
|
||||
query = select(func.count(ReferralContestEvent.id)).where(
|
||||
ReferralContestEvent.contest_id == contest_id
|
||||
)
|
||||
if start:
|
||||
query = query.where(ReferralContestEvent.occurred_at >= start)
|
||||
if end:
|
||||
query = query.where(ReferralContestEvent.occurred_at < end)
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_contest_events(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
) -> List[ReferralContestEvent]:
|
||||
result = await db.execute(
|
||||
select(ReferralContestEvent).where(ReferralContestEvent.contest_id == contest_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def mark_daily_summary_sent(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
summary_date: date,
|
||||
summary_dt_utc: Optional[datetime] = None,
|
||||
) -> ReferralContest:
|
||||
contest.last_daily_summary_date = summary_date
|
||||
if summary_dt_utc:
|
||||
contest.last_daily_summary_at = summary_dt_utc
|
||||
await db.commit()
|
||||
await db.refresh(contest)
|
||||
return contest
|
||||
|
||||
|
||||
async def mark_final_summary_sent(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
) -> ReferralContest:
|
||||
contest.final_summary_sent = True
|
||||
contest.is_active = False
|
||||
await db.commit()
|
||||
await db.refresh(contest)
|
||||
return contest
|
||||
|
||||
|
||||
async def delete_referral_contest(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
) -> None:
|
||||
await db.delete(contest)
|
||||
await db.commit()
|
||||
@@ -50,6 +50,21 @@ async def create_transaction(
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
if type == TransactionType.SUBSCRIPTION_PAYMENT:
|
||||
try:
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
|
||||
await referral_contest_service.on_subscription_payment(
|
||||
db,
|
||||
user_id,
|
||||
amount_kopeks,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Не удалось записать событие конкурса для пользователя %s: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
+175
-1
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, time, date
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
@@ -7,6 +7,8 @@ from sqlalchemy import (
|
||||
Integer,
|
||||
String,
|
||||
DateTime,
|
||||
Date,
|
||||
Time,
|
||||
Boolean,
|
||||
Text,
|
||||
ForeignKey,
|
||||
@@ -16,6 +18,7 @@ from sqlalchemy import (
|
||||
UniqueConstraint,
|
||||
Index,
|
||||
Table,
|
||||
SmallInteger,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||
@@ -957,6 +960,132 @@ class ReferralEarning(Base):
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
|
||||
class ReferralContest(Base):
|
||||
__tablename__ = "referral_contests"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
prize_text = Column(Text, nullable=True)
|
||||
contest_type = Column(String(50), nullable=False, default="referral_paid")
|
||||
start_at = Column(DateTime, nullable=False)
|
||||
end_at = Column(DateTime, nullable=False)
|
||||
daily_summary_time = Column(Time, nullable=False, default=time(hour=12, minute=0))
|
||||
daily_summary_times = Column(String(255), nullable=True) # CSV HH:MM
|
||||
timezone = Column(String(64), nullable=False, default="UTC")
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
last_daily_summary_date = Column(Date, nullable=True)
|
||||
last_daily_summary_at = Column(DateTime, nullable=True)
|
||||
final_summary_sent = Column(Boolean, nullable=False, default=False)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship("User", backref="created_referral_contests")
|
||||
events = relationship(
|
||||
"ReferralContestEvent",
|
||||
back_populates="contest",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ReferralContest id={self.id} title='{self.title}'>"
|
||||
|
||||
|
||||
class ReferralContestEvent(Base):
|
||||
__tablename__ = "referral_contest_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"contest_id",
|
||||
"referral_id",
|
||||
name="uq_referral_contest_referral",
|
||||
),
|
||||
Index("idx_referral_contest_referrer", "contest_id", "referrer_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
contest_id = Column(Integer, ForeignKey("referral_contests.id", ondelete="CASCADE"), nullable=False)
|
||||
referrer_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
referral_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
event_type = Column(String(50), nullable=False)
|
||||
amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
occurred_at = Column(DateTime, nullable=False, default=func.now())
|
||||
|
||||
contest = relationship("ReferralContest", back_populates="events")
|
||||
referrer = relationship("User", foreign_keys=[referrer_id])
|
||||
referral = relationship("User", foreign_keys=[referral_id])
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<ReferralContestEvent contest={self.contest_id} "
|
||||
f"referrer={self.referrer_id} referral={self.referral_id}>"
|
||||
)
|
||||
|
||||
|
||||
class ContestTemplate(Base):
|
||||
__tablename__ = "contest_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
slug = Column(String(50), nullable=False, unique=True, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
prize_days = Column(Integer, nullable=False, default=1)
|
||||
max_winners = Column(Integer, nullable=False, default=1)
|
||||
attempts_per_user = Column(Integer, nullable=False, default=1)
|
||||
times_per_day = Column(Integer, nullable=False, default=1)
|
||||
schedule_times = Column(String(255), nullable=True) # CSV of HH:MM in local TZ
|
||||
cooldown_hours = Column(Integer, nullable=False, default=24)
|
||||
payload = Column(JSON, nullable=True)
|
||||
is_enabled = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
rounds = relationship("ContestRound", back_populates="template")
|
||||
|
||||
|
||||
class ContestRound(Base):
|
||||
__tablename__ = "contest_rounds"
|
||||
__table_args__ = (
|
||||
Index("idx_contest_round_status", "status"),
|
||||
Index("idx_contest_round_template", "template_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
template_id = Column(Integer, ForeignKey("contest_templates.id", ondelete="CASCADE"), nullable=False)
|
||||
starts_at = Column(DateTime, nullable=False)
|
||||
ends_at = Column(DateTime, nullable=False)
|
||||
status = Column(String(20), nullable=False, default="active") # active, finished
|
||||
payload = Column(JSON, nullable=True)
|
||||
winners_count = Column(Integer, nullable=False, default=0)
|
||||
max_winners = Column(Integer, nullable=False, default=1)
|
||||
attempts_per_user = Column(Integer, nullable=False, default=1)
|
||||
message_id = Column(BigInteger, nullable=True)
|
||||
chat_id = Column(BigInteger, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
template = relationship("ContestTemplate", back_populates="rounds")
|
||||
attempts = relationship("ContestAttempt", back_populates="round", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ContestAttempt(Base):
|
||||
__tablename__ = "contest_attempts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("round_id", "user_id", name="uq_round_user_attempt"),
|
||||
Index("idx_contest_attempt_round", "round_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
round_id = Column(Integer, ForeignKey("contest_rounds.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
answer = Column(Text, nullable=True)
|
||||
is_winner = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
round = relationship("ContestRound", back_populates="attempts")
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class Squad(Base):
|
||||
__tablename__ = "squads"
|
||||
|
||||
@@ -1653,3 +1782,48 @@ class MainMenuButton(Base):
|
||||
f"<MainMenuButton id={self.id} text='{self.text}' "
|
||||
f"action={self.action_type} visibility={self.visibility} active={self.is_active}>"
|
||||
)
|
||||
|
||||
|
||||
class MenuLayoutHistory(Base):
|
||||
"""История изменений конфигурации меню."""
|
||||
__tablename__ = "menu_layout_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
config_json = Column(Text, nullable=False) # Полная конфигурация в JSON
|
||||
action = Column(String(50), nullable=False) # update, reset, import
|
||||
changes_summary = Column(Text, nullable=True) # Краткое описание изменений
|
||||
user_info = Column(String(255), nullable=True) # Информация о пользователе/токене
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_menu_layout_history_created", "created_at"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MenuLayoutHistory id={self.id} action='{self.action}' created_at={self.created_at}>"
|
||||
|
||||
|
||||
class ButtonClickLog(Base):
|
||||
"""Логи кликов по кнопкам меню."""
|
||||
__tablename__ = "button_click_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
button_id = Column(String(100), nullable=False, index=True) # ID кнопки
|
||||
user_id = Column(BigInteger, ForeignKey("users.telegram_id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
callback_data = Column(String(255), nullable=True) # callback_data кнопки
|
||||
clicked_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
# Дополнительная информация
|
||||
button_type = Column(String(20), nullable=True) # builtin, callback, url, mini_app
|
||||
button_text = Column(String(255), nullable=True) # Текст кнопки на момент клика
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_button_click_logs_button_date", "button_id", "clicked_at"),
|
||||
Index("ix_button_click_logs_user_date", "user_id", "clicked_at"),
|
||||
)
|
||||
|
||||
# Связи
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ButtonClickLog id={self.id} button='{self.button_id}' user={self.user_id} at={self.clicked_at}>"
|
||||
|
||||
@@ -1383,6 +1383,464 @@ async def create_discount_offers_table():
|
||||
return False
|
||||
|
||||
|
||||
async def create_referral_contests_table() -> bool:
|
||||
table_exists = await check_table_exists("referral_contests")
|
||||
if table_exists:
|
||||
logger.info("Таблица referral_contests уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE referral_contests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
prize_text TEXT NULL,
|
||||
contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid',
|
||||
start_at DATETIME NOT NULL,
|
||||
end_at DATETIME NOT NULL,
|
||||
daily_summary_time TIME NOT NULL DEFAULT '12:00:00',
|
||||
daily_summary_times VARCHAR(255) NULL,
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
last_daily_summary_date DATE NULL,
|
||||
last_daily_summary_at DATETIME NULL,
|
||||
final_summary_sent BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE referral_contests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
prize_text TEXT NULL,
|
||||
contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid',
|
||||
start_at TIMESTAMP NOT NULL,
|
||||
end_at TIMESTAMP NOT NULL,
|
||||
daily_summary_time TIME NOT NULL DEFAULT '12:00:00',
|
||||
daily_summary_times VARCHAR(255) NULL,
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_daily_summary_date DATE NULL,
|
||||
last_daily_summary_at TIMESTAMP NULL,
|
||||
final_summary_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE referral_contests (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
prize_text TEXT NULL,
|
||||
contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid',
|
||||
start_at DATETIME NOT NULL,
|
||||
end_at DATETIME NOT NULL,
|
||||
daily_summary_time TIME NOT NULL DEFAULT '12:00:00',
|
||||
daily_summary_times VARCHAR(255) NULL,
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_daily_summary_date DATE NULL,
|
||||
last_daily_summary_at DATETIME NULL,
|
||||
final_summary_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_by INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_referral_contest_creator FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
"""))
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Таблица referral_contests создана")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания таблицы referral_contests: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_referral_contest_events_table() -> bool:
|
||||
table_exists = await check_table_exists("referral_contest_events")
|
||||
if table_exists:
|
||||
logger.info("Таблица referral_contest_events уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE referral_contest_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contest_id INTEGER NOT NULL,
|
||||
referrer_id INTEGER NOT NULL,
|
||||
referral_id INTEGER NOT NULL,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(referrer_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(referral_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(contest_id, referral_id)
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_referral_contest_referrer
|
||||
ON referral_contest_events (contest_id, referrer_id)
|
||||
"""))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE referral_contest_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
contest_id INTEGER NOT NULL REFERENCES referral_contests(id) ON DELETE CASCADE,
|
||||
referrer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
referral_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_referral_contest_referral UNIQUE (contest_id, referral_id)
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_referral_contest_referrer
|
||||
ON referral_contest_events (contest_id, referrer_id)
|
||||
"""))
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE referral_contest_events (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
contest_id INTEGER NOT NULL,
|
||||
referrer_id INTEGER NOT NULL,
|
||||
referral_id INTEGER NOT NULL,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_referral_contest FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_referral_contest_referrer FOREIGN KEY(referrer_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_referral_contest_referral FOREIGN KEY(referral_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_referral_contest_referral UNIQUE (contest_id, referral_id)
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
CREATE INDEX idx_referral_contest_referrer
|
||||
ON referral_contest_events (contest_id, referrer_id)
|
||||
"""))
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Таблица referral_contest_events создана")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания таблицы referral_contest_events: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_referral_contest_summary_columns() -> bool:
|
||||
ok = True
|
||||
for column in ["daily_summary_times", "last_daily_summary_at"]:
|
||||
exists = await check_column_exists("referral_contests", column)
|
||||
if exists:
|
||||
logger.info("Колонка %s в referral_contests уже существует", column)
|
||||
continue
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
if db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text(
|
||||
f"ALTER TABLE referral_contests ADD COLUMN {column} "
|
||||
+ ("VARCHAR(255)" if column == "daily_summary_times" else "TIMESTAMP")
|
||||
)
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
text(
|
||||
f"ALTER TABLE referral_contests ADD COLUMN {column} "
|
||||
+ ("VARCHAR(255)" if column == "daily_summary_times" else "DATETIME")
|
||||
)
|
||||
)
|
||||
logger.info("✅ Колонка %s в referral_contests добавлена", column)
|
||||
except Exception as error:
|
||||
ok = False
|
||||
logger.error("Ошибка добавления %s в referral_contests: %s", column, error)
|
||||
return ok
|
||||
|
||||
|
||||
async def create_contest_templates_table() -> bool:
|
||||
table_exists = await check_table_exists("contest_templates")
|
||||
if table_exists:
|
||||
logger.info("Таблица contest_templates уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
slug VARCHAR(50) NOT NULL UNIQUE,
|
||||
description TEXT NULL,
|
||||
prize_days INTEGER NOT NULL DEFAULT 1,
|
||||
max_winners INTEGER NOT NULL DEFAULT 1,
|
||||
attempts_per_user INTEGER NOT NULL DEFAULT 1,
|
||||
times_per_day INTEGER NOT NULL DEFAULT 1,
|
||||
schedule_times VARCHAR(255) NULL,
|
||||
cooldown_hours INTEGER NOT NULL DEFAULT 24,
|
||||
payload TEXT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
slug VARCHAR(50) NOT NULL UNIQUE,
|
||||
description TEXT NULL,
|
||||
prize_days INTEGER NOT NULL DEFAULT 1,
|
||||
max_winners INTEGER NOT NULL DEFAULT 1,
|
||||
attempts_per_user INTEGER NOT NULL DEFAULT 1,
|
||||
times_per_day INTEGER NOT NULL DEFAULT 1,
|
||||
schedule_times VARCHAR(255) NULL,
|
||||
cooldown_hours INTEGER NOT NULL DEFAULT 24,
|
||||
payload JSON NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_templates (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
slug VARCHAR(50) NOT NULL UNIQUE,
|
||||
description TEXT NULL,
|
||||
prize_days INTEGER NOT NULL DEFAULT 1,
|
||||
max_winners INTEGER NOT NULL DEFAULT 1,
|
||||
attempts_per_user INTEGER NOT NULL DEFAULT 1,
|
||||
times_per_day INTEGER NOT NULL DEFAULT 1,
|
||||
schedule_times VARCHAR(255) NULL,
|
||||
cooldown_hours INTEGER NOT NULL DEFAULT 24,
|
||||
payload JSON NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Таблица contest_templates создана")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания таблицы contest_templates: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_contest_rounds_table() -> bool:
|
||||
table_exists = await check_table_exists("contest_rounds")
|
||||
if table_exists:
|
||||
logger.info("Таблица contest_rounds уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_rounds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_id INTEGER NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
ends_at DATETIME NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
payload TEXT NULL,
|
||||
winners_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_winners INTEGER NOT NULL DEFAULT 1,
|
||||
attempts_per_user INTEGER NOT NULL DEFAULT 1,
|
||||
message_id BIGINT NULL,
|
||||
chat_id BIGINT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(template_id) REFERENCES contest_templates(id) ON DELETE CASCADE
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_status ON contest_rounds(status)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_template ON contest_rounds(template_id)"))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_rounds (
|
||||
id SERIAL PRIMARY KEY,
|
||||
template_id INTEGER NOT NULL REFERENCES contest_templates(id) ON DELETE CASCADE,
|
||||
starts_at TIMESTAMP NOT NULL,
|
||||
ends_at TIMESTAMP NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
payload JSON NULL,
|
||||
winners_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_winners INTEGER NOT NULL DEFAULT 1,
|
||||
attempts_per_user INTEGER NOT NULL DEFAULT 1,
|
||||
message_id BIGINT NULL,
|
||||
chat_id BIGINT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_status ON contest_rounds(status)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_template ON contest_rounds(template_id)"))
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_rounds (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
template_id INTEGER NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
ends_at DATETIME NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
payload JSON NULL,
|
||||
winners_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_winners INTEGER NOT NULL DEFAULT 1,
|
||||
attempts_per_user INTEGER NOT NULL DEFAULT 1,
|
||||
message_id BIGINT NULL,
|
||||
chat_id BIGINT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_contest_round_template FOREIGN KEY(template_id) REFERENCES contest_templates(id) ON DELETE CASCADE
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("CREATE INDEX idx_contest_round_status ON contest_rounds(status)"))
|
||||
await conn.execute(text("CREATE INDEX idx_contest_round_template ON contest_rounds(template_id)"))
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Таблица contest_rounds создана")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания таблицы contest_rounds: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_contest_attempts_table() -> bool:
|
||||
table_exists = await check_table_exists("contest_attempts")
|
||||
if table_exists:
|
||||
logger.info("Таблица contest_attempts уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
round_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
answer TEXT NULL,
|
||||
is_winner BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(round_id) REFERENCES contest_rounds(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(round_id, user_id)
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_attempt_round ON contest_attempts(round_id)"))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_attempts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
round_id INTEGER NOT NULL REFERENCES contest_rounds(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
answer TEXT NULL,
|
||||
is_winner BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_round_user_attempt UNIQUE(round_id, user_id)
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_attempt_round ON contest_attempts(round_id)"))
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_attempts (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
round_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
answer TEXT NULL,
|
||||
is_winner BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_contest_attempt_round FOREIGN KEY(round_id) REFERENCES contest_rounds(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_contest_attempt_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_round_user_attempt UNIQUE(round_id, user_id)
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("CREATE INDEX idx_contest_attempt_round ON contest_attempts(round_id)"))
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Таблица contest_attempts создана")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания таблицы contest_attempts: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_referral_contest_type_column() -> bool:
|
||||
column_exists = await check_column_exists("referral_contests", "contest_type")
|
||||
if column_exists:
|
||||
logger.info("Колонка contest_type в referral_contests уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE referral_contests "
|
||||
"ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'"
|
||||
)
|
||||
)
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE referral_contests "
|
||||
"ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'"
|
||||
)
|
||||
)
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE referral_contests "
|
||||
"ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'"
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Колонка contest_type в referral_contests добавлена")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка добавления contest_type в referral_contests: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_discount_offer_columns():
|
||||
try:
|
||||
effect_exists = await check_column_exists('discount_offers', 'effect_type')
|
||||
@@ -3257,6 +3715,133 @@ async def create_system_settings_table() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def create_menu_layout_history_table() -> bool:
|
||||
"""Создаёт таблицу для хранения истории изменений конфигурации меню."""
|
||||
table_exists = await check_table_exists("menu_layout_history")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица menu_layout_history уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_table_sql = """
|
||||
CREATE TABLE menu_layout_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
config_json TEXT NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
changes_summary TEXT NULL,
|
||||
user_info VARCHAR(255) NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_table_sql = """
|
||||
CREATE TABLE menu_layout_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
config_json TEXT NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
changes_summary TEXT NULL,
|
||||
user_info VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
else:
|
||||
create_table_sql = """
|
||||
CREATE TABLE menu_layout_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
config_json TEXT NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
changes_summary TEXT NULL,
|
||||
user_info VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_table_sql))
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX ix_menu_layout_history_created ON menu_layout_history(created_at)"
|
||||
))
|
||||
logger.info("✅ Таблица menu_layout_history создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы menu_layout_history: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_button_click_logs_table() -> bool:
|
||||
"""Создаёт таблицу для логирования кликов по кнопкам меню."""
|
||||
table_exists = await check_table_exists("button_click_logs")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица button_click_logs уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_table_sql = """
|
||||
CREATE TABLE button_click_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
button_id VARCHAR(100) NOT NULL,
|
||||
user_id BIGINT NULL REFERENCES users(telegram_id) ON DELETE SET NULL,
|
||||
callback_data VARCHAR(255) NULL,
|
||||
clicked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
button_type VARCHAR(20) NULL,
|
||||
button_text VARCHAR(255) NULL
|
||||
)
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_table_sql = """
|
||||
CREATE TABLE button_click_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
button_id VARCHAR(100) NOT NULL,
|
||||
user_id BIGINT NULL REFERENCES users(telegram_id) ON DELETE SET NULL,
|
||||
callback_data VARCHAR(255) NULL,
|
||||
clicked_at TIMESTAMP DEFAULT NOW(),
|
||||
button_type VARCHAR(20) NULL,
|
||||
button_text VARCHAR(255) NULL
|
||||
)
|
||||
"""
|
||||
else:
|
||||
create_table_sql = """
|
||||
CREATE TABLE button_click_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
button_id VARCHAR(100) NOT NULL,
|
||||
user_id BIGINT NULL,
|
||||
callback_data VARCHAR(255) NULL,
|
||||
clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
button_type VARCHAR(20) NULL,
|
||||
button_text VARCHAR(255) NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(telegram_id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_table_sql))
|
||||
|
||||
# Создаём индексы отдельными запросами
|
||||
index_statements = [
|
||||
"CREATE INDEX ix_button_click_logs_button_id ON button_click_logs(button_id)",
|
||||
"CREATE INDEX ix_button_click_logs_user_id ON button_click_logs(user_id)",
|
||||
"CREATE INDEX ix_button_click_logs_clicked_at ON button_click_logs(clicked_at)",
|
||||
"CREATE INDEX ix_button_click_logs_button_date ON button_click_logs(button_id, clicked_at)",
|
||||
"CREATE INDEX ix_button_click_logs_user_date ON button_click_logs(user_id, clicked_at)",
|
||||
]
|
||||
for stmt in index_statements:
|
||||
await conn.execute(text(stmt))
|
||||
|
||||
logger.info("✅ Таблица button_click_logs создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы button_click_logs: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_web_api_tokens_table() -> bool:
|
||||
table_exists = await check_table_exists("web_api_tokens")
|
||||
if table_exists:
|
||||
@@ -3858,6 +4443,20 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MENU_LAYOUT_HISTORY ===")
|
||||
menu_layout_history_ready = await create_menu_layout_history_table()
|
||||
if menu_layout_history_ready:
|
||||
logger.info("✅ Таблица menu_layout_history готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей menu_layout_history")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ BUTTON_CLICK_LOGS ===")
|
||||
button_click_logs_ready = await create_button_click_logs_table()
|
||||
if button_click_logs_ready:
|
||||
logger.info("✅ Таблица button_click_logs готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей button_click_logs")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ ДЛЯ ТРИАЛЬНЫХ СКВАДОВ ===")
|
||||
trial_column_ready = await add_server_trial_flag_column()
|
||||
if trial_column_ready:
|
||||
@@ -3961,6 +4560,49 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить колонки discount_offers")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕФЕРАЛЬНЫХ КОНКУРСОВ ===")
|
||||
contests_table_ready = await create_referral_contests_table()
|
||||
if contests_table_ready:
|
||||
logger.info("✅ Таблица referral_contests готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей referral_contests")
|
||||
|
||||
contest_events_ready = await create_referral_contest_events_table()
|
||||
if contest_events_ready:
|
||||
logger.info("✅ Таблица referral_contest_events готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей referral_contest_events")
|
||||
|
||||
contest_type_ready = await ensure_referral_contest_type_column()
|
||||
if contest_type_ready:
|
||||
logger.info("✅ Колонка contest_type для referral_contests готова")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось добавить contest_type в referral_contests")
|
||||
|
||||
contest_summary_ready = await ensure_referral_contest_summary_columns()
|
||||
if contest_summary_ready:
|
||||
logger.info("✅ Колонки daily_summary_times/last_daily_summary_at готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить колонки сводок для referral_contests")
|
||||
|
||||
contest_templates_ready = await create_contest_templates_table()
|
||||
if contest_templates_ready:
|
||||
logger.info("✅ Таблица contest_templates готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей contest_templates")
|
||||
|
||||
contest_rounds_ready = await create_contest_rounds_table()
|
||||
if contest_rounds_ready:
|
||||
logger.info("✅ Таблица contest_rounds готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей contest_rounds")
|
||||
|
||||
contest_attempts_ready = await create_contest_attempts_table()
|
||||
if contest_attempts_ready:
|
||||
logger.info("✅ Таблица contest_attempts готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей contest_attempts")
|
||||
|
||||
user_discount_columns_ready = await ensure_user_promo_offer_discount_columns()
|
||||
if user_discount_columns_ready:
|
||||
logger.info("✅ Колонки пользовательских промо-скидок готовы")
|
||||
@@ -4263,6 +4905,14 @@ async def check_migration_status():
|
||||
"discount_offers_table": False,
|
||||
"discount_offers_effect_column": False,
|
||||
"discount_offers_extra_column": False,
|
||||
"referral_contests_table": False,
|
||||
"referral_contest_events_table": False,
|
||||
"referral_contest_type_column": False,
|
||||
"referral_contest_summary_times_column": False,
|
||||
"referral_contest_last_summary_at_column": False,
|
||||
"contest_templates_table": False,
|
||||
"contest_rounds_table": False,
|
||||
"contest_attempts_table": False,
|
||||
"promo_offer_templates_table": False,
|
||||
"promo_offer_templates_active_discount_column": False,
|
||||
"promo_offer_logs_table": False,
|
||||
@@ -4286,6 +4936,14 @@ async def check_migration_status():
|
||||
status["discount_offers_table"] = await check_table_exists('discount_offers')
|
||||
status["discount_offers_effect_column"] = await check_column_exists('discount_offers', 'effect_type')
|
||||
status["discount_offers_extra_column"] = await check_column_exists('discount_offers', 'extra_data')
|
||||
status["referral_contests_table"] = await check_table_exists('referral_contests')
|
||||
status["referral_contest_events_table"] = await check_table_exists('referral_contest_events')
|
||||
status["referral_contest_type_column"] = await check_column_exists('referral_contests', 'contest_type')
|
||||
status["referral_contest_summary_times_column"] = await check_column_exists('referral_contests', 'daily_summary_times')
|
||||
status["referral_contest_last_summary_at_column"] = await check_column_exists('referral_contests', 'last_daily_summary_at')
|
||||
status["contest_templates_table"] = await check_table_exists('contest_templates')
|
||||
status["contest_rounds_table"] = await check_table_exists('contest_rounds')
|
||||
status["contest_attempts_table"] = await check_table_exists('contest_attempts')
|
||||
status["promo_offer_templates_table"] = await check_table_exists('promo_offer_templates')
|
||||
status["promo_offer_templates_active_discount_column"] = await check_column_exists('promo_offer_templates', 'active_discount_hours')
|
||||
status["promo_offer_logs_table"] = await check_table_exists('promo_offer_logs')
|
||||
@@ -4354,6 +5012,14 @@ async def check_migration_status():
|
||||
"discount_offers_table": "Таблица discount_offers",
|
||||
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
|
||||
"discount_offers_extra_column": "Колонка extra_data в discount_offers",
|
||||
"referral_contests_table": "Таблица referral_contests",
|
||||
"referral_contest_events_table": "Таблица referral_contest_events",
|
||||
"referral_contest_type_column": "Колонка contest_type в referral_contests",
|
||||
"referral_contest_summary_times_column": "Колонка daily_summary_times в referral_contests",
|
||||
"referral_contest_last_summary_at_column": "Колонка last_daily_summary_at в referral_contests",
|
||||
"contest_templates_table": "Таблица contest_templates",
|
||||
"contest_rounds_table": "Таблица contest_rounds",
|
||||
"contest_attempts_table": "Таблица contest_attempts",
|
||||
"promo_offer_templates_table": "Таблица promo_offer_templates",
|
||||
"promo_offer_templates_active_discount_column": "Колонка active_discount_hours в promo_offer_templates",
|
||||
"promo_offer_logs_table": "Таблица promo_offer_logs",
|
||||
|
||||
Vendored
+254
-31
@@ -189,6 +189,33 @@ class SubscriptionInfo:
|
||||
happ_crypto_link: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubscriptionPageConfig:
|
||||
"""Конфигурация страницы подписки"""
|
||||
uuid: str
|
||||
name: str
|
||||
view_position: int
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveExternalSquad:
|
||||
"""Структура External Squad"""
|
||||
uuid: str
|
||||
name: str
|
||||
view_position: int
|
||||
members_count: int
|
||||
templates: List[Dict[str, str]]
|
||||
subscription_settings: Optional[Dict[str, Any]] = None
|
||||
host_overrides: Optional[Dict[str, Any]] = None
|
||||
response_headers: Optional[Dict[str, str]] = None
|
||||
hwid_settings: Optional[Dict[str, Any]] = None
|
||||
custom_remarks: Optional[Dict[str, Any]] = None
|
||||
subpage_config_uuid: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class RemnaWaveAPIError(Exception):
|
||||
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
|
||||
self.message = message
|
||||
@@ -497,15 +524,6 @@ class RemnaWaveAPI:
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает список всех пользователей.
|
||||
|
||||
Args:
|
||||
start: Смещение для пагинации
|
||||
size: Размер страницы
|
||||
enrich_happ_links: Если True, для каждого пользователя будет запрошена
|
||||
зашифрованная happ ссылка (медленно для больших списков)
|
||||
"""
|
||||
params = {'start': start, 'size': size}
|
||||
response = await self._make_request('GET', '/api/users', params=params)
|
||||
|
||||
@@ -590,6 +608,100 @@ class RemnaWaveAPI:
|
||||
response = await self._make_request('POST', '/api/internal-squads/actions/reorder', data)
|
||||
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
|
||||
|
||||
# ============== External Squads API ==============
|
||||
|
||||
async def get_external_squads(self) -> List[RemnaWaveExternalSquad]:
|
||||
"""Получает список всех External Squads"""
|
||||
response = await self._make_request('GET', '/api/external-squads')
|
||||
return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']]
|
||||
|
||||
async def get_external_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveExternalSquad]:
|
||||
"""Получает External Squad по UUID"""
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/external-squads/{uuid}')
|
||||
return self._parse_external_squad(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def create_external_squad(self, name: str) -> RemnaWaveExternalSquad:
|
||||
data = {'name': name}
|
||||
response = await self._make_request('POST', '/api/external-squads', data)
|
||||
return self._parse_external_squad(response['response'])
|
||||
|
||||
async def update_external_squad(
|
||||
self,
|
||||
uuid: str,
|
||||
name: Optional[str] = None,
|
||||
templates: Optional[List[Dict[str, str]]] = None,
|
||||
subscription_settings: Optional[Dict[str, Any]] = None,
|
||||
host_overrides: Optional[Dict[str, Any]] = None,
|
||||
response_headers: Optional[Dict[str, str]] = None,
|
||||
hwid_settings: Optional[Dict[str, Any]] = None,
|
||||
custom_remarks: Optional[Dict[str, Any]] = None,
|
||||
subpage_config_uuid: Optional[str] = None
|
||||
) -> RemnaWaveExternalSquad:
|
||||
data = {'uuid': uuid}
|
||||
if name is not None:
|
||||
data['name'] = name
|
||||
if templates is not None:
|
||||
data['templates'] = templates
|
||||
if subscription_settings is not None:
|
||||
data['subscriptionSettings'] = subscription_settings
|
||||
if host_overrides is not None:
|
||||
data['hostOverrides'] = host_overrides
|
||||
if response_headers is not None:
|
||||
data['responseHeaders'] = response_headers
|
||||
if hwid_settings is not None:
|
||||
data['hwidSettings'] = hwid_settings
|
||||
if custom_remarks is not None:
|
||||
data['customRemarks'] = custom_remarks
|
||||
if subpage_config_uuid is not None:
|
||||
data['subpageConfigUuid'] = subpage_config_uuid
|
||||
|
||||
response = await self._make_request('PATCH', '/api/external-squads', data)
|
||||
return self._parse_external_squad(response['response'])
|
||||
|
||||
async def delete_external_squad(self, uuid: str) -> bool:
|
||||
"""Удаляет External Squad"""
|
||||
response = await self._make_request('DELETE', f'/api/external-squads/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
async def add_users_to_external_squad(self, uuid: str) -> bool:
|
||||
"""Добавляет всех пользователей в External Squad (bulk action)"""
|
||||
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/add-users')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def remove_users_from_external_squad(self, uuid: str) -> bool:
|
||||
"""Удаляет всех пользователей из External Squad (bulk action)"""
|
||||
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/remove-users')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def reorder_external_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveExternalSquad]:
|
||||
data = {'items': items}
|
||||
response = await self._make_request('POST', '/api/external-squads/actions/reorder', data)
|
||||
return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']]
|
||||
|
||||
def _parse_external_squad(self, squad_data: Dict) -> RemnaWaveExternalSquad:
|
||||
"""Парсит данные External Squad"""
|
||||
info = squad_data.get('info', {})
|
||||
return RemnaWaveExternalSquad(
|
||||
uuid=squad_data['uuid'],
|
||||
name=squad_data['name'],
|
||||
view_position=squad_data.get('viewPosition', 0),
|
||||
members_count=info.get('membersCount', 0),
|
||||
templates=squad_data.get('templates', []),
|
||||
subscription_settings=squad_data.get('subscriptionSettings'),
|
||||
host_overrides=squad_data.get('hostOverrides'),
|
||||
response_headers=squad_data.get('responseHeaders'),
|
||||
hwid_settings=squad_data.get('hwidSettings'),
|
||||
custom_remarks=squad_data.get('customRemarks'),
|
||||
subpage_config_uuid=squad_data.get('subpageConfigUuid'),
|
||||
created_at=self._parse_optional_datetime(squad_data.get('createdAt')),
|
||||
updated_at=self._parse_optional_datetime(squad_data.get('updatedAt'))
|
||||
)
|
||||
|
||||
|
||||
async def get_all_nodes(self) -> List[RemnaWaveNode]:
|
||||
response = await self._make_request('GET', '/api/nodes')
|
||||
@@ -683,29 +795,148 @@ class RemnaWaveAPI:
|
||||
return response['response']
|
||||
|
||||
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
|
||||
response = await self._make_request('GET', '/api/nodes/usage/realtime')
|
||||
return response['response']
|
||||
return await self.get_bandwidth_stats_nodes_realtime()
|
||||
|
||||
async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает статистику использования трафика пользователем за указанный период
|
||||
return await self.get_bandwidth_stats_user_legacy(user_uuid, start_date, end_date)
|
||||
|
||||
Args:
|
||||
user_uuid: UUID пользователя
|
||||
start_date: Начальная дата в формате ISO (например, "2025-12-09T00:00:00.000Z")
|
||||
end_date: Конечная дата в формате ISO (например, "2025-12-09T23:59:59.999Z")
|
||||
# ============== Bandwidth Stats API ==============
|
||||
|
||||
Returns:
|
||||
Словарь с информацией о трафике пользователя за указанный период
|
||||
"""
|
||||
async def get_bandwidth_stats_nodes(self, start_date: str, end_date: str) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date
|
||||
}
|
||||
response = await self._make_request('GET', f'/api/users/stats/usage/{user_uuid}/range', params=params)
|
||||
response = await self._make_request('GET', '/api/bandwidth-stats/nodes', params=params)
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_nodes_realtime(self) -> List[Dict[str, Any]]:
|
||||
response = await self._make_request('GET', '/api/bandwidth-stats/nodes/realtime')
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_node_users(
|
||||
self,
|
||||
node_uuid: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
top_users_limit: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date,
|
||||
'topUsersLimit': top_users_limit
|
||||
}
|
||||
response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users', params=params)
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_node_users_legacy(
|
||||
self,
|
||||
node_uuid: str,
|
||||
start_date: str,
|
||||
end_date: str
|
||||
) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date
|
||||
}
|
||||
response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy', params=params)
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_user(
|
||||
self,
|
||||
user_uuid: str,
|
||||
start_date: str,
|
||||
end_date: str
|
||||
) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date
|
||||
}
|
||||
response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}', params=params)
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_user_legacy(
|
||||
self,
|
||||
user_uuid: str,
|
||||
start_date: str,
|
||||
end_date: str
|
||||
) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date
|
||||
}
|
||||
response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}/legacy', params=params)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
# ============== Subscription Page Configs API ==============
|
||||
|
||||
async def get_subscription_page_configs(self) -> List[SubscriptionPageConfig]:
|
||||
response = await self._make_request('GET', '/api/subscription-page-configs')
|
||||
configs_data = response['response'].get('configs', [])
|
||||
return [self._parse_subscription_page_config(c) for c in configs_data]
|
||||
|
||||
async def get_subscription_page_config(self, uuid: str) -> Optional[SubscriptionPageConfig]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/subscription-page-configs/{uuid}')
|
||||
return self._parse_subscription_page_config(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def create_subscription_page_config(self, name: str) -> SubscriptionPageConfig:
|
||||
data = {'name': name}
|
||||
response = await self._make_request('POST', '/api/subscription-page-configs', data)
|
||||
return self._parse_subscription_page_config(response['response'])
|
||||
|
||||
async def update_subscription_page_config(
|
||||
self,
|
||||
uuid: str,
|
||||
name: Optional[str] = None,
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
) -> SubscriptionPageConfig:
|
||||
data = {'uuid': uuid}
|
||||
if name is not None:
|
||||
data['name'] = name
|
||||
if config is not None:
|
||||
data['config'] = config
|
||||
response = await self._make_request('PATCH', '/api/subscription-page-configs', data)
|
||||
return self._parse_subscription_page_config(response['response'])
|
||||
|
||||
async def delete_subscription_page_config(self, uuid: str) -> bool:
|
||||
response = await self._make_request('DELETE', f'/api/subscription-page-configs/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
async def reorder_subscription_page_configs(self, items: List[Dict[str, Any]]) -> List[SubscriptionPageConfig]:
|
||||
data = {'items': items}
|
||||
response = await self._make_request('POST', '/api/subscription-page-configs/actions/reorder', data)
|
||||
configs_data = response['response'].get('configs', [])
|
||||
return [self._parse_subscription_page_config(c) for c in configs_data]
|
||||
|
||||
async def clone_subscription_page_config(self, clone_from_uuid: str) -> SubscriptionPageConfig:
|
||||
data = {'cloneFromUuid': clone_from_uuid}
|
||||
response = await self._make_request('POST', '/api/subscription-page-configs/actions/clone', data)
|
||||
return self._parse_subscription_page_config(response['response'])
|
||||
|
||||
async def get_subpage_config_by_short_uuid(self, short_uuid: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/subscriptions/subpage-config/{short_uuid}')
|
||||
return response.get('response')
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
def _parse_subscription_page_config(self, data: Dict) -> SubscriptionPageConfig:
|
||||
"""Парсит данные конфигурации страницы подписки"""
|
||||
return SubscriptionPageConfig(
|
||||
uuid=data['uuid'],
|
||||
name=data['name'],
|
||||
view_position=data['viewPosition'],
|
||||
config=data.get('config')
|
||||
)
|
||||
|
||||
|
||||
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
|
||||
@@ -756,10 +987,6 @@ class RemnaWaveAPI:
|
||||
return False
|
||||
|
||||
async def encrypt_happ_crypto_link(self, link_to_encrypt: str) -> Optional[str]:
|
||||
"""
|
||||
Шифрует ссылку подписки через API Remnawave.
|
||||
Возвращает зашифрованную happ:// ссылку или None при ошибке.
|
||||
"""
|
||||
try:
|
||||
data = {"linkToEncrypt": link_to_encrypt}
|
||||
response = await self._make_request('POST', '/api/system/tools/happ/encrypt', data)
|
||||
@@ -772,10 +999,6 @@ class RemnaWaveAPI:
|
||||
return None
|
||||
|
||||
async def enrich_user_with_happ_link(self, user: RemnaWaveUser) -> RemnaWaveUser:
|
||||
"""
|
||||
Обогащает объект пользователя зашифрованной happ ссылкой,
|
||||
если она отсутствует но есть subscription_url.
|
||||
"""
|
||||
if not user.happ_crypto_link and user.subscription_url:
|
||||
encrypted = await self.encrypt_happ_crypto_link(user.subscription_url)
|
||||
if encrypted:
|
||||
|
||||
@@ -5,6 +5,8 @@ from . import (
|
||||
bot_configuration,
|
||||
bulk_ban,
|
||||
campaigns,
|
||||
daily_contests,
|
||||
contests,
|
||||
faq,
|
||||
main,
|
||||
maintenance,
|
||||
@@ -33,4 +35,4 @@ from . import (
|
||||
user_messages,
|
||||
users,
|
||||
welcome_text,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
"""
|
||||
import logging
|
||||
from aiogram import types
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.states import BlacklistStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.keyboards.admin import get_admin_users_keyboard
|
||||
|
||||
@@ -253,7 +255,7 @@ async def start_set_blacklist_url(
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state("waiting_for_blacklist_url")
|
||||
await state.set_state(BlacklistStates.waiting_for_blacklist_url)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -267,6 +269,10 @@ async def process_blacklist_url(
|
||||
"""
|
||||
Обрабатывает введенный URL к черному списку
|
||||
"""
|
||||
# Обрабатываем сообщение только если бот ожидает ввод URL
|
||||
if await state.get_state() != BlacklistStates.waiting_for_blacklist_url.state:
|
||||
return
|
||||
|
||||
url = message.text.strip()
|
||||
|
||||
# В реальной реализации нужно сохранить URL в систему настроек
|
||||
@@ -361,5 +367,5 @@ def register_blacklist_handlers(dp):
|
||||
# Обработчик сообщений для установки URL (работает только в нужном состоянии)
|
||||
dp.message.register(
|
||||
process_blacklist_url,
|
||||
lambda m: True # Фильтр будет внутри функции
|
||||
StateFilter(BlacklistStates.waiting_for_blacklist_url)
|
||||
)
|
||||
|
||||
@@ -1984,7 +1984,7 @@ async def test_payment_provider(
|
||||
return
|
||||
|
||||
amount_kopeks = 10 * 100
|
||||
description = settings.get_balance_payment_description(amount_kopeks)
|
||||
description = settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
|
||||
payment_result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
|
||||
@@ -0,0 +1,784 @@
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone, time
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.referral_contest import (
|
||||
create_referral_contest,
|
||||
get_contest_events_count,
|
||||
get_contest_leaderboard,
|
||||
get_referral_contest,
|
||||
get_referral_contests_count,
|
||||
list_referral_contests,
|
||||
toggle_referral_contest,
|
||||
update_referral_contest,
|
||||
delete_referral_contest,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_contests_keyboard,
|
||||
get_admin_contests_root_keyboard,
|
||||
get_admin_pagination_keyboard,
|
||||
get_contest_mode_keyboard,
|
||||
get_referral_contest_manage_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PAGE_SIZE = 5
|
||||
|
||||
|
||||
def _ensure_timezone(tz_name: str) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name)
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def _format_contest_summary(contest, texts, tz: ZoneInfo) -> str:
|
||||
start_local = contest.start_at if contest.start_at.tzinfo else contest.start_at.replace(tzinfo=timezone.utc)
|
||||
end_local = contest.end_at if contest.end_at.tzinfo else contest.end_at.replace(tzinfo=timezone.utc)
|
||||
start_local = start_local.astimezone(tz)
|
||||
end_local = end_local.astimezone(tz)
|
||||
|
||||
status = texts.t("ADMIN_CONTEST_STATUS_ACTIVE", "🟢 Активен") if contest.is_active else texts.t(
|
||||
"ADMIN_CONTEST_STATUS_INACTIVE", "⚪️ Выключен"
|
||||
)
|
||||
|
||||
period = (
|
||||
f"{start_local.strftime('%d.%m %H:%M')} — "
|
||||
f"{end_local.strftime('%d.%m %H:%M')} ({tz.key})"
|
||||
)
|
||||
|
||||
summary_time = contest.daily_summary_time.strftime("%H:%M") if contest.daily_summary_time else "12:00"
|
||||
summary_times = contest.daily_summary_times or summary_time
|
||||
parts = [
|
||||
f"{status}",
|
||||
f"Период: <b>{period}</b>",
|
||||
f"Дневная сводка: <b>{summary_times}</b>",
|
||||
]
|
||||
if contest.prize_text:
|
||||
parts.append(texts.t("ADMIN_CONTEST_PRIZE", "Приз: {prize}").format(prize=contest.prize_text))
|
||||
if contest.last_daily_summary_date:
|
||||
parts.append(
|
||||
texts.t("ADMIN_CONTEST_LAST_DAILY", "Последняя сводка: {date}").format(
|
||||
date=contest.last_daily_summary_date.strftime("%d.%m")
|
||||
)
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _parse_local_datetime(value: str, tz: ZoneInfo) -> datetime | None:
|
||||
try:
|
||||
dt = datetime.strptime(value.strip(), "%d.%m.%Y %H:%M")
|
||||
except ValueError:
|
||||
return None
|
||||
return dt.replace(tzinfo=tz)
|
||||
|
||||
|
||||
def _parse_time(value: str):
|
||||
try:
|
||||
return datetime.strptime(value.strip(), "%H:%M").time()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_times(value: str) -> list[time]:
|
||||
times: list[time] = []
|
||||
for part in value.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
parsed = _parse_time(part)
|
||||
if parsed:
|
||||
times.append(parsed)
|
||||
return times
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_contests_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_CONTESTS_DISABLED",
|
||||
"Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
|
||||
),
|
||||
reply_markup=get_admin_contests_root_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_CONTESTS_TITLE", "🏆 <b>Конкурсы</b>\n\nВыберите действие:"),
|
||||
reply_markup=get_admin_contests_root_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_referral_contests_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_CONTESTS_TITLE", "🏆 <b>Конкурсы</b>\n\nВыберите действие:"),
|
||||
reply_markup=get_admin_contests_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def list_contests(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t(
|
||||
"ADMIN_CONTESTS_DISABLED",
|
||||
"Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
page = 1
|
||||
if callback.data.startswith("admin_contests_list_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except Exception:
|
||||
page = 1
|
||||
|
||||
total = await get_referral_contests_count(db)
|
||||
total_pages = max(1, math.ceil(total / PAGE_SIZE))
|
||||
page = max(1, min(page, total_pages))
|
||||
offset = (page - 1) * PAGE_SIZE
|
||||
|
||||
contests = await list_referral_contests(db, limit=PAGE_SIZE, offset=offset)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
lines = [texts.t("ADMIN_CONTESTS_LIST_HEADER", "🏆 <b>Конкурсы</b>\n")]
|
||||
|
||||
if not contests:
|
||||
lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов."))
|
||||
else:
|
||||
for contest in contests:
|
||||
lines.append(f"• <b>{contest.title}</b> (#{contest.id})")
|
||||
contest_tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
|
||||
lines.append(_format_contest_summary(contest, texts, contest_tz))
|
||||
lines.append("")
|
||||
|
||||
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
for contest in contests:
|
||||
title = contest.title if len(contest.title) <= 25 else contest.title[:22] + "..."
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"🔎 {title}",
|
||||
callback_data=f"admin_contest_view_{contest.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
pagination = get_admin_pagination_keyboard(
|
||||
page,
|
||||
total_pages,
|
||||
"admin_contests_list",
|
||||
back_callback="admin_contests",
|
||||
language=db_user.language,
|
||||
)
|
||||
keyboard_rows.extend(pagination.inline_keyboard)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_contest_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not contest:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
|
||||
leaderboard = await get_contest_leaderboard(db, contest.id, limit=5)
|
||||
total_events = await get_contest_events_count(db, contest.id)
|
||||
|
||||
lines = [
|
||||
f"🏆 <b>{contest.title}</b>",
|
||||
_format_contest_summary(contest, texts, tz),
|
||||
texts.t("ADMIN_CONTEST_TOTAL_EVENTS", "Зачётов: <b>{count}</b>").format(count=total_events),
|
||||
]
|
||||
|
||||
if contest.description:
|
||||
lines.append("")
|
||||
lines.append(contest.description)
|
||||
|
||||
if leaderboard:
|
||||
lines.append("")
|
||||
lines.append(texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"))
|
||||
for idx, (user, score, _) in enumerate(leaderboard, start=1):
|
||||
lines.append(f"{idx}. {user.full_name} — {score}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
contest.id,
|
||||
is_active=contest.is_active,
|
||||
can_delete=(
|
||||
not contest.is_active
|
||||
and (contest.end_at.replace(tzinfo=timezone.utc) if contest.end_at.tzinfo is None else contest.end_at)
|
||||
< datetime.now(timezone.utc)
|
||||
),
|
||||
language=db_user.language,
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_contest(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
|
||||
if not contest:
|
||||
await callback.answer("Конкурс не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await toggle_referral_contest(db, contest, not contest.is_active)
|
||||
await show_contest_details(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def prompt_edit_summary_times(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
await state.set_state(AdminStates.editing_referral_contest_summary_times)
|
||||
await state.update_data(contest_id=contest_id)
|
||||
kb = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_contest_view_{contest_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_ENTER_DAILY_TIME",
|
||||
"Во сколько отправлять ежедневные итоги? Формат ЧЧ:ММ или несколько через запятую (12:00,18:00).",
|
||||
),
|
||||
reply_markup=kb,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_summary_times(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
contest_id = data.get("contest_id")
|
||||
if not contest_id:
|
||||
await message.answer(texts.ERROR)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
times = _parse_times(message.text or "")
|
||||
summary_time = times[0] if times else _parse_time(message.text or "")
|
||||
if not summary_time:
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_INVALID_TIME", "Не удалось распознать время. Формат: 12:00 или 12:00,18:00")
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
contest = await get_referral_contest(db, int(contest_id))
|
||||
if not contest:
|
||||
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_referral_contest(
|
||||
db,
|
||||
contest,
|
||||
daily_summary_time=summary_time,
|
||||
daily_summary_times=",".join(t.strftime("%H:%M") for t in times) if times else None,
|
||||
)
|
||||
|
||||
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"))
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_contest(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
if contest.is_active or contest.end_at > now_utc:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_CONTEST_DELETE_RESTRICT", "Удалять можно только завершённые конкурсы."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await delete_referral_contest(db, contest)
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_DELETED", "Конкурс удалён."), show_alert=True)
|
||||
await list_contests(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_leaderboard(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not contest:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
leaderboard = await get_contest_leaderboard(db, contest_id, limit=10)
|
||||
if not leaderboard:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_EMPTY_LEADERBOARD", "Пока нет участников."), show_alert=True)
|
||||
return
|
||||
|
||||
lines = [
|
||||
texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"),
|
||||
]
|
||||
for idx, (user, score, _) in enumerate(leaderboard, start=1):
|
||||
lines.append(f"{idx}. {user.full_name} ({user.telegram_id}) — {score}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
contest_id, is_active=contest.is_active, language=db_user.language
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_contest_creation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
await state.set_state(AdminStates.creating_referral_contest_mode)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_MODE_PROMPT",
|
||||
"Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.",
|
||||
),
|
||||
reply_markup=get_contest_mode_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_contest_mode(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
mode = "referral_paid" if callback.data == "admin_contest_mode_paid" else "referral_registered"
|
||||
await state.update_data(contest_type=mode)
|
||||
await state.set_state(AdminStates.creating_referral_contest_title)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_CONTEST_ENTER_TITLE", "Введите название конкурса:"),
|
||||
reply_markup=None,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_title(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
title = message.text.strip()
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await state.update_data(title=title)
|
||||
await state.set_state(AdminStates.creating_referral_contest_description)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_ENTER_DESCRIPTION", "Опишите конкурс (или отправьте '-' чтобы пропустить):")
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_description(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
description = message.text.strip()
|
||||
if description in {"-", "skip", "пропустить"}:
|
||||
description = None
|
||||
|
||||
await state.update_data(description=description)
|
||||
await state.set_state(AdminStates.creating_referral_contest_prize)
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_ENTER_PRIZE", "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):")
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_prize(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
prize = message.text.strip()
|
||||
if prize in {"-", "skip", "пропустить"}:
|
||||
prize = None
|
||||
|
||||
await state.update_data(prize=prize)
|
||||
await state.set_state(AdminStates.creating_referral_contest_start)
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_ENTER_START",
|
||||
"Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_start_date(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
tz = _ensure_timezone(settings.TIMEZONE)
|
||||
start_dt = _parse_local_datetime(message.text, tz)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not start_dt:
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00")
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(start_at=start_dt.isoformat())
|
||||
await state.set_state(AdminStates.creating_referral_contest_end)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_ENTER_END",
|
||||
"Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_end_date(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
tz = _ensure_timezone(settings.TIMEZONE)
|
||||
end_dt = _parse_local_datetime(message.text, tz)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not end_dt:
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00")
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
start_raw = data.get("start_at")
|
||||
start_dt = datetime.fromisoformat(start_raw) if start_raw else None
|
||||
if start_dt and end_dt <= start_dt:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_END_BEFORE_START",
|
||||
"Дата окончания должна быть позже даты начала.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(end_at=end_dt.isoformat())
|
||||
await state.set_state(AdminStates.creating_referral_contest_time)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_ENTER_DAILY_TIME",
|
||||
"Во сколько отправлять ежедневные итоги? Укажите время в формате ЧЧ:ММ (например, 12:00).",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def finalize_contest_creation(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
times = _parse_times(message.text or "")
|
||||
summary_time = times[0] if times else _parse_time(message.text)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not summary_time:
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_INVALID_TIME", "Не удалось распознать время. Формат: 12:00 или 12:00,18:00")
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
tz = _ensure_timezone(settings.TIMEZONE)
|
||||
|
||||
start_at_raw = data.get("start_at")
|
||||
end_at_raw = data.get("end_at")
|
||||
if not start_at_raw or not end_at_raw:
|
||||
await message.answer(texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату."))
|
||||
return
|
||||
|
||||
start_at = (
|
||||
datetime.fromisoformat(start_at_raw)
|
||||
.astimezone(timezone.utc)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
end_at = (
|
||||
datetime.fromisoformat(end_at_raw)
|
||||
.astimezone(timezone.utc)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
|
||||
contest_type = data.get("contest_type") or "referral_paid"
|
||||
|
||||
contest = await create_referral_contest(
|
||||
db,
|
||||
title=data.get("title"),
|
||||
description=data.get("description"),
|
||||
prize_text=data.get("prize"),
|
||||
contest_type=contest_type,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
daily_summary_time=summary_time,
|
||||
daily_summary_times=",".join(t.strftime("%H:%M") for t in times) if times else None,
|
||||
timezone_name=tz.key,
|
||||
created_by=db_user.id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_CREATED", "Конкурс создан!"),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
contest.id,
|
||||
is_active=contest.is_active,
|
||||
language=db_user.language,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_detailed_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
|
||||
if not contest:
|
||||
await callback.answer("Конкурс не найден.", show_alert=True)
|
||||
return
|
||||
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||||
|
||||
# Общее сообщение с основной статистикой
|
||||
general_lines = [
|
||||
"📈 <b>Статистика конкурса</b>",
|
||||
f"🏆 {contest.title}",
|
||||
"",
|
||||
f"👥 Участников: <b>{stats['total_participants']}</b>",
|
||||
f"📨 Приглашено рефералов: <b>{stats['total_invited']}</b>",
|
||||
f"💰 Оплатили подписок: <b>{stats['total_paid_amount'] // 100} руб.</b>",
|
||||
f"❌ Не оплатили: <b>{stats['total_unpaid']}</b>",
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(general_lines),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
contest_id, is_active=contest.is_active, language=db_user.language
|
||||
),
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_detailed_stats_page(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
contest_id: int = None,
|
||||
page: int = 1,
|
||||
stats: dict = None,
|
||||
):
|
||||
if contest_id is None or stats is None:
|
||||
# Парсим из callback.data: admin_contest_detailed_stats_page_{contest_id}_page_{page}
|
||||
parts = callback.data.split("_")
|
||||
contest_id = int(parts[5]) # contest_id после page
|
||||
page = int(parts[7]) # page после второго page
|
||||
|
||||
# Получаем stats если не переданы
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||||
|
||||
participants = stats['participants']
|
||||
total_participants = len(participants)
|
||||
PAGE_SIZE = 10
|
||||
total_pages = math.ceil(total_participants / PAGE_SIZE)
|
||||
|
||||
page = max(1, min(page, total_pages))
|
||||
offset = (page - 1) * PAGE_SIZE
|
||||
page_participants = participants[offset:offset + PAGE_SIZE]
|
||||
|
||||
lines = [f"📊 По участникам (страница {page}/{total_pages}):"]
|
||||
for p in page_participants:
|
||||
lines.extend([
|
||||
f"• <b>{p['full_name']}</b>",
|
||||
f" 📨 Приглашено: {p['total_referrals']}",
|
||||
f" 💰 Оплатили: {p['paid_referrals']}",
|
||||
f" ❌ Не оплатили: {p['unpaid_referrals']}",
|
||||
f" 💵 Сумма: {p['total_paid_amount'] // 100} руб.",
|
||||
"" # Пустая строка для разделения
|
||||
])
|
||||
|
||||
pagination = get_admin_pagination_keyboard(
|
||||
page,
|
||||
total_pages,
|
||||
f"admin_contest_detailed_stats_page_{contest_id}",
|
||||
back_callback=f"admin_contest_view_{contest_id}",
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=pagination,
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_contests_menu, F.data == "admin_contests")
|
||||
dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral")
|
||||
dp.callback_query.register(list_contests, F.data == "admin_contests_list")
|
||||
dp.callback_query.register(list_contests, F.data.startswith("admin_contests_list_page_"))
|
||||
dp.callback_query.register(show_contest_details, F.data.startswith("admin_contest_view_"))
|
||||
dp.callback_query.register(toggle_contest, F.data.startswith("admin_contest_toggle_"))
|
||||
dp.callback_query.register(prompt_edit_summary_times, F.data.startswith("admin_contest_edit_times_"))
|
||||
dp.callback_query.register(delete_contest, F.data.startswith("admin_contest_delete_"))
|
||||
dp.callback_query.register(show_leaderboard, F.data.startswith("admin_contest_leaderboard_"))
|
||||
dp.callback_query.register(show_detailed_stats, F.data.startswith("admin_contest_detailed_stats_"))
|
||||
dp.callback_query.register(show_detailed_stats_page, F.data.startswith("admin_contest_detailed_stats_page_"))
|
||||
dp.callback_query.register(start_contest_creation, F.data == "admin_contests_create")
|
||||
dp.callback_query.register(select_contest_mode, F.data.in_(["admin_contest_mode_paid", "admin_contest_mode_registered"]))
|
||||
|
||||
dp.message.register(process_title, AdminStates.creating_referral_contest_title)
|
||||
dp.message.register(process_description, AdminStates.creating_referral_contest_description)
|
||||
dp.message.register(process_prize, AdminStates.creating_referral_contest_prize)
|
||||
dp.message.register(process_start_date, AdminStates.creating_referral_contest_start)
|
||||
dp.message.register(process_end_date, AdminStates.creating_referral_contest_end)
|
||||
dp.message.register(finalize_contest_creation, AdminStates.creating_referral_contest_time)
|
||||
dp.message.register(process_edit_summary_times, AdminStates.editing_referral_contest_summary_times)
|
||||
@@ -0,0 +1,550 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
get_template_by_id,
|
||||
list_templates,
|
||||
update_template_fields,
|
||||
create_round,
|
||||
)
|
||||
from app.database.models import ContestTemplate
|
||||
from app.keyboards.admin import (
|
||||
get_admin_contests_keyboard,
|
||||
get_admin_contests_root_keyboard,
|
||||
get_daily_contest_manage_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.contest_rotation_service import contest_rotation_service
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EDITABLE_FIELDS: Dict[str, Dict] = {
|
||||
"prize_days": {"type": int, "min": 1, "label": "приз (дни)"},
|
||||
"max_winners": {"type": int, "min": 1, "label": "макс. победителей"},
|
||||
"attempts_per_user": {"type": int, "min": 1, "label": "попыток на пользователя"},
|
||||
"times_per_day": {"type": int, "min": 1, "label": "раундов в день"},
|
||||
"schedule_times": {"type": str, "label": "расписание HH:MM через запятую"},
|
||||
"cooldown_hours": {"type": int, "min": 1, "label": "длительность раунда (часы)"},
|
||||
}
|
||||
|
||||
|
||||
async def _get_template(db: AsyncSession, template_id: int) -> ContestTemplate | None:
|
||||
return await get_template_by_id(db, template_id)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_daily_contests(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
templates = await list_templates(db, enabled_only=False)
|
||||
|
||||
lines = [texts.t("ADMIN_DAILY_CONTESTS_TITLE", "📆 Ежедневные конкурсы")]
|
||||
if not templates:
|
||||
lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов."))
|
||||
else:
|
||||
for tpl in templates:
|
||||
status = "🟢" if tpl.is_enabled else "⚪️"
|
||||
lines.append(f"{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {tpl.prize_days}д, макс {tpl.max_winners}")
|
||||
|
||||
keyboard_rows = []
|
||||
if templates:
|
||||
keyboard_rows.append([types.InlineKeyboardButton(text="❌ Закрыть все активные раунды", callback_data="admin_daily_close_all")])
|
||||
keyboard_rows.append([types.InlineKeyboardButton(text="� Сбросить попытки во всех активных раундах", callback_data="admin_daily_reset_all_attempts")])
|
||||
keyboard_rows.append([types.InlineKeyboardButton(text="� Запустить все активные конкурсы", callback_data="admin_daily_start_all")])
|
||||
for tpl in templates:
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"⚙️ {tpl.name}",
|
||||
callback_data=f"admin_daily_contest_{tpl.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_daily_contest(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
try:
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
except Exception:
|
||||
await callback.answer("Некорректный id", show_alert=True)
|
||||
return
|
||||
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
lines = [
|
||||
f"🏷 <b>{tpl.name}</b> (slug: {tpl.slug})",
|
||||
f"{texts.t('ADMIN_CONTEST_STATUS_ACTIVE','🟢 Активен') if tpl.is_enabled else texts.t('ADMIN_CONTEST_STATUS_INACTIVE','⚪️ Выключен')}",
|
||||
f"Приз: {tpl.prize_days} дн. | Макс победителей: {tpl.max_winners}",
|
||||
f"Попыток/польз: {tpl.attempts_per_user}",
|
||||
f"Раундов в день: {tpl.times_per_day}",
|
||||
f"Расписание: {tpl.schedule_times or '-'}",
|
||||
f"Длительность раунда: {tpl.cooldown_hours} ч.",
|
||||
]
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=get_daily_contest_manage_keyboard(tpl.id, tpl.is_enabled, db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_daily_contest(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
tpl.is_enabled = not tpl.is_enabled
|
||||
await db.commit()
|
||||
await callback.answer(texts.t("ADMIN_UPDATED", "Обновлено"))
|
||||
await show_daily_contest(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_round_now(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
if not tpl.is_enabled:
|
||||
tpl.is_enabled = True
|
||||
await db.commit()
|
||||
await db.refresh(tpl)
|
||||
|
||||
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
|
||||
now = datetime.utcnow()
|
||||
ends = now + timedelta(hours=tpl.cooldown_hours)
|
||||
round_obj = await create_round(
|
||||
db,
|
||||
template=tpl,
|
||||
starts_at=now,
|
||||
ends_at=ends,
|
||||
payload=payload,
|
||||
)
|
||||
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
|
||||
tpl,
|
||||
now.replace(tzinfo=None),
|
||||
ends.replace(tzinfo=None),
|
||||
)
|
||||
await callback.answer(texts.t("ADMIN_ROUND_STARTED", "Раунд запущен"), show_alert=True)
|
||||
await show_daily_contest(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def manual_start_round(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, есть ли уже активный раунд для этого шаблона
|
||||
from app.database.crud.contest import get_active_rounds
|
||||
exists = await get_active_rounds(db, tpl.id)
|
||||
if exists:
|
||||
await callback.answer(texts.t("ADMIN_ROUND_ALREADY_ACTIVE", "Раунд уже активен."), show_alert=True)
|
||||
await show_daily_contest(callback, db_user, db)
|
||||
return
|
||||
|
||||
# Для ручного старта не включаем конкурс, если он выключен
|
||||
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
|
||||
now = datetime.utcnow()
|
||||
ends = now + timedelta(hours=tpl.cooldown_hours)
|
||||
round_obj = await create_round(
|
||||
db,
|
||||
template=tpl,
|
||||
starts_at=now,
|
||||
ends_at=ends,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
# Анонсируем всем пользователям (как тест)
|
||||
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
|
||||
tpl,
|
||||
now.replace(tzinfo=None),
|
||||
ends.replace(tzinfo=None),
|
||||
)
|
||||
await callback.answer(texts.t("ADMIN_ROUND_STARTED", "Тестовый раунд запущен"), show_alert=True)
|
||||
await show_daily_contest(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def prompt_edit_field(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
parts = callback.data.split("_")
|
||||
template_id = int(parts[3])
|
||||
field = "_".join(parts[4:]) # поле может содержать подчеркивания
|
||||
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl or field not in EDITABLE_FIELDS:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
meta = EDITABLE_FIELDS[field]
|
||||
await state.set_state(AdminStates.editing_daily_contest_field)
|
||||
await state.update_data(template_id=template_id, field=field)
|
||||
kb = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_daily_contest_{template_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_FIELD_PROMPT",
|
||||
"Введите новое значение для {label}:",
|
||||
).format(label=meta.get("label", field)),
|
||||
reply_markup=kb,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_field(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
template_id = data.get("template_id")
|
||||
field = data.get("field")
|
||||
if not template_id or not field or field not in EDITABLE_FIELDS:
|
||||
await message.answer(texts.ERROR)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
meta = EDITABLE_FIELDS[field]
|
||||
raw = message.text or ""
|
||||
try:
|
||||
if meta["type"] is int:
|
||||
value = int(raw)
|
||||
if meta.get("min") is not None and value < meta["min"]:
|
||||
raise ValueError("min")
|
||||
else:
|
||||
value = raw.strip()
|
||||
except Exception:
|
||||
await message.answer(texts.t("ADMIN_INVALID_NUMBER", "Некорректное число"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_template_fields(db, tpl, **{field: value})
|
||||
back_kb = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_daily_contest_{template_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=back_kb)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def edit_payload(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_state(AdminStates.editing_daily_contest_value)
|
||||
await state.update_data(template_id=template_id, field="payload")
|
||||
payload_json = json.dumps(tpl.payload or {}, ensure_ascii=False, indent=2)
|
||||
kb = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_daily_contest_{template_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_CONTEST_PAYLOAD_PROMPT", "Отправьте JSON payload для игры (словарь настроек):\n") + f"<code>{payload_json}</code>",
|
||||
reply_markup=kb,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_payload(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
template_id = data.get("template_id")
|
||||
if not template_id:
|
||||
await message.answer(texts.ERROR)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.loads(message.text or "{}")
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError
|
||||
except Exception:
|
||||
await message.answer(texts.t("ADMIN_INVALID_JSON", "Некорректный JSON"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_template_fields(db, tpl, payload=payload)
|
||||
back_kb = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_daily_contest_{template_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=back_kb)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_all_contests(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
templates = await list_templates(db, enabled_only=True)
|
||||
if not templates:
|
||||
await callback.answer(texts.t("ADMIN_CONTESTS_EMPTY", "Нет активных конкурсов."), show_alert=True)
|
||||
return
|
||||
|
||||
started_count = 0
|
||||
for tpl in templates:
|
||||
from app.database.crud.contest import get_active_round_by_template
|
||||
exists = await get_active_round_by_template(db, tpl.id)
|
||||
if exists:
|
||||
continue # уже запущен
|
||||
|
||||
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
|
||||
now = datetime.utcnow()
|
||||
ends = now + timedelta(hours=tpl.cooldown_hours)
|
||||
round_obj = await create_round(
|
||||
db,
|
||||
template=tpl,
|
||||
starts_at=now,
|
||||
ends_at=ends,
|
||||
payload=payload,
|
||||
)
|
||||
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
|
||||
tpl,
|
||||
now.replace(tzinfo=None),
|
||||
ends.replace(tzinfo=None),
|
||||
)
|
||||
started_count += 1
|
||||
|
||||
message = f"Запущено конкурсов: {started_count}"
|
||||
await callback.answer(message, show_alert=True)
|
||||
await show_daily_contests(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def close_all_rounds(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
from app.database.crud.contest import get_active_rounds
|
||||
active_rounds = await get_active_rounds(db)
|
||||
if not active_rounds:
|
||||
await callback.answer("Нет активных раундов", show_alert=True)
|
||||
return
|
||||
|
||||
for rnd in active_rounds:
|
||||
rnd.status = "finished"
|
||||
await db.commit()
|
||||
|
||||
await callback.answer(f"Закрыто раундов: {len(active_rounds)}", show_alert=True)
|
||||
await show_daily_contests(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def reset_all_attempts(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
from app.database.crud.contest import get_active_rounds
|
||||
active_rounds = await get_active_rounds(db)
|
||||
if not active_rounds:
|
||||
await callback.answer("Нет активных раундов", show_alert=True)
|
||||
return
|
||||
|
||||
total_deleted = 0
|
||||
for rnd in active_rounds:
|
||||
deleted = await clear_attempts(db, rnd.id)
|
||||
total_deleted += deleted
|
||||
|
||||
await callback.answer(f"Попытки сброшены: {total_deleted}", show_alert=True)
|
||||
await show_daily_contests(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def reset_attempts(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
from app.database.crud.contest import get_active_round_by_template, clear_attempts
|
||||
round_obj = await get_active_round_by_template(db, tpl.id)
|
||||
if not round_obj:
|
||||
await callback.answer("Нет активного раунда", show_alert=True)
|
||||
return
|
||||
|
||||
deleted_count = await clear_attempts(db, round_obj.id)
|
||||
await callback.answer(f"Попытки сброшены: {deleted_count}", show_alert=True)
|
||||
await show_daily_contest(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def close_round(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
from app.database.crud.contest import get_active_round_by_template
|
||||
round_obj = await get_active_round_by_template(db, tpl.id)
|
||||
if not round_obj:
|
||||
await callback.answer("Нет активного раунда", show_alert=True)
|
||||
return
|
||||
|
||||
round_obj.status = "finished"
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
|
||||
await callback.answer("Раунд закрыт", show_alert=True)
|
||||
await show_daily_contest(callback, db_user, db)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_daily_contests, F.data == "admin_contests_daily")
|
||||
dp.callback_query.register(show_daily_contest, F.data.startswith("admin_daily_contest_"))
|
||||
dp.callback_query.register(toggle_daily_contest, F.data.startswith("admin_daily_toggle_"))
|
||||
dp.callback_query.register(start_all_contests, F.data == "admin_daily_start_all")
|
||||
dp.callback_query.register(start_round_now, F.data.startswith("admin_daily_start_"))
|
||||
dp.callback_query.register(manual_start_round, F.data.startswith("admin_daily_manual_"))
|
||||
dp.callback_query.register(close_all_rounds, F.data == "admin_daily_close_all")
|
||||
dp.callback_query.register(reset_all_attempts, F.data == "admin_daily_reset_all_attempts")
|
||||
dp.callback_query.register(reset_attempts, F.data.startswith("admin_daily_reset_attempts_"))
|
||||
dp.callback_query.register(close_round, F.data.startswith("admin_daily_close_"))
|
||||
dp.callback_query.register(prompt_edit_field, F.data.startswith("admin_daily_edit_"))
|
||||
dp.callback_query.register(edit_payload, F.data.startswith("admin_daily_payload_"))
|
||||
|
||||
dp.message.register(process_edit_field, AdminStates.editing_daily_contest_field)
|
||||
dp.message.register(process_payload, AdminStates.editing_daily_contest_value)
|
||||
@@ -1047,6 +1047,106 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
if target == "expiring_subscribers":
|
||||
expiring_subs = await get_expiring_subscriptions(db, 7)
|
||||
return [sub.user for sub in expiring_subs if sub.user]
|
||||
|
||||
if target == "expired_subscribers":
|
||||
now = datetime.utcnow()
|
||||
expired_statuses = {
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
}
|
||||
expired_users = []
|
||||
for user in users:
|
||||
subscription = user.subscription
|
||||
if subscription:
|
||||
if subscription.status in expired_statuses:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
if subscription.end_date <= now and not subscription.is_active:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
elif user.has_had_paid_subscription:
|
||||
expired_users.append(user)
|
||||
return expired_users
|
||||
|
||||
if target == "canceled_subscribers":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.status == SubscriptionStatus.DISABLED.value
|
||||
]
|
||||
|
||||
if target == "trial_ending":
|
||||
now = datetime.utcnow()
|
||||
in_3_days = now + timedelta(days=3)
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
and user.subscription.end_date <= in_3_days
|
||||
]
|
||||
|
||||
if target == "trial_expired":
|
||||
now = datetime.utcnow()
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_trial
|
||||
and user.subscription.end_date <= now
|
||||
]
|
||||
|
||||
if target == "autopay_failed":
|
||||
from app.database.models import SubscriptionEvent
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
stmt = select(SubscriptionEvent.user_id).where(
|
||||
and_(
|
||||
SubscriptionEvent.event_type == "autopay_failed",
|
||||
SubscriptionEvent.occurred_at >= week_ago,
|
||||
)
|
||||
).distinct()
|
||||
result = await db.execute(stmt)
|
||||
failed_user_ids = set(result.scalars().all())
|
||||
return [user for user in users if user.id in failed_user_ids]
|
||||
|
||||
if target == "low_balance":
|
||||
threshold_kopeks = 10000 # 100 рублей
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if (user.balance_kopeks or 0) < threshold_kopeks
|
||||
and (user.balance_kopeks or 0) > 0
|
||||
]
|
||||
|
||||
if target == "inactive_30d":
|
||||
threshold = datetime.utcnow() - timedelta(days=30)
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.last_activity and user.last_activity < threshold
|
||||
]
|
||||
|
||||
if target == "inactive_60d":
|
||||
threshold = datetime.utcnow() - timedelta(days=60)
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.last_activity and user.last_activity < threshold
|
||||
]
|
||||
|
||||
if target == "inactive_90d":
|
||||
threshold = datetime.utcnow() - timedelta(days=90)
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.last_activity and user.last_activity < threshold
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ async def process_yookassa_payment_amount(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
|
||||
receipt_email=None,
|
||||
receipt_phone=None,
|
||||
metadata={
|
||||
@@ -321,7 +321,7 @@ async def process_yookassa_sbp_payment_amount(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
|
||||
receipt_email=None,
|
||||
receipt_phone=None,
|
||||
metadata={
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.filters import Command
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
get_active_rounds,
|
||||
get_attempt,
|
||||
create_attempt,
|
||||
increment_winner_count,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import ContestRound, ContestTemplate, SubscriptionStatus
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.contest_rotation_service import (
|
||||
GAME_QUEST,
|
||||
GAME_LOCKS,
|
||||
GAME_CIPHER,
|
||||
GAME_SERVER,
|
||||
GAME_BLITZ,
|
||||
GAME_EMOJI,
|
||||
GAME_ANAGRAM,
|
||||
)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.subscription import extend_subscription
|
||||
from app.utils.decorators import auth_required, error_handler
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.states import ContestStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _user_allowed(subscription) -> bool:
|
||||
if not subscription:
|
||||
return False
|
||||
return subscription.status in {
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
}
|
||||
|
||||
|
||||
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return ""
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
if not subscription:
|
||||
return ""
|
||||
await extend_subscription(db, subscription, prize_days)
|
||||
texts = get_texts(language)
|
||||
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=prize_days)
|
||||
|
||||
|
||||
async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
|
||||
texts = get_texts(language)
|
||||
await callback.answer(texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."), show_alert=True)
|
||||
|
||||
|
||||
# ---------- Handlers ----------
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not _user_allowed(subscription):
|
||||
await _reply_not_eligible(callback, db_user.language)
|
||||
return
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
unique_templates = {}
|
||||
for rnd in active_rounds:
|
||||
if not rnd.template or not rnd.template.is_enabled:
|
||||
continue
|
||||
tpl_slug = rnd.template.slug if rnd.template else ""
|
||||
if tpl_slug not in unique_templates:
|
||||
unique_templates[tpl_slug] = rnd
|
||||
|
||||
buttons = []
|
||||
for tpl_slug, rnd in unique_templates.items():
|
||||
title = rnd.template.name if rnd.template else tpl_slug
|
||||
buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"▶️ {title}",
|
||||
callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
if not buttons:
|
||||
buttons.append(
|
||||
[types.InlineKeyboardButton(text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"), callback_data="noop")]
|
||||
)
|
||||
buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_MENU_TITLE", "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not _user_allowed(subscription):
|
||||
await _reply_not_eligible(callback, db_user.language)
|
||||
return
|
||||
|
||||
parts = callback.data.split("_")
|
||||
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "play":
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
round_id_str = parts[-1]
|
||||
try:
|
||||
round_id = int(round_id_str)
|
||||
except ValueError:
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
slug = "_".join(parts[2:-1])
|
||||
|
||||
# reload round with template
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
if not round_obj:
|
||||
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."), show_alert=True)
|
||||
return
|
||||
if not round_obj.template or not round_obj.template.is_enabled:
|
||||
await callback.answer(texts.t("CONTEST_DISABLED", "Игра отключена."), show_alert=True)
|
||||
return
|
||||
attempt = await get_attempt(db2, round_id, db_user.id)
|
||||
if attempt:
|
||||
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."), show_alert=True)
|
||||
return
|
||||
|
||||
tpl = round_obj.template
|
||||
if tpl.slug == GAME_QUEST:
|
||||
await _render_quest(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_LOCKS:
|
||||
await _render_locks(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_SERVER:
|
||||
await _render_server_lottery(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_CIPHER:
|
||||
await _render_cipher(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_EMOJI:
|
||||
await _render_emoji(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_ANAGRAM:
|
||||
await _render_anagram(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_BLITZ:
|
||||
await _render_blitz(callback, db_user, round_obj, tpl)
|
||||
else:
|
||||
await callback.answer(texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."), show_alert=True)
|
||||
|
||||
|
||||
async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
rows = round_obj.payload.get("rows", 3)
|
||||
cols = round_obj.payload.get("cols", 3)
|
||||
keyboard = []
|
||||
for r in range(rows):
|
||||
row_buttons = []
|
||||
for c in range(cols):
|
||||
idx = r * cols + c
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="🎛",
|
||||
callback_data=f"contest_pick_{round_obj.id}_quest_{idx}"
|
||||
)
|
||||
)
|
||||
keyboard.append(row_buttons)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_locks(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
total = round_obj.payload.get("total", 20)
|
||||
keyboard = []
|
||||
row = []
|
||||
for i in range(total):
|
||||
row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_locks_{i}"))
|
||||
if len(row) == 5:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_server_lottery(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
flags = round_obj.payload.get("flags") or []
|
||||
shuffled_flags = flags.copy()
|
||||
random.shuffle(shuffled_flags)
|
||||
keyboard = []
|
||||
row = []
|
||||
for flag in shuffled_flags:
|
||||
row.append(types.InlineKeyboardButton(text=flag, callback_data=f"contest_pick_{round_obj.id}_{flag}"))
|
||||
if len(row) == 5:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_cipher(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
question = round_obj.payload.get("question", "")
|
||||
await state.set_state(ContestStates.waiting_for_answer)
|
||||
await state.update_data(contest_round_id=round_obj.id)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_emoji(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
question = round_obj.payload.get("question", "🤔")
|
||||
emoji_list = question.split()
|
||||
random.shuffle(emoji_list)
|
||||
shuffled_question = " ".join(emoji_list)
|
||||
await state.set_state(ContestStates.waiting_for_answer)
|
||||
await state.update_data(contest_round_id=round_obj.id)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(q=shuffled_question),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_anagram(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
letters = round_obj.payload.get("letters", "")
|
||||
await state.set_state(ContestStates.waiting_for_answer)
|
||||
await state.update_data(contest_round_id=round_obj.id)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(letters=letters),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_blitz(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"), callback_data=f"contest_pick_{round_obj.id}_blitz")]
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
parts = callback.data.split("_")
|
||||
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "pick":
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
round_id_str = parts[2]
|
||||
pick = "_".join(parts[3:])
|
||||
try:
|
||||
round_id = int(round_id_str)
|
||||
except ValueError:
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
if not round_obj:
|
||||
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), show_alert=True)
|
||||
return
|
||||
|
||||
tpl = round_obj.template
|
||||
attempt = await get_attempt(db2, round_id, db_user.id)
|
||||
if attempt:
|
||||
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), show_alert=True)
|
||||
return
|
||||
|
||||
secret_idx = round_obj.payload.get("secret_idx")
|
||||
correct_flag = ""
|
||||
if tpl.slug == GAME_SERVER:
|
||||
flags = round_obj.payload.get("flags") or []
|
||||
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
|
||||
|
||||
is_winner = False
|
||||
if tpl.slug == GAME_SERVER:
|
||||
is_winner = pick == correct_flag
|
||||
elif tpl.slug == GAME_QUEST:
|
||||
# Format: quest_{idx}
|
||||
try:
|
||||
if pick.startswith("quest_"):
|
||||
idx = int(pick.split("_")[1])
|
||||
is_winner = secret_idx is not None and idx == secret_idx
|
||||
except (ValueError, IndexError):
|
||||
is_winner = False
|
||||
elif tpl.slug == GAME_LOCKS:
|
||||
# Format: locks_{idx}
|
||||
try:
|
||||
if pick.startswith("locks_"):
|
||||
idx = int(pick.split("_")[1])
|
||||
is_winner = secret_idx is not None and idx == secret_idx
|
||||
except (ValueError, IndexError):
|
||||
is_winner = False
|
||||
elif tpl.slug == GAME_BLITZ:
|
||||
is_winner = pick == "blitz"
|
||||
else:
|
||||
is_winner = False
|
||||
|
||||
# Check if max winners already reached
|
||||
if is_winner and round_obj.winners_count >= round_obj.max_winners:
|
||||
is_winner = False # Too late, max winners already reached
|
||||
|
||||
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=str(pick), is_winner=is_winner)
|
||||
|
||||
if is_winner:
|
||||
await increment_winner_count(db2, round_obj)
|
||||
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
|
||||
await callback.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), show_alert=True)
|
||||
else:
|
||||
responses = {
|
||||
GAME_QUEST: ["Пусто", "Ложный сервер", "Найди другой узел"],
|
||||
GAME_LOCKS: ["Заблокировано", "Попробуй ещё", "Нет доступа"],
|
||||
GAME_SERVER: ["Сервер перегружен", "Нет ответа", "Попробуй завтра"],
|
||||
}.get(tpl.slug, ["Неудача"])
|
||||
await callback.answer(random.choice(responses), show_alert=True)
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
round_id = data.get("contest_round_id")
|
||||
if not round_id:
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
if not round_obj:
|
||||
await message.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
attempt = await get_attempt(db2, round_obj.id, db_user.id)
|
||||
if attempt:
|
||||
await message.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
answer = (message.text or "").strip().upper()
|
||||
tpl = round_obj.template
|
||||
correct = (round_obj.payload.get("answer") or "").upper()
|
||||
|
||||
is_winner = correct and answer == correct
|
||||
|
||||
# Check if max winners already reached
|
||||
if is_winner and round_obj.winners_count >= round_obj.max_winners:
|
||||
is_winner = False # Too late, max winners already reached
|
||||
|
||||
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=answer, is_winner=is_winner)
|
||||
|
||||
if is_winner:
|
||||
await increment_winner_count(db2, round_obj)
|
||||
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
|
||||
await message.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), reply_markup=get_back_keyboard(db_user.language))
|
||||
else:
|
||||
await message.answer(texts.t("CONTEST_LOSE", "Не верно, попробуй снова в следующем раунде."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await state.clear()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_contests_menu, F.data == "contests_menu")
|
||||
dp.callback_query.register(play_contest, F.data.startswith("contest_play_"))
|
||||
dp.callback_query.register(handle_pick, F.data.startswith("contest_pick_"))
|
||||
dp.message.register(handle_text_answer, ContestStates.waiting_for_answer)
|
||||
dp.message.register(lambda message: None, Command("contests")) # placeholder
|
||||
+132
-30
@@ -17,6 +17,7 @@ from app.database.crud.promo_group import (
|
||||
from app.database.crud.transaction import get_user_total_spent_kopeks
|
||||
from app.keyboards.inline import (
|
||||
get_main_menu_keyboard,
|
||||
get_main_menu_keyboard_async,
|
||||
get_language_selection_keyboard,
|
||||
get_info_menu_keyboard,
|
||||
)
|
||||
@@ -39,7 +40,7 @@ from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.services.faq_service import FaqService
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
from app.handlers.subscription.traffic import handle_add_traffic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -198,22 +199,26 @@ async def show_main_menu(
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=db_user,
|
||||
language=db_user.language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
keyboard=keyboard,
|
||||
parse_mode="HTML",
|
||||
force_text=settings.is_text_main_menu_mode(),
|
||||
)
|
||||
@@ -518,6 +523,7 @@ async def show_faq_pages(
|
||||
await callback.message.edit_text(
|
||||
caption,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -654,6 +660,7 @@ async def show_faq_page(
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -772,6 +779,7 @@ async def show_privacy_policy(
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -891,6 +899,7 @@ async def show_public_offer(
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -1057,22 +1066,26 @@ async def handle_back_to_menu(
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=db_user,
|
||||
language=db_user.language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
keyboard=keyboard,
|
||||
parse_mode="HTML",
|
||||
force_text=settings.is_text_main_menu_mode(),
|
||||
)
|
||||
@@ -1212,15 +1225,94 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
return _insert_random_message(base_text, random_message, action_prompt)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения случайного сообщения: {e}")
|
||||
|
||||
|
||||
return base_text
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
async def handle_activate_button(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Получить подписку пользователя
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
|
||||
await callback.answer(
|
||||
texts.t("SUBSCRIPTION_ALREADY_ACTIVE", "✅ Подписка уже активна!"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Параметры из подписки или дефолтные
|
||||
device_limit = subscription.device_limit if subscription else settings.DEFAULT_DEVICE_LIMIT
|
||||
traffic_limit_gb = subscription.traffic_limit_gb if subscription else 0
|
||||
connected_squads = subscription.connected_squads if subscription else []
|
||||
|
||||
# Получить IDs серверов из UUIDs
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
|
||||
|
||||
balance = db_user.balance_kopeks
|
||||
available_periods = [int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS]
|
||||
|
||||
best_period = None
|
||||
best_price = 0
|
||||
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
# Найти максимальный период, цена которого <= баланса
|
||||
for period in sorted(available_periods, reverse=True):
|
||||
price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
period,
|
||||
traffic_limit_gb,
|
||||
server_ids,
|
||||
device_limit,
|
||||
db,
|
||||
user=db_user
|
||||
)
|
||||
if price <= balance:
|
||||
best_period = period
|
||||
best_price = price
|
||||
break
|
||||
|
||||
if best_period:
|
||||
# Создать новую подписку
|
||||
from app.database.crud.subscription import create_paid_subscription
|
||||
new_subscription = await create_paid_subscription(
|
||||
db,
|
||||
db_user.id,
|
||||
best_period,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
update_server_counters=True
|
||||
)
|
||||
|
||||
# Списать деньги
|
||||
db_user.balance_kopeks -= best_price
|
||||
await db.commit()
|
||||
|
||||
await callback.answer(
|
||||
texts.t("ACTIVATION_SUCCESS", f"✅ Подписка активирована на {best_period} дней за {best_price//100} руб!"),
|
||||
show_alert=True,
|
||||
)
|
||||
else:
|
||||
await callback.answer(
|
||||
texts.t("INSUFFICIENT_FUNDS", "❌ Недостаточно средств для активации подписки"),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_back_to_menu,
|
||||
F.data == "back_to_menu"
|
||||
@@ -1286,3 +1378,13 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.startswith("language_select:"),
|
||||
StateFilter(None)
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_add_traffic,
|
||||
F.data == "buy_traffic"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_activate_button,
|
||||
F.data == "activate_button"
|
||||
)
|
||||
|
||||
+185
-162
@@ -2,6 +2,7 @@ import logging
|
||||
from datetime import datetime
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -22,6 +23,7 @@ from app.keyboards.inline import (
|
||||
get_rules_keyboard,
|
||||
get_privacy_policy_keyboard,
|
||||
get_main_menu_keyboard,
|
||||
get_main_menu_keyboard_async,
|
||||
get_post_registration_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
)
|
||||
@@ -264,10 +266,14 @@ async def _continue_registration_after_language(
|
||||
return
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await target_message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
try:
|
||||
await target_message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"⚠️ Пользователь {callback.from_user.id if callback else message.from_user.id} заблокировал бота, пропускаем отправку правил")
|
||||
return
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 LANGUAGE: Правила отправлены после выбора языка")
|
||||
|
||||
@@ -319,7 +325,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
|
||||
if referral_code:
|
||||
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 and not campaign_notification_sent:
|
||||
@@ -337,32 +343,32 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
campaign.id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
|
||||
|
||||
|
||||
profile_updated = False
|
||||
|
||||
|
||||
if user.username != message.from_user.username:
|
||||
old_username = user.username
|
||||
user.username = message.from_user.username
|
||||
logger.info(f"📝 Username обновлен: '{old_username}' → '{user.username}'")
|
||||
profile_updated = True
|
||||
|
||||
|
||||
if user.first_name != message.from_user.first_name:
|
||||
old_first_name = user.first_name
|
||||
user.first_name = message.from_user.first_name
|
||||
logger.info(f"📝 Имя обновлено: '{old_first_name}' → '{user.first_name}'")
|
||||
profile_updated = True
|
||||
|
||||
|
||||
if user.last_name != message.from_user.last_name:
|
||||
old_last_name = user.last_name
|
||||
user.last_name = message.from_user.last_name
|
||||
logger.info(f"📝 Фамилия обновлена: '{old_last_name}' → '{user.last_name}'")
|
||||
profile_updated = True
|
||||
|
||||
|
||||
user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
if profile_updated:
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
@@ -370,7 +376,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.info(f"💾 Профиль пользователя {user.telegram_id} обновлен")
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
if referral_code and not user.referred_by_id:
|
||||
@@ -393,11 +399,11 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.error(
|
||||
f"Ошибка отправки уведомления о рекламной кампании: {e}"
|
||||
)
|
||||
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
user.subscription
|
||||
)
|
||||
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
@@ -414,35 +420,38 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=user,
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
|
||||
if user and user.status == UserStatus.DELETED.value:
|
||||
logger.info(f"🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию")
|
||||
|
||||
|
||||
try:
|
||||
from app.services.user_service import UserService
|
||||
from app.database.models import (
|
||||
Subscription, Transaction, PromoCodeUse,
|
||||
Subscription, Transaction, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
|
||||
|
||||
if user.subscription:
|
||||
await decrement_subscription_server_counts(db, user.subscription)
|
||||
await db.execute(
|
||||
@@ -451,51 +460,51 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
)
|
||||
)
|
||||
logger.info(f"🗑️ Удалены записи SubscriptionServer")
|
||||
|
||||
|
||||
if user.subscription:
|
||||
await db.delete(user.subscription)
|
||||
logger.info(f"🗑️ Удалена подписка пользователя")
|
||||
|
||||
|
||||
await db.execute(
|
||||
delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)
|
||||
)
|
||||
|
||||
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.user_id == user.id)
|
||||
)
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)
|
||||
)
|
||||
|
||||
|
||||
await db.execute(
|
||||
delete(Transaction).where(Transaction.user_id == user.id)
|
||||
)
|
||||
|
||||
|
||||
user.status = UserStatus.ACTIVE.value
|
||||
user.balance_kopeks = 0
|
||||
user.remnawave_uuid = None
|
||||
user.has_had_paid_subscription = False
|
||||
user.referred_by_id = None
|
||||
|
||||
|
||||
user.username = message.from_user.username
|
||||
user.first_name = message.from_user.first_name
|
||||
user.last_name = message.from_user.last_name
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
user.referral_code = await generate_unique_referral_code(db, user.telegram_id)
|
||||
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} подготовлен к восстановлению")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка подготовки к восстановлению: {e}")
|
||||
await db.rollback()
|
||||
else:
|
||||
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
|
||||
|
||||
|
||||
data = await state.get_data() or {}
|
||||
if not data.get('language'):
|
||||
if settings.is_language_selection_enabled():
|
||||
@@ -626,11 +635,11 @@ async def _show_privacy_policy_after_rules(
|
||||
Возвращает True, если политика была показана, False если её нет или произошла ошибка.
|
||||
"""
|
||||
policy = await PrivacyPolicyService.get_policy(db, language, fallback=True)
|
||||
|
||||
|
||||
if not policy or not policy.is_enabled:
|
||||
logger.info("⚠️ Политика конфиденциальности не включена, пропускаем её показ")
|
||||
return False
|
||||
|
||||
|
||||
if not policy.content or not policy.content.strip():
|
||||
privacy_policy_text = get_privacy_policy(language)
|
||||
if not privacy_policy_text or not privacy_policy_text.strip():
|
||||
@@ -640,7 +649,7 @@ async def _show_privacy_policy_after_rules(
|
||||
else:
|
||||
privacy_policy_text = policy.content
|
||||
logger.info(f"🔒 Используется политика конфиденциальности из БД для языка {language}")
|
||||
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
privacy_policy_text,
|
||||
@@ -675,7 +684,7 @@ async def _continue_registration_after_rules(
|
||||
"""
|
||||
data = await state.get_data() or {}
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
if data.get('referral_code'):
|
||||
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
||||
|
||||
@@ -717,10 +726,10 @@ async def process_rules_accept(
|
||||
logger.info(f"📋 RULES: Начало обработки правил")
|
||||
logger.info(f"📊 Callback data: {callback.data}")
|
||||
logger.info(f"👤 User: {callback.from_user.id}")
|
||||
|
||||
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Текущее состояние: {current_state}")
|
||||
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -730,24 +739,24 @@ async def process_rules_accept(
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', language)
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
if callback.data == 'rules_accept':
|
||||
logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}")
|
||||
|
||||
|
||||
# Пытаемся показать политику конфиденциальности
|
||||
policy_shown = await _show_privacy_policy_after_rules(
|
||||
callback, state, db, language
|
||||
)
|
||||
|
||||
|
||||
# Если политика не была показана, продолжаем регистрацию
|
||||
if not policy_shown:
|
||||
await _continue_registration_after_rules(
|
||||
callback, state, db, language
|
||||
)
|
||||
|
||||
|
||||
else:
|
||||
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
|
||||
|
||||
|
||||
rules_required_text = texts.t(
|
||||
"RULES_REQUIRED",
|
||||
"Для использования бота необходимо принять правила сервиса.",
|
||||
@@ -767,9 +776,9 @@ async def process_rules_accept(
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обработки правил: {e}", exc_info=True)
|
||||
await callback.answer(
|
||||
@@ -798,14 +807,14 @@ async def process_privacy_policy_accept(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
|
||||
logger.info(f"🔒 PRIVACY POLICY: Начало обработки политики конфиденциальности")
|
||||
logger.info(f"📊 Callback data: {callback.data}")
|
||||
logger.info(f"👤 User: {callback.from_user.id}")
|
||||
|
||||
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Текущее состояние: {current_state}")
|
||||
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -815,10 +824,10 @@ async def process_privacy_policy_accept(
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', language)
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
if callback.data == 'privacy_policy_accept':
|
||||
logger.info(f"✅ Политика конфиденциальности принята пользователем {callback.from_user.id}")
|
||||
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
logger.info(f"🗑️ Сообщение с политикой конфиденциальности удалено")
|
||||
@@ -834,7 +843,7 @@ async def process_privacy_policy_accept(
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if data.get('referral_code'):
|
||||
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
||||
|
||||
@@ -853,7 +862,7 @@ async def process_privacy_policy_accept(
|
||||
try:
|
||||
await state.set_data(data)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
|
||||
|
||||
await callback.bot.send_message(
|
||||
chat_id=callback.from_user.id,
|
||||
text=texts.t(
|
||||
@@ -866,10 +875,10 @@ async def process_privacy_policy_accept(
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
|
||||
else:
|
||||
logger.info(f"❌ Политика конфиденциальности отклонена пользователем {callback.from_user.id}")
|
||||
|
||||
|
||||
privacy_policy_required_text = texts.t(
|
||||
"PRIVACY_POLICY_REQUIRED",
|
||||
"Для использования бота необходимо принять политику конфиденциальности.",
|
||||
@@ -889,9 +898,9 @@ async def process_privacy_policy_accept(
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
logger.info(f"✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обработки политики конфиденциальности: {e}", exc_info=True)
|
||||
await callback.answer(
|
||||
@@ -994,7 +1003,7 @@ async def process_referral_code_skip(
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
|
||||
@@ -1029,11 +1038,11 @@ async def complete_registration_from_callback(
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
|
||||
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
|
||||
logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.")
|
||||
texts = get_texts(existing_user.language)
|
||||
|
||||
|
||||
data = await state.get_data() or {}
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await callback.message.answer(
|
||||
@@ -1042,13 +1051,13 @@ async def complete_registration_from_callback(
|
||||
"ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
existing_user.subscription
|
||||
)
|
||||
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
is_admin = settings.is_admin(existing_user.telegram_id)
|
||||
@@ -1067,19 +1076,22 @@ async def complete_registration_from_callback(
|
||||
)
|
||||
|
||||
try:
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=existing_user,
|
||||
language=existing_user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
await callback.message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=existing_user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -1090,10 +1102,10 @@ async def complete_registration_from_callback(
|
||||
"Добро пожаловать, {user_name}!",
|
||||
).format(user_name=existing_user.full_name)
|
||||
)
|
||||
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
@@ -1112,10 +1124,10 @@ async def complete_registration_from_callback(
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.DELETED.value:
|
||||
logger.info(f"🔄 Восстанавливаем удаленного пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
existing_user.username = callback.from_user.username
|
||||
existing_user.first_name = callback.from_user.first_name
|
||||
existing_user.last_name = callback.from_user.last_name
|
||||
@@ -1124,22 +1136,22 @@ async def complete_registration_from_callback(
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.balance_kopeks = 0
|
||||
existing_user.has_had_paid_subscription = False
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен")
|
||||
|
||||
|
||||
elif not existing_user:
|
||||
logger.info(f"🆕 Создаем нового пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
|
||||
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=callback.from_user.id,
|
||||
@@ -1148,7 +1160,7 @@ async def complete_registration_from_callback(
|
||||
last_name=callback.from_user.last_name,
|
||||
language=language,
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
referral_code=referral_code
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
else:
|
||||
@@ -1157,15 +1169,15 @@ async def complete_registration_from_callback(
|
||||
existing_user.language = language
|
||||
if referrer_id and not existing_user.referred_by_id:
|
||||
existing_user.referred_by_id = referrer_id
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
user = existing_user
|
||||
|
||||
|
||||
if referrer_id:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer_id, callback.bot)
|
||||
@@ -1224,11 +1236,11 @@ async def complete_registration_from_callback(
|
||||
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
getattr(user, "subscription", None)
|
||||
)
|
||||
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
@@ -1247,19 +1259,22 @@ async def complete_registration_from_callback(
|
||||
)
|
||||
|
||||
try:
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=user,
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
await callback.message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
|
||||
@@ -1303,11 +1318,11 @@ async def complete_registration(
|
||||
return
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
|
||||
logger.warning(f"⚠️ Пользователь {message.from_user.id} уже активен! Показываем главное меню.")
|
||||
texts = get_texts(existing_user.language)
|
||||
|
||||
|
||||
data = await state.get_data() or {}
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await message.answer(
|
||||
@@ -1316,13 +1331,13 @@ async def complete_registration(
|
||||
"ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
existing_user.subscription
|
||||
)
|
||||
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
is_admin = settings.is_admin(existing_user.telegram_id)
|
||||
@@ -1341,19 +1356,22 @@ async def complete_registration(
|
||||
)
|
||||
|
||||
try:
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=existing_user,
|
||||
language=existing_user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=existing_user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -1364,10 +1382,10 @@ async def complete_registration(
|
||||
"Добро пожаловать, {user_name}!",
|
||||
).format(user_name=existing_user.full_name)
|
||||
)
|
||||
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
@@ -1386,10 +1404,10 @@ async def complete_registration(
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.DELETED.value:
|
||||
logger.info(f"🔄 Восстанавливаем удаленного пользователя {message.from_user.id}")
|
||||
|
||||
|
||||
existing_user.username = message.from_user.username
|
||||
existing_user.first_name = message.from_user.first_name
|
||||
existing_user.last_name = message.from_user.last_name
|
||||
@@ -1398,22 +1416,22 @@ async def complete_registration(
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.balance_kopeks = 0
|
||||
existing_user.has_had_paid_subscription = False
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {message.from_user.id} восстановлен")
|
||||
|
||||
|
||||
elif not existing_user:
|
||||
logger.info(f"🆕 Создаем нового пользователя {message.from_user.id}")
|
||||
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, message.from_user.id)
|
||||
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=message.from_user.id,
|
||||
@@ -1431,15 +1449,15 @@ async def complete_registration(
|
||||
existing_user.language = language
|
||||
if referrer_id and not existing_user.referred_by_id:
|
||||
existing_user.referred_by_id = referrer_id
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
user = existing_user
|
||||
|
||||
|
||||
if referrer_id:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer_id, message.bot)
|
||||
@@ -1521,11 +1539,11 @@ async def complete_registration(
|
||||
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(
|
||||
getattr(user, "subscription", None)
|
||||
)
|
||||
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
@@ -1544,19 +1562,22 @@ async def complete_registration(
|
||||
)
|
||||
|
||||
try:
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=user,
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
|
||||
@@ -1665,7 +1686,7 @@ def _insert_random_message(base_text: str, random_message: str, action_prompt: s
|
||||
|
||||
def get_referral_code_keyboard(language: str):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
@@ -1875,7 +1896,9 @@ async def required_sub_channel_check(
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
keyboard = get_main_menu_keyboard(
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=user,
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
@@ -1964,29 +1987,29 @@ async def required_sub_channel_check(
|
||||
await query.answer(f"{texts.ERROR}!", show_alert=True)
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
|
||||
logger.info("🔧 === НАЧАЛО регистрации обработчиков start.py ===")
|
||||
|
||||
|
||||
dp.message.register(
|
||||
cmd_start,
|
||||
Command("start")
|
||||
)
|
||||
logger.info("✅ Зарегистрирован cmd_start")
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
process_rules_accept,
|
||||
F.data.in_(["rules_accept", "rules_decline"]),
|
||||
StateFilter(RegistrationStates.waiting_for_rules_accept)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_rules_accept")
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
process_privacy_policy_accept,
|
||||
F.data.in_(["privacy_policy_accept", "privacy_policy_decline"]),
|
||||
StateFilter(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_privacy_policy_accept")
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
process_language_selection,
|
||||
F.data.startswith("language_select:"),
|
||||
@@ -2000,13 +2023,13 @@ def register_handlers(dp: Dispatcher):
|
||||
StateFilter(RegistrationStates.waiting_for_referral_code)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_referral_code_skip")
|
||||
|
||||
|
||||
dp.message.register(
|
||||
process_referral_code_input,
|
||||
StateFilter(RegistrationStates.waiting_for_referral_code)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_referral_code_input")
|
||||
|
||||
|
||||
dp.message.register(
|
||||
handle_potential_referral_code,
|
||||
StateFilter(
|
||||
@@ -2021,6 +2044,6 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.in_(["sub_channel_check"])
|
||||
)
|
||||
logger.info("✅ Зарегистрирован required_sub_channel_check")
|
||||
|
||||
|
||||
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
|
||||
|
||||
|
||||
|
||||
@@ -376,10 +376,19 @@ async def show_subscription_info(
|
||||
and actual_status in ["trial_active", "paid_active"]
|
||||
and not hide_subscription_link
|
||||
):
|
||||
subscription_link_display = subscription_link
|
||||
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
subscription_link_display = (
|
||||
f"<blockquote expandable><code>{subscription_link}</code></blockquote>"
|
||||
)
|
||||
else:
|
||||
subscription_link_display = f"<code>{subscription_link}</code>"
|
||||
|
||||
message += "\n\n" + texts.t(
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION",
|
||||
"🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
|
||||
).format(subscription_url=subscription_link)
|
||||
"🔗 <b>Ссылка для подключения:</b>\n{subscription_url}",
|
||||
).format(subscription_url=subscription_link_display)
|
||||
message += "\n\n" + texts.t(
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT",
|
||||
"📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
|
||||
|
||||
@@ -95,6 +95,12 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS", "🏆 Конкурсы"),
|
||||
callback_data="admin_contests",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data="admin_promo_groups")
|
||||
],
|
||||
@@ -471,6 +477,181 @@ def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
])
|
||||
|
||||
|
||||
def get_admin_contests_root_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS_REFERRAL", "🤝 Реферальные конкурсы"),
|
||||
callback_data="admin_contests_referral",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS_DAILY", "📆 Ежедневные конкурсы"),
|
||||
callback_data="admin_contests_daily",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo"),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_admin_contests_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS_LIST", "📋 Текущие конкурсы"),
|
||||
callback_data="admin_contests_list",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS_CREATE", "➕ Новый конкурс"),
|
||||
callback_data="admin_contests_create",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="admin_contests",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_contest_mode_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTEST_MODE_PAID", "💳 Реферал с покупкой"),
|
||||
callback_data="admin_contest_mode_paid",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTEST_MODE_REGISTERED", "🧑🤝🧑 Просто реферал"),
|
||||
callback_data="admin_contest_mode_registered",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_referral")
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_daily_contest_manage_keyboard(
|
||||
template_id: int,
|
||||
is_enabled: bool,
|
||||
language: str = "ru",
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
toggle_text = _t(texts, "ADMIN_CONTEST_DISABLE", "⏸️ Остановить") if is_enabled else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить")
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=toggle_text, callback_data=f"admin_daily_toggle_{template_id}"),
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_NOW", "🚀 Запустить раунд"), callback_data=f"admin_daily_start_{template_id}"),
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_MANUAL", "🧪 Ручной старт"), callback_data=f"admin_daily_manual_{template_id}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PRIZE", "🏅 Приз (дни)"), callback_data=f"admin_daily_edit_{template_id}_prize_days"),
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_MAX_WINNERS", "👥 Победителей"), callback_data=f"admin_daily_edit_{template_id}_max_winners"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_ATTEMPTS", "🔁 Попытки"), callback_data=f"admin_daily_edit_{template_id}_attempts_per_user"),
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_TIMES", "⏰ Раундов/день"), callback_data=f"admin_daily_edit_{template_id}_times_per_day"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_SCHEDULE", "🕒 Расписание"), callback_data=f"admin_daily_edit_{template_id}_schedule_times"),
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_COOLDOWN", "⌛ Длительность"), callback_data=f"admin_daily_edit_{template_id}_cooldown_hours"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PAYLOAD", "🧩 Payload"), callback_data=f"admin_daily_payload_{template_id}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_RESET_ATTEMPTS", "🔄 Сбросить попытки"), callback_data=f"admin_daily_reset_attempts_{template_id}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_CLOSE_ROUND", "❌ Закрыть раунд"), callback_data=f"admin_daily_close_{template_id}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_daily"),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
def get_referral_contest_manage_keyboard(
|
||||
contest_id: int,
|
||||
*,
|
||||
is_active: bool,
|
||||
can_delete: bool = False,
|
||||
language: str = "ru",
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
toggle_text = (
|
||||
_t(texts, "ADMIN_CONTEST_DISABLE", "⏸️ Остановить")
|
||||
if is_active
|
||||
else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить")
|
||||
)
|
||||
|
||||
rows = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTEST_LEADERBOARD", "📊 Лидеры"),
|
||||
callback_data=f"admin_contest_leaderboard_{contest_id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=toggle_text,
|
||||
callback_data=f"admin_contest_toggle_{contest_id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📈 Детальная статистика",
|
||||
callback_data=f"admin_contest_detailed_stats_{contest_id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTEST_EDIT_SUMMARY_TIMES", "🕒 Итоги в день"),
|
||||
callback_data=f"admin_contest_edit_times_{contest_id}",
|
||||
),
|
||||
],
|
||||
]
|
||||
|
||||
if can_delete:
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTEST_DELETE", "🗑 Удалить"),
|
||||
callback_data=f"admin_contest_delete_{contest_id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_BACK_TO_LIST", "⬅️ К списку"),
|
||||
callback_data="admin_contests_list",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def get_campaign_management_keyboard(
|
||||
campaign_id: int, is_active: bool, language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
|
||||
+151
-3
@@ -1,7 +1,7 @@
|
||||
from typing import List, Optional
|
||||
from aiogram import types
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from app.database.models import User
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -23,6 +23,136 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_main_menu_keyboard_async(
|
||||
db: AsyncSession,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
is_admin: bool = False,
|
||||
has_had_paid_subscription: bool = False,
|
||||
has_active_subscription: bool = False,
|
||||
subscription_is_active: bool = False,
|
||||
balance_kopeks: int = 0,
|
||||
subscription=None,
|
||||
show_resume_checkout: bool = False,
|
||||
has_saved_cart: bool = False,
|
||||
*,
|
||||
is_moderator: bool = False,
|
||||
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
|
||||
user=None, # Добавляем параметр пользователя для получения данных
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Асинхронная версия get_main_menu_keyboard с поддержкой конструктора меню.
|
||||
|
||||
Если MENU_LAYOUT_ENABLED=True, использует конфигурацию из БД.
|
||||
Иначе делегирует в синхронную версию.
|
||||
"""
|
||||
if settings.MENU_LAYOUT_ENABLED:
|
||||
from app.services.menu_layout_service import MenuLayoutService, MenuContext
|
||||
from datetime import datetime
|
||||
|
||||
# Получаем данные для плейсхолдеров
|
||||
subscription_days_left = 0
|
||||
traffic_used_gb = 0.0
|
||||
traffic_left_gb = 0.0
|
||||
referral_count = 0
|
||||
referral_earnings_kopeks = 0
|
||||
registration_days = 0
|
||||
promo_group_id = None
|
||||
has_autopay = False
|
||||
username = ""
|
||||
|
||||
# Заполняем данными из подписки
|
||||
if subscription:
|
||||
# Дни до окончания подписки
|
||||
if hasattr(subscription, 'days_left'):
|
||||
# Используем свойство из модели, которое правильно вычисляет дни в UTC
|
||||
subscription_days_left = subscription.days_left
|
||||
elif hasattr(subscription, 'end_date') and subscription.end_date:
|
||||
# Fallback: вычисляем вручную, используя UTC
|
||||
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
days_left = (subscription.end_date - now_utc).days
|
||||
subscription_days_left = max(0, days_left)
|
||||
|
||||
# Трафик
|
||||
if hasattr(subscription, 'traffic_used_gb'):
|
||||
traffic_used_gb = subscription.traffic_used_gb or 0.0
|
||||
|
||||
if hasattr(subscription, 'traffic_limit_gb') and subscription.traffic_limit_gb:
|
||||
traffic_left_gb = max(0, subscription.traffic_limit_gb - (subscription.traffic_used_gb or 0))
|
||||
|
||||
# Автоплатеж
|
||||
if hasattr(subscription, 'autopay_enabled'):
|
||||
has_autopay = subscription.autopay_enabled
|
||||
|
||||
# Получаем данные пользователя
|
||||
if user:
|
||||
# Имя пользователя
|
||||
if hasattr(user, 'username') and user.username:
|
||||
username = user.username
|
||||
elif hasattr(user, 'first_name') and user.first_name:
|
||||
username = user.first_name
|
||||
|
||||
# Дни с регистрации
|
||||
if hasattr(user, 'created_at') and user.created_at:
|
||||
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
registration_days = (now_utc - user.created_at).days
|
||||
|
||||
# ID промо-группы
|
||||
if hasattr(user, 'promo_group_id'):
|
||||
promo_group_id = user.promo_group_id
|
||||
|
||||
# Получаем данные о рефералах из БД (если нужно)
|
||||
try:
|
||||
from app.database.crud.referral import get_user_referral_stats
|
||||
if user and hasattr(user, 'id'):
|
||||
referral_data = await get_user_referral_stats(db, user.id)
|
||||
if referral_data:
|
||||
referral_count = referral_data.get('invited_count', 0)
|
||||
referral_earnings_kopeks = referral_data.get('total_earned_kopeks', 0)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting referral data: {e}")
|
||||
|
||||
context = MenuContext(
|
||||
language=language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
balance_kopeks=balance_kopeks,
|
||||
subscription=subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart,
|
||||
custom_buttons=custom_buttons or [],
|
||||
# Добавляем данные для плейсхолдеров
|
||||
username=username,
|
||||
subscription_days=subscription_days_left,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
traffic_left_gb=traffic_left_gb,
|
||||
referral_count=referral_count,
|
||||
referral_earnings_kopeks=referral_earnings_kopeks,
|
||||
registration_days=registration_days,
|
||||
promo_group_id=promo_group_id,
|
||||
has_autopay=has_autopay,
|
||||
)
|
||||
|
||||
return await MenuLayoutService.build_keyboard(db, context)
|
||||
|
||||
# Fallback на синхронную версию
|
||||
return get_main_menu_keyboard(
|
||||
language=language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks,
|
||||
subscription=subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
|
||||
def _get_localized_value(values, language: str, default_language: str = "en") -> str:
|
||||
if not isinstance(values, dict):
|
||||
return ""
|
||||
@@ -376,6 +506,12 @@ def get_main_menu_keyboard(
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
)
|
||||
|
||||
# Добавляем кнопку докупки трафика для лимитированных подписок
|
||||
if subscription and not subscription.is_trial and subscription.traffic_limit_gb > 0:
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), callback_data="buy_traffic")
|
||||
)
|
||||
|
||||
keyboard.append([InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")])
|
||||
|
||||
show_trial = not has_had_paid_subscription and not has_active_subscription
|
||||
@@ -432,23 +568,35 @@ def get_main_menu_keyboard(
|
||||
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode")
|
||||
)
|
||||
|
||||
# Добавляем кнопку рефералов только если программа включена
|
||||
# Добавляем кнопку рефералов, только если программа включена
|
||||
if settings.is_referral_program_enabled():
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
|
||||
)
|
||||
|
||||
# Support button is configurable (runtime via service)
|
||||
# Добавляем кнопку конкурсов
|
||||
if settings.CONTESTS_ENABLED and settings.CONTESTS_BUTTON_VISIBLE:
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.t("CONTESTS_BUTTON", "🎲 Конкурсы"), callback_data="contests_menu")
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
support_enabled = SupportSettingsService.is_support_menu_enabled()
|
||||
except Exception:
|
||||
support_enabled = settings.SUPPORT_MENU_ENABLED
|
||||
|
||||
if support_enabled:
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
|
||||
)
|
||||
|
||||
# Добавляем кнопку активации
|
||||
if settings.ACTIVATE_BUTTON_VISIBLE:
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=settings.ACTIVATE_BUTTON_TEXT, callback_data="activate_button")
|
||||
)
|
||||
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_INFO", "ℹ️ Инфо"),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"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.",
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
|
||||
"ADD_TRAFFIC_PROMPT": "📈 <b>Add traffic to your subscription</b>\n\nCurrent limit: {current_traffic}\nChoose extra traffic:",
|
||||
"BUY_TRAFFIC_BUTTON": "📈 Buy more traffic",
|
||||
"ADMIN_BACK_TO_ADMIN": "⬅️ Back to admin",
|
||||
"ADMIN_BACK_TO_LIST": "⬅️ Back to list",
|
||||
"ADMIN_BACK_TO_MAIN": "🏠 Back to main menu",
|
||||
@@ -48,6 +49,76 @@
|
||||
"ADMIN_CAMPAIGN_SERVERS": "🌍 Servers",
|
||||
"ADMIN_CAMPAIGN_STATS": "📊 Statistics",
|
||||
"ADMIN_CAMPAIGN_TRAFFIC": "🌐 Traffic",
|
||||
"ADMIN_CONTESTS": "🏆 Contests",
|
||||
"ADMIN_CONTESTS_CREATE": "➕ New contest",
|
||||
"ADMIN_CONTESTS_DISABLED": "Contests are disabled via CONTESTS_ENABLED.",
|
||||
"ADMIN_CONTESTS_LIST": "📋 Current contests",
|
||||
"ADMIN_CONTESTS_REFERRAL": "🤝 Referral contests",
|
||||
"ADMIN_CONTESTS_DAILY": "📆 Daily contests",
|
||||
"ADMIN_CONTESTS_COMING_SOON": "Coming soon.",
|
||||
"ADMIN_CONTESTS_LIST_HEADER": "🏆 <b>Contests</b>\n",
|
||||
"ADMIN_CONTESTS_EMPTY": "No contests yet.",
|
||||
"ADMIN_CONTESTS_TITLE": "🏆 <b>Contests</b>\n\nChoose an action:",
|
||||
"ADMIN_CONTEST_MODE_PAID": "💳 Referral with purchase",
|
||||
"ADMIN_CONTEST_MODE_REGISTERED": "🧑🤝🧑 Referral registration",
|
||||
"ADMIN_CONTEST_MODE_PROMPT": "Choose qualifying rule: referral must buy a subscription or just register.",
|
||||
"ADMIN_CONTEST_ENTER_TITLE": "Enter contest title:",
|
||||
"ADMIN_CONTEST_ENTER_DESCRIPTION": "Describe the contest (or send '-' to skip):",
|
||||
"ADMIN_CONTEST_ENTER_PRIZE": "Specify prizes/rewards (or '-' to skip):",
|
||||
"ADMIN_CONTEST_ENTER_START": "Enter start date/time (dd.mm.yyyy hh:mm) in your timezone:",
|
||||
"ADMIN_CONTEST_ENTER_END": "Enter end date/time (dd.mm.yyyy hh:mm) in your timezone:",
|
||||
"ADMIN_CONTEST_ENTER_DAILY_TIME": "What time to send daily results? Multiple times allowed, comma-separated HH:MM (e.g., 12:00 or 12:00,18:00).",
|
||||
"ADMIN_CONTEST_INVALID_DATE": "Cannot parse date. Format: 01.06.2024 12:00",
|
||||
"ADMIN_CONTEST_INVALID_TIME": "Cannot parse time. Format: 12:00",
|
||||
"ADMIN_CONTEST_END_BEFORE_START": "End date must be after start date.",
|
||||
"ADMIN_CONTEST_CREATED": "Contest created!",
|
||||
"ADMIN_CONTEST_PRIZE": "Prize: {prize}",
|
||||
"ADMIN_CONTEST_LAST_DAILY": "Last digest: {date}",
|
||||
"ADMIN_CONTEST_STATUS_ACTIVE": "🟢 Active",
|
||||
"ADMIN_CONTEST_STATUS_INACTIVE": "⚪️ Disabled",
|
||||
"ADMIN_CONTEST_TOTAL_EVENTS": "Qualified: <b>{count}</b>",
|
||||
"ADMIN_CONTEST_LEADERBOARD_TITLE": "📊 Top participants:",
|
||||
"ADMIN_CONTEST_LEADERBOARD": "📊 Leaders",
|
||||
"ADMIN_CONTEST_ENABLE": "▶️ Start",
|
||||
"ADMIN_CONTEST_DISABLE": "⏸️ Pause",
|
||||
"ADMIN_CONTEST_START_MANUAL": "🧪 Manual start",
|
||||
"ADMIN_CONTEST_EDIT_SUMMARY_TIMES": "🕒 Daily summaries",
|
||||
"ADMIN_CONTEST_DELETE": "🗑 Delete",
|
||||
"ADMIN_CONTEST_DELETE_RESTRICT": "Only finished contests can be deleted.",
|
||||
"ADMIN_CONTEST_DELETED": "Contest deleted.",
|
||||
"ADMIN_CONTEST_NOT_FOUND": "Contest not found.",
|
||||
"ADMIN_CONTEST_EMPTY_LEADERBOARD": "No participants yet.",
|
||||
"ADMIN_DAILY_CONTESTS_TITLE": "📆 Daily contests",
|
||||
"ADMIN_EDIT_PRIZE": "🏅 Prize (days)",
|
||||
"ADMIN_EDIT_MAX_WINNERS": "👥 Winners",
|
||||
"ADMIN_EDIT_ATTEMPTS": "🔁 Attempts",
|
||||
"ADMIN_EDIT_TIMES": "⏰ Rounds/day",
|
||||
"ADMIN_EDIT_SCHEDULE": "🕒 Schedule",
|
||||
"ADMIN_EDIT_COOLDOWN": "⌛ Duration",
|
||||
"ADMIN_EDIT_PAYLOAD": "🧩 Payload",
|
||||
"ADMIN_CONTEST_FIELD_PROMPT": "Enter new value for {label}:",
|
||||
"ADMIN_CONTEST_PAYLOAD_PROMPT": "Send JSON payload for this game:",
|
||||
"ADMIN_ROUND_STARTED": "Round started",
|
||||
"ADMIN_UPDATED": "Updated",
|
||||
"ADMIN_INVALID_NUMBER": "Invalid number",
|
||||
"ADMIN_INVALID_JSON": "Invalid JSON",
|
||||
"CONTEST_MENU_TITLE": "🎲 <b>Games/Contests</b>\nChoose a game:",
|
||||
"CONTEST_EMPTY": "No games right now",
|
||||
"CONTEST_NOT_ELIGIBLE": "Games are available only with an active or trial subscription.",
|
||||
"CONTEST_ROUND_FINISHED": "Round finished or unavailable.",
|
||||
"CONTEST_ALREADY_PLAYED": "You already played this round.",
|
||||
"CONTEST_UNKNOWN": "Contest type not supported.",
|
||||
"CONTEST_QUEST_PROMPT": "Pick one of the 3×3 nodes:",
|
||||
"CONTEST_LOCKS_PROMPT": "Find the hacked lock:",
|
||||
"CONTEST_SERVER_PROMPT": "Choose a server:",
|
||||
"CONTEST_CIPHER_PROMPT": "Decode: {q}",
|
||||
"CONTEST_EMOJI_PROMPT": "Guess the service: {q}",
|
||||
"CONTEST_ANAGRAM_PROMPT": "Make a word: {letters}",
|
||||
"CONTEST_BLITZ_PROMPT": "⚡️ Blitz! Press “I'm here!”",
|
||||
"CONTEST_BLITZ_BUTTON": "I'm here!",
|
||||
"CONTEST_WIN": "🎉 You win! ",
|
||||
"CONTEST_LOSE": "Wrong, try next round.",
|
||||
"CONTEST_PRIZE_GRANTED": "Bonus {days} days credited!",
|
||||
"ADMIN_CANCEL": "❌ Cancel",
|
||||
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Menu messages",
|
||||
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Promo offers",
|
||||
@@ -1290,7 +1361,7 @@
|
||||
"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_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Connection link:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Connection link:</b>\n{subscription_url}",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
|
||||
"ADD_TRAFFIC_PROMPT": "📈 <b>Добавить трафик к подписке</b>\n\nТекущий лимит: {current_traffic}\nВыберите дополнительный трафик:",
|
||||
"BUY_TRAFFIC_BUTTON": "📈 Докупить трафик",
|
||||
"ADMIN_BACK_TO_ADMIN": "⬅️ Назад в админку",
|
||||
"ADMIN_BACK_TO_LIST": "⬅️ К списку",
|
||||
"ADMIN_BACK_TO_MAIN": "🏠 В главное меню",
|
||||
@@ -48,6 +49,79 @@
|
||||
"ADMIN_CAMPAIGN_SERVERS": "🌍 Серверы",
|
||||
"ADMIN_CAMPAIGN_STATS": "📊 Статистика",
|
||||
"ADMIN_CAMPAIGN_TRAFFIC": "🌐 Трафик",
|
||||
"ADMIN_CONTESTS": "🏆 Конкурсы",
|
||||
"ADMIN_CONTESTS_CREATE": "➕ Новый конкурс",
|
||||
"ADMIN_CONTESTS_DISABLED": "Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
|
||||
"ADMIN_CONTESTS_LIST": "📋 Текущие конкурсы",
|
||||
"ADMIN_CONTESTS_REFERRAL": "🤝 Реферальные конкурсы",
|
||||
"ADMIN_CONTESTS_DAILY": "📆 Ежедневные конкурсы",
|
||||
"ADMIN_CONTESTS_COMING_SOON": "Функционал появится позже.",
|
||||
"ADMIN_CONTESTS_LIST_HEADER": "🏆 <b>Конкурсы</b>\n",
|
||||
"ADMIN_CONTESTS_EMPTY": "Пока нет созданных конкурсов.",
|
||||
"ADMIN_CONTESTS_TITLE": "🏆 <b>Конкурсы</b>\n\nВыберите действие:",
|
||||
"ADMIN_CONTEST_MODE_PAID": "💳 Реферал с покупкой",
|
||||
"ADMIN_CONTEST_MODE_REGISTERED": "🧑🤝🧑 Просто реферал",
|
||||
"ADMIN_CONTEST_MODE_PROMPT": "Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.",
|
||||
"ADMIN_CONTEST_ENTER_TITLE": "Введите название конкурса:",
|
||||
"ADMIN_CONTEST_ENTER_DESCRIPTION": "Опишите конкурс (или отправьте '-' чтобы пропустить):",
|
||||
"ADMIN_CONTEST_ENTER_PRIZE": "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):",
|
||||
"ADMIN_CONTEST_ENTER_START": "Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:",
|
||||
"ADMIN_CONTEST_ENTER_END": "Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:",
|
||||
"ADMIN_CONTEST_ENTER_DAILY_TIME": "Во сколько отправлять ежедневные итоги? Можно несколько через запятую, формат ЧЧ:ММ (например, 12:00 или 12:00,18:00).",
|
||||
"ADMIN_CONTEST_INVALID_DATE": "Не удалось распознать дату. Формат: 01.06.2024 12:00",
|
||||
"ADMIN_CONTEST_INVALID_TIME": "Не удалось распознать время. Формат: 12:00",
|
||||
"ADMIN_CONTEST_END_BEFORE_START": "Дата окончания должна быть позже даты начала.",
|
||||
"ADMIN_CONTEST_CREATED": "Конкурс создан!",
|
||||
"ADMIN_CONTEST_PRIZE": "Приз: {prize}",
|
||||
"ADMIN_CONTEST_LAST_DAILY": "Последняя сводка: {date}",
|
||||
"ADMIN_CONTEST_STATUS_ACTIVE": "🟢 Активен",
|
||||
"ADMIN_CONTEST_STATUS_INACTIVE": "⚪️ Выключен",
|
||||
"ADMIN_CONTEST_TOTAL_EVENTS": "Зачётов: <b>{count}</b>",
|
||||
"ADMIN_CONTEST_LEADERBOARD_TITLE": "📊 Топ участников:",
|
||||
"ADMIN_CONTEST_LEADERBOARD": "📊 Лидеры",
|
||||
"ADMIN_CONTEST_ENABLE": "▶️ Запустить",
|
||||
"ADMIN_CONTEST_DISABLE": "⏸️ Остановить",
|
||||
"ADMIN_CONTEST_START_MANUAL": "🧪 Ручной старт",
|
||||
"ADMIN_CONTEST_EDIT_SUMMARY_TIMES": "🕒 Итоги в день",
|
||||
"ADMIN_CONTEST_DELETE": "🗑 Удалить",
|
||||
"ADMIN_CONTEST_DELETE_RESTRICT": "Удалять можно только завершённые конкурсы.",
|
||||
"ADMIN_CONTEST_DELETED": "Конкурс удалён.",
|
||||
"ADMIN_CONTEST_NOT_FOUND": "Конкурс не найден.",
|
||||
"ADMIN_CONTEST_EMPTY_LEADERBOARD": "Пока нет участников.",
|
||||
"ADMIN_DAILY_CONTESTS_TITLE": "📆 Ежедневные конкурсы",
|
||||
"ADMIN_EDIT_PRIZE": "🏅 Приз (дни)",
|
||||
"ADMIN_EDIT_MAX_WINNERS": "👥 Победителей",
|
||||
"ADMIN_EDIT_ATTEMPTS": "🔁 Попытки",
|
||||
"ADMIN_EDIT_TIMES": "⏰ Раундов/день",
|
||||
"ADMIN_EDIT_SCHEDULE": "🕒 Расписание",
|
||||
"ADMIN_EDIT_COOLDOWN": "⌛ Длительность",
|
||||
"ADMIN_EDIT_PAYLOAD": "🧩 Payload",
|
||||
"ADMIN_CONTEST_FIELD_PROMPT": "Введите новое значение для {label}:",
|
||||
"ADMIN_CONTEST_START_NOW": "Запустить раунд сейчас",
|
||||
"ADMIN_RESET_ATTEMPTS": "🔄 Сбросить попытки",
|
||||
"ADMIN_CLOSE_ROUND": "❌ Закрыть раунд",
|
||||
"ADMIN_ROUND_STARTED": "Раунд запущен",
|
||||
"ADMIN_UPDATED": "Обновлено",
|
||||
"ADMIN_INVALID_NUMBER": "Некорректное число",
|
||||
"ADMIN_INVALID_JSON": "Некорректный JSON",
|
||||
"CONTEST_MENU_TITLE": "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:",
|
||||
"CONTESTS_BUTTON": "🎲 Конкурсы",
|
||||
"CONTEST_EMPTY": "Сейчас игр нет",
|
||||
"CONTEST_NOT_ELIGIBLE": "Игры доступны только с активной или триальной подпиской.",
|
||||
"CONTEST_ROUND_FINISHED": "Раунд завершён или недоступен.",
|
||||
"CONTEST_ALREADY_PLAYED": "У вас уже была попытка в этом раунде.",
|
||||
"CONTEST_UNKNOWN": "Тип конкурса не поддерживается.",
|
||||
"CONTEST_QUEST_PROMPT": "Выбери один из узлов 3×3:",
|
||||
"CONTEST_LOCKS_PROMPT": "Найди взломанную кнопку среди замков:",
|
||||
"CONTEST_SERVER_PROMPT": "Выбери сервер:",
|
||||
"CONTEST_CIPHER_PROMPT": "Расшифруй: {q}",
|
||||
"CONTEST_EMOJI_PROMPT": "Угадай сервис по эмодзи: {q}",
|
||||
"CONTEST_ANAGRAM_PROMPT": "Составь слово: {letters}",
|
||||
"CONTEST_BLITZ_PROMPT": "⚡️ Блиц! Нажми «Я здесь!»",
|
||||
"CONTEST_BLITZ_BUTTON": "Я здесь!",
|
||||
"CONTEST_WIN": "🎉 Победа! ",
|
||||
"CONTEST_LOSE": "Не верно, попробуй снова в следующем раунде.",
|
||||
"CONTEST_PRIZE_GRANTED": "Бонус {days} дней зачислен!",
|
||||
"ADMIN_CANCEL": "❌ Отмена",
|
||||
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Сообщения в меню",
|
||||
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Промо-предложения",
|
||||
@@ -1302,7 +1376,7 @@
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Подключить подписку</b>\n\nℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n{subscription_url}",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"ACCESS_DENIED": "❌ Доступ заборонено",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостатньо коштів</b>\n\nВартість послуги: {required}\nНа балансі: {balance}\nНе вистачає: {missing}\n\nОберіть спосіб поповнення. Сума підставиться автоматично.",
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Додати країни",
|
||||
"ADD_TRAFFIC_PROMPT": "📈 <b>Додати трафік до підписки</b>\n\nПоточний ліміт: {current_traffic}\nВиберіть додатковий трафік:",
|
||||
"BUY_TRAFFIC_BUTTON": "📈 Докупити трафік",
|
||||
"ADMIN_BACK_TO_ADMIN": "⬅️ Назад до адмінки",
|
||||
"ADMIN_BACK_TO_LIST": "⬅️ До списку",
|
||||
"ADMIN_BACK_TO_MAIN": "🏠 В головне меню",
|
||||
@@ -1293,7 +1295,7 @@
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Підключити підписку</b>\n\nℹ️ Посилання підписки доступне за кнопками нижче або в розділі «Моя підписка».\n\n💡 <b>Оберіть ваш пристрій</b> для отримання детальної інструкції з налаштування:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Підключити підписку</b>\n\n🔗 Натисніть кнопку нижче, щоб відкрити посилання підписки:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопіюйте посилання та додайте у ваш VPN додаток",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Посилання для підключення:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Посилання для підключення:</b>\n{subscription_url}",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Підключити підписку</b>\n\n🚀 Натисніть кнопку нижче, щоб відкрити підписку в міні-додатку Telegram:",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Додатки для цього пристрою не знайдено",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендований додаток:</b> {app_name}",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"ACCESS_DENIED":"❌拒绝访问",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE":"⚠️<b>资金不足</b>\n\n服务费用:{required}\n当前余额:{balance}\n缺少:{missing}\n\n请选择充值方式。金额将自动填入。",
|
||||
"ADD_COUNTRIES_BUTTON":"🌐添加国家",
|
||||
"BUY_TRAFFIC_BUTTON":"📈购买更多流量",
|
||||
"ADMIN_BACK_TO_ADMIN":"⬅️返回后台管理",
|
||||
"ADMIN_BACK_TO_LIST":"⬅️返回列表",
|
||||
"ADMIN_BACK_TO_MAIN":"🏠返回主菜单",
|
||||
@@ -1292,7 +1293,7 @@
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN":"📱<b>连接订阅</b>\n\nℹ️订阅链接在下方按钮中或“我的订阅”部分可用。\n\n💡<b>请选择您的设备</b>以获取详细设置说明:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE":"🚀<b>连接订阅</b>\n\n🔗点击下方按钮打开订阅链接:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT":"📱复制链接并添加到您的VPN应用程序中",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION":"🔗<b>连接链接:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION":"🔗<b>连接链接:</b>\n{subscription_url}",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE":"📱<b>连接订阅</b>\n\n🚀点击下方按钮在Telegram小程序中打开订阅:",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND":"❌未找到此设备的应用程序",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP":"📋<b>推荐应用:</b>{app_name}",
|
||||
@@ -1619,7 +1620,7 @@
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN":"📱<b>连接订阅</b>\n\nℹ️订阅链接在下方按钮中或“我的订阅”部分可用。\n\n💡<b>请选择您的设备</b>以获取详细设置说明:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE":"🚀<b>连接订阅</b>\n\n🔗点击下方按钮打开订阅链接:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT":"📱复制链接并添加到您的VPN应用程序中",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION":"🔗<b>连接链接:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION":"🔗<b>连接链接:</b>\n{subscription_url}",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE":"📱<b>连接订阅</b>\n\n🚀点击下方按钮在Telegram小程序中打开订阅:",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND":"❌未找到此设备的应用程序",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP":"📋<b>推荐应用:</b>{app_name}",
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Middleware для автоматического логирования кликов по кнопкам."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable, Set
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import CallbackQuery, TelegramObject
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Известные builtin callback_data из меню
|
||||
BUILTIN_CALLBACKS: Set[str] = {
|
||||
# Основные кнопки меню
|
||||
"subscription_connect",
|
||||
"subscription_happ_download",
|
||||
"menu_subscription",
|
||||
"buy_traffic",
|
||||
"menu_balance",
|
||||
"menu_trial",
|
||||
"menu_buy",
|
||||
"simple_subscription_purchase",
|
||||
"return_to_saved_cart",
|
||||
"menu_promocode",
|
||||
"menu_referrals",
|
||||
"contests_menu",
|
||||
"menu_support",
|
||||
"menu_info",
|
||||
"menu_language",
|
||||
"admin_panel",
|
||||
"moderator_panel",
|
||||
# Навигация
|
||||
"back_to_menu",
|
||||
"menu_faq",
|
||||
"menu_info_promo_groups",
|
||||
"menu_privacy_policy",
|
||||
"menu_public_offer",
|
||||
"menu_rules",
|
||||
"menu_server_status",
|
||||
# Баланс
|
||||
"balance_history",
|
||||
"balance_topup",
|
||||
# Подписка
|
||||
"subscription_extend",
|
||||
"subscription_autopay",
|
||||
"subscription_settings",
|
||||
"open_subscription_link",
|
||||
"subscription_add_countries",
|
||||
"subscription_reset_traffic",
|
||||
"subscription_switch_traffic",
|
||||
"subscription_change_devices",
|
||||
"subscription_manage_devices",
|
||||
"subscription_upgrade",
|
||||
# Устройства
|
||||
"device_guide_ios",
|
||||
"device_guide_android",
|
||||
"device_guide_windows",
|
||||
"device_guide_mac",
|
||||
"device_guide_tv",
|
||||
"device_guide_appletv",
|
||||
# Happ
|
||||
"happ_download_ios",
|
||||
"happ_download_android",
|
||||
"happ_download_macos",
|
||||
"happ_download_windows",
|
||||
# Рефералы
|
||||
"referral_create_invite",
|
||||
"referral_show_qr",
|
||||
"referral_list",
|
||||
"referral_analytics",
|
||||
# Поддержка
|
||||
"create_ticket",
|
||||
"my_tickets",
|
||||
# Триал
|
||||
"trial_activate",
|
||||
# Покупка
|
||||
"clear_saved_cart",
|
||||
"subscription_confirm",
|
||||
"subscription_cancel",
|
||||
}
|
||||
|
||||
|
||||
class ButtonStatsMiddleware(BaseMiddleware):
|
||||
"""Middleware для автоматического логирования статистики кликов по кнопкам."""
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""Перехватывает CallbackQuery и логирует клики по кнопкам."""
|
||||
|
||||
# Обрабатываем только CallbackQuery
|
||||
if not isinstance(event, CallbackQuery):
|
||||
return await handler(event, data)
|
||||
|
||||
# Пропускаем, если статистика отключена
|
||||
if not settings.MENU_LAYOUT_ENABLED:
|
||||
return await handler(event, data)
|
||||
|
||||
# Логируем клик асинхронно, не блокируя обработку
|
||||
try:
|
||||
# Получаем callback_data
|
||||
callback_data = event.data
|
||||
if not callback_data:
|
||||
return await handler(event, data)
|
||||
|
||||
# Получаем user_id
|
||||
user_id = event.from_user.id if event.from_user else None
|
||||
|
||||
# Определяем тип кнопки по callback_data
|
||||
button_type = self._determine_button_type(callback_data)
|
||||
|
||||
# Получаем текст кнопки, если возможно
|
||||
button_text = None
|
||||
if event.message and hasattr(event.message, 'reply_markup'):
|
||||
button_text = self._extract_button_text(event.message.reply_markup, callback_data)
|
||||
|
||||
# Логируем в фоне, не блокируя обработку
|
||||
asyncio.create_task(
|
||||
self._log_button_click_async(
|
||||
button_id=callback_data,
|
||||
user_id=user_id,
|
||||
callback_data=callback_data,
|
||||
button_type=button_type,
|
||||
button_text=button_text
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
# Не прерываем обработку при ошибке логирования
|
||||
logger.error(f"Ошибка логирования клика по кнопке: {e}", exc_info=True)
|
||||
|
||||
# Продолжаем обработку
|
||||
return await handler(event, data)
|
||||
|
||||
def _determine_button_type(self, callback_data: str) -> str:
|
||||
"""Определяет тип кнопки по callback_data.
|
||||
|
||||
Примечание: URL и MiniApp кнопки не имеют callback_data,
|
||||
поэтому они не отслеживаются через этот middleware.
|
||||
Для их отслеживания нужен отдельный механизм на стороне клиента.
|
||||
"""
|
||||
# Проверяем по известному списку builtin кнопок
|
||||
if callback_data in BUILTIN_CALLBACKS:
|
||||
return "builtin"
|
||||
|
||||
# Дополнительная проверка по префиксам для динамических callback_data
|
||||
builtin_prefixes = (
|
||||
"menu_",
|
||||
"admin_",
|
||||
"subscription_",
|
||||
"balance_",
|
||||
"referral_",
|
||||
"device_guide_",
|
||||
"happ_download_",
|
||||
)
|
||||
if callback_data.startswith(builtin_prefixes):
|
||||
return "builtin"
|
||||
|
||||
# Всё остальное - кастомные callback кнопки
|
||||
return "callback"
|
||||
|
||||
def _extract_button_text(self, reply_markup, callback_data: str) -> str:
|
||||
"""Извлекает текст кнопки из клавиатуры."""
|
||||
try:
|
||||
if not reply_markup or not hasattr(reply_markup, 'inline_keyboard'):
|
||||
return None
|
||||
|
||||
for row in reply_markup.inline_keyboard:
|
||||
for button in row:
|
||||
if hasattr(button, 'callback_data') and button.callback_data == callback_data:
|
||||
if hasattr(button, 'text'):
|
||||
return button.text
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def _log_button_click_async(
|
||||
self,
|
||||
button_id: str,
|
||||
user_id: int = None,
|
||||
callback_data: str = None,
|
||||
button_type: str = None,
|
||||
button_text: str = None
|
||||
):
|
||||
"""Асинхронно логирует клик по кнопке."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
from app.services.menu_layout_service import MenuLayoutService
|
||||
|
||||
await MenuLayoutService.log_button_click(
|
||||
db,
|
||||
button_id=button_id,
|
||||
user_id=user_id,
|
||||
callback_data=callback_data,
|
||||
button_type=button_type,
|
||||
button_text=button_text
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка записи клика в БД {button_id}: {e}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка создания сессии БД для логирования клика: {e}")
|
||||
|
||||
@@ -108,7 +108,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
elif member.status in self.BAD_MEMBER_STATUS:
|
||||
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
|
||||
|
||||
if telegram_id:
|
||||
if telegram_id and settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
|
||||
await self._deactivate_trial_subscription(telegram_id)
|
||||
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
@@ -254,6 +254,13 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
break
|
||||
|
||||
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
|
||||
logger.debug(
|
||||
"ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено",
|
||||
telegram_id,
|
||||
)
|
||||
return
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta, time, timezone
|
||||
from typing import Dict, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
create_round,
|
||||
get_active_round_by_template,
|
||||
list_templates,
|
||||
upsert_template,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import ContestTemplate, SubscriptionStatus, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Slugs for games
|
||||
GAME_QUEST = "quest_buttons"
|
||||
GAME_LOCKS = "lock_hack"
|
||||
GAME_CIPHER = "letter_cipher"
|
||||
GAME_SERVER = "server_lottery"
|
||||
GAME_BLITZ = "blitz_reaction"
|
||||
GAME_EMOJI = "emoji_guess"
|
||||
GAME_ANAGRAM = "anagram"
|
||||
|
||||
|
||||
DEFAULT_TEMPLATES = [
|
||||
{
|
||||
"slug": GAME_QUEST,
|
||||
"name": "Квест-кнопки",
|
||||
"description": "Найди секретную кнопку 3×3",
|
||||
"prize_days": 1,
|
||||
"max_winners": 3,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
"schedule_times": "10:00,18:00",
|
||||
"payload": {"rows": 3, "cols": 3},
|
||||
"is_enabled": False,
|
||||
},
|
||||
{
|
||||
"slug": GAME_LOCKS,
|
||||
"name": "Кнопочный взлом",
|
||||
"description": "Найди взломанную кнопку среди 20 замков",
|
||||
"prize_days": 5,
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
"schedule_times": "09:00,19:00",
|
||||
"payload": {"buttons": 20},
|
||||
"is_enabled": False,
|
||||
},
|
||||
{
|
||||
"slug": GAME_CIPHER,
|
||||
"name": "Шифр букв",
|
||||
"description": "Расшифруй слово по номерам",
|
||||
"prize_days": 1,
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
"schedule_times": "12:00,20:00",
|
||||
"payload": {"words": ["VPN", "SERVER", "PROXY", "XRAY"]},
|
||||
"is_enabled": False,
|
||||
},
|
||||
{
|
||||
"slug": GAME_SERVER,
|
||||
"name": "Сервер-лотерея",
|
||||
"description": "Угадай доступный сервер",
|
||||
"prize_days": 7,
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 1,
|
||||
"schedule_times": "15:00",
|
||||
"payload": {"flags": ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]},
|
||||
"is_enabled": False,
|
||||
},
|
||||
{
|
||||
"slug": GAME_BLITZ,
|
||||
"name": "Блиц-реакция",
|
||||
"description": "Нажми кнопку за 10 секунд",
|
||||
"prize_days": 1,
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
"schedule_times": "11:00,21:00",
|
||||
"payload": {"timeout_seconds": 10},
|
||||
"is_enabled": False,
|
||||
},
|
||||
{
|
||||
"slug": GAME_EMOJI,
|
||||
"name": "Угадай сервис по эмодзи",
|
||||
"description": "Определи сервис по эмодзи",
|
||||
"prize_days": 1,
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 1,
|
||||
"schedule_times": "13:00",
|
||||
"payload": {"pairs": [{"question": "🔐📡🌐", "answer": "VPN"}]},
|
||||
"is_enabled": False,
|
||||
},
|
||||
{
|
||||
"slug": GAME_ANAGRAM,
|
||||
"name": "Анаграмма дня",
|
||||
"description": "Собери слово из букв",
|
||||
"prize_days": 1,
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 1,
|
||||
"schedule_times": "17:00",
|
||||
"payload": {"words": ["SERVER", "XRAY", "VPN"]},
|
||||
"is_enabled": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ContestRotationService:
|
||||
def __init__(self) -> None:
|
||||
self.bot: Optional[Bot] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._interval_seconds = 60
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
self.bot = bot
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop()
|
||||
|
||||
if not settings.is_contests_enabled():
|
||||
logger.info("Сервис игр отключён настройками")
|
||||
return
|
||||
|
||||
await self._ensure_default_templates()
|
||||
|
||||
self._task = asyncio.create_task(self._loop())
|
||||
logger.info("🎲 Сервис ротационных конкурсов запущен")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def _ensure_default_templates(self) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
for tpl in DEFAULT_TEMPLATES:
|
||||
try:
|
||||
await upsert_template(db, **tpl)
|
||||
except Exception as exc:
|
||||
logger.error("Не удалось создать шаблон %s: %s", tpl["slug"], exc)
|
||||
|
||||
async def _loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
await self._tick()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Ошибка в ротации конкурсов: %s", exc)
|
||||
await asyncio.sleep(self._interval_seconds)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Сервис ротации конкурсов остановлен")
|
||||
raise
|
||||
|
||||
def _parse_times(self, times_str: Optional[str]) -> List[time]:
|
||||
if not times_str:
|
||||
return []
|
||||
times: List[time] = []
|
||||
for part in times_str.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
hh, mm = part.split(":")
|
||||
times.append(time(int(hh), int(mm)))
|
||||
except Exception:
|
||||
continue
|
||||
return times
|
||||
|
||||
async def _tick(self) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
templates = await list_templates(db)
|
||||
# Get current time in configured timezone
|
||||
tz = self._get_timezone()
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
now_local = now_utc.astimezone(tz)
|
||||
|
||||
for tpl in templates:
|
||||
times = self._parse_times(tpl.schedule_times) or []
|
||||
for slot in times[: tpl.times_per_day]:
|
||||
# Apply schedule time to local date
|
||||
starts_at_local = now_local.replace(
|
||||
hour=slot.hour, minute=slot.minute, second=0, microsecond=0
|
||||
)
|
||||
if starts_at_local > now_local:
|
||||
starts_at_local -= timedelta(days=1)
|
||||
ends_at_local = starts_at_local + timedelta(hours=tpl.cooldown_hours)
|
||||
if not (starts_at_local <= now_local <= ends_at_local):
|
||||
continue
|
||||
|
||||
exists = await get_active_round_by_template(db, tpl.id)
|
||||
if exists:
|
||||
continue
|
||||
|
||||
# Convert to UTC for storage
|
||||
starts_at_utc = starts_at_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
ends_at_utc = ends_at_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
# Анонс перед созданием раунда
|
||||
await self._announce_round_start(tpl, starts_at_local, ends_at_local)
|
||||
payload = self._build_payload_for_template(tpl)
|
||||
round_obj = await create_round(
|
||||
db,
|
||||
template=tpl,
|
||||
starts_at=starts_at_utc,
|
||||
ends_at=ends_at_utc,
|
||||
payload=payload,
|
||||
)
|
||||
logger.info("Создан раунд %s для шаблона %s", round_obj.id, tpl.slug)
|
||||
|
||||
def _get_timezone(self) -> ZoneInfo:
|
||||
tz_name = settings.TIMEZONE or "UTC"
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name)
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
def _build_payload_for_template(self, tpl: ContestTemplate) -> Dict:
|
||||
payload = tpl.payload or {}
|
||||
if tpl.slug == GAME_QUEST:
|
||||
rows = payload.get("rows", 3)
|
||||
cols = payload.get("cols", 3)
|
||||
total = rows * cols
|
||||
secret_idx = random.randint(0, total - 1)
|
||||
return {"rows": rows, "cols": cols, "secret_idx": secret_idx}
|
||||
if tpl.slug == GAME_LOCKS:
|
||||
total = payload.get("buttons", 20)
|
||||
secret_idx = random.randint(0, max(0, total - 1))
|
||||
return {"total": total, "secret_idx": secret_idx}
|
||||
if tpl.slug == GAME_CIPHER:
|
||||
words = payload.get("words") or ["VPN"]
|
||||
word = random.choice(words)
|
||||
codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()]
|
||||
return {"question": "-".join(codes), "answer": word.upper()}
|
||||
if tpl.slug == GAME_SERVER:
|
||||
flags = payload.get("flags") or ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]
|
||||
secret_idx = random.randint(0, len(flags) - 1)
|
||||
return {"flags": flags, "secret_idx": secret_idx}
|
||||
if tpl.slug == GAME_BLITZ:
|
||||
return {"timeout_seconds": payload.get("timeout_seconds", 10)}
|
||||
if tpl.slug == GAME_EMOJI:
|
||||
pairs = payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}]
|
||||
pair = random.choice(pairs)
|
||||
return pair
|
||||
if tpl.slug == GAME_ANAGRAM:
|
||||
words = payload.get("words") or ["SERVER"]
|
||||
word = random.choice(words).upper()
|
||||
shuffled = "".join(random.sample(word, len(word)))
|
||||
return {"letters": shuffled, "answer": word}
|
||||
return payload
|
||||
|
||||
async def _announce_round_start(
|
||||
self,
|
||||
tpl: ContestTemplate,
|
||||
starts_at_local: datetime,
|
||||
ends_at_local: datetime,
|
||||
) -> None:
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
text = (
|
||||
f"🎲 Стартует игра: <b>{tpl.name}</b>\n"
|
||||
f"Приз: {tpl.prize_days} дн. подписки • Победителей: {tpl.max_winners}\n"
|
||||
f"Попыток/польз: {tpl.attempts_per_user}\n\n"
|
||||
"Участвовать могут только с активной или триальной подпиской."
|
||||
)
|
||||
|
||||
await asyncio.gather(
|
||||
self._send_channel_announce(text),
|
||||
self._broadcast_to_users(text),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
async def _send_channel_announce(self, text: str) -> None:
|
||||
if not self.bot:
|
||||
return
|
||||
channel_id_raw = settings.CHANNEL_SUB_ID
|
||||
if not channel_id_raw:
|
||||
return
|
||||
try:
|
||||
channel_id = int(channel_id_raw)
|
||||
except Exception:
|
||||
channel_id = channel_id_raw
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🎲 Играть", callback_data="contests_menu")]
|
||||
])
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=channel_id,
|
||||
text=text,
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Не удалось отправить анонс в канал %s: %s", channel_id_raw, exc)
|
||||
|
||||
async def _broadcast_to_users(self, text: str) -> None:
|
||||
"""Отправляет анонс всем пользователям с активной/триальной подпиской."""
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
batch_size = 500
|
||||
offset = 0
|
||||
sent = failed = 0
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🎲 Играть", callback_data="contests_menu")]
|
||||
])
|
||||
|
||||
while True:
|
||||
async with AsyncSessionLocal() as db:
|
||||
users_batch = await self._load_users_batch(db, offset, batch_size)
|
||||
if not users_batch:
|
||||
break
|
||||
offset += batch_size
|
||||
|
||||
tasks = []
|
||||
semaphore = asyncio.Semaphore(15)
|
||||
|
||||
async def _send(u: User):
|
||||
nonlocal sent, failed
|
||||
async with semaphore:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=u.telegram_id,
|
||||
text=text,
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
sent += 1
|
||||
except Exception:
|
||||
failed += 1
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
for user in users_batch:
|
||||
tasks.append(asyncio.create_task(_send(user)))
|
||||
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
logger.info("Анонс игр: отправлено=%s, ошибок=%s", sent, failed)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Ошибка рассылки анонса игр пользователям: %s", exc)
|
||||
|
||||
async def _load_users_batch(self, db: AsyncSession, offset: int, limit: int) -> List[User]:
|
||||
from app.database.crud.user import get_users_list
|
||||
|
||||
users = await get_users_list(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
status=None,
|
||||
)
|
||||
allowed: List[User] = []
|
||||
for u in users:
|
||||
sub = getattr(u, "subscription", None)
|
||||
if not sub:
|
||||
continue
|
||||
if sub.status in {SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value}:
|
||||
allowed.append(u)
|
||||
return allowed
|
||||
|
||||
|
||||
contest_rotation_service = ContestRotationService()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Модуль конструктора меню.
|
||||
|
||||
Структура модуля:
|
||||
- constants.py - константы и дефолтная конфигурация
|
||||
- context.py - MenuContext для построения меню
|
||||
- history_service.py - сервис истории изменений
|
||||
- stats_service.py - сервис статистики кликов
|
||||
- service.py - основной MenuLayoutService
|
||||
"""
|
||||
|
||||
from .constants import (
|
||||
MENU_LAYOUT_CONFIG_KEY,
|
||||
DEFAULT_MENU_CONFIG,
|
||||
BUILTIN_BUTTONS_INFO,
|
||||
AVAILABLE_CALLBACKS,
|
||||
DYNAMIC_PLACEHOLDERS,
|
||||
)
|
||||
from .context import MenuContext
|
||||
from .history_service import MenuLayoutHistoryService
|
||||
from .stats_service import MenuLayoutStatsService
|
||||
from .service import MenuLayoutService
|
||||
|
||||
__all__ = [
|
||||
# Константы
|
||||
"MENU_LAYOUT_CONFIG_KEY",
|
||||
"DEFAULT_MENU_CONFIG",
|
||||
"BUILTIN_BUTTONS_INFO",
|
||||
"AVAILABLE_CALLBACKS",
|
||||
"DYNAMIC_PLACEHOLDERS",
|
||||
# Классы
|
||||
"MenuContext",
|
||||
"MenuLayoutService",
|
||||
"MenuLayoutHistoryService",
|
||||
"MenuLayoutStatsService",
|
||||
]
|
||||
@@ -0,0 +1,501 @@
|
||||
"""Константы для конструктора меню."""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# Ключ для хранения конфигурации в SystemSetting
|
||||
MENU_LAYOUT_CONFIG_KEY = "menu_layout_config"
|
||||
|
||||
# Дефолтная конфигурация меню
|
||||
DEFAULT_MENU_CONFIG: Dict[str, Any] = {
|
||||
"version": 1,
|
||||
"rows": [
|
||||
{
|
||||
"id": "connect_row",
|
||||
"buttons": ["connect"],
|
||||
"conditions": {"has_active_subscription": True, "subscription_is_active": True},
|
||||
"max_per_row": 1,
|
||||
},
|
||||
{
|
||||
"id": "happ_row",
|
||||
"buttons": ["happ_download"],
|
||||
"conditions": {"has_active_subscription": True, "happ_enabled": True},
|
||||
"max_per_row": 1,
|
||||
},
|
||||
{
|
||||
"id": "subscription_traffic_row",
|
||||
"buttons": ["subscription", "buy_traffic"],
|
||||
"conditions": {"has_active_subscription": True},
|
||||
"max_per_row": 2,
|
||||
},
|
||||
{
|
||||
"id": "balance_row",
|
||||
"buttons": ["balance"],
|
||||
"conditions": None,
|
||||
"max_per_row": 1,
|
||||
},
|
||||
{
|
||||
"id": "trial_buy_row",
|
||||
"buttons": ["trial", "buy_subscription"],
|
||||
"conditions": None,
|
||||
"max_per_row": 2,
|
||||
},
|
||||
{
|
||||
"id": "simple_subscription_row",
|
||||
"buttons": ["simple_subscription"],
|
||||
"conditions": {"simple_subscription_enabled": True},
|
||||
"max_per_row": 1,
|
||||
},
|
||||
{
|
||||
"id": "resume_row",
|
||||
"buttons": ["resume_checkout"],
|
||||
"conditions": {"has_saved_cart": True},
|
||||
"max_per_row": 1,
|
||||
},
|
||||
{
|
||||
"id": "promo_referral_row",
|
||||
"buttons": ["promocode", "referrals"],
|
||||
"conditions": None,
|
||||
"max_per_row": 2,
|
||||
},
|
||||
{
|
||||
"id": "contests_row",
|
||||
"buttons": ["contests"],
|
||||
"conditions": {"contests_visible": True},
|
||||
"max_per_row": 2,
|
||||
},
|
||||
{
|
||||
"id": "support_info_row",
|
||||
"buttons": ["support", "info"],
|
||||
"conditions": None,
|
||||
"max_per_row": 2,
|
||||
},
|
||||
{
|
||||
"id": "language_row",
|
||||
"buttons": ["language"],
|
||||
"conditions": {"language_selection_enabled": True},
|
||||
"max_per_row": 2,
|
||||
},
|
||||
{
|
||||
"id": "admin_row",
|
||||
"buttons": ["admin_panel"],
|
||||
"conditions": {"is_admin": True},
|
||||
"max_per_row": 1,
|
||||
},
|
||||
{
|
||||
"id": "moderator_row",
|
||||
"buttons": ["moderator_panel"],
|
||||
"conditions": {"is_moderator": True},
|
||||
"max_per_row": 1,
|
||||
},
|
||||
],
|
||||
"buttons": {
|
||||
"connect": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "connect",
|
||||
"text": {"ru": "🔗 Подключиться", "en": "🔗 Connect"},
|
||||
"action": "subscription_connect",
|
||||
"enabled": True,
|
||||
"visibility": "subscribers",
|
||||
"conditions": {"has_active_subscription": True, "subscription_is_active": True},
|
||||
"dynamic_text": False,
|
||||
"open_mode": "callback", # "callback" или "direct"
|
||||
"webapp_url": None, # URL для Mini App при open_mode="direct"
|
||||
},
|
||||
"happ_download": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "happ_download",
|
||||
"text": {"ru": "⬇️ Скачать Happ", "en": "⬇️ Download Happ"},
|
||||
"action": "subscription_happ_download",
|
||||
"enabled": True,
|
||||
"visibility": "subscribers",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"subscription": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "subscription",
|
||||
"text": {"ru": "📊 Подписка", "en": "📊 Subscription"},
|
||||
"action": "menu_subscription",
|
||||
"enabled": True,
|
||||
"visibility": "subscribers",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"buy_traffic": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "buy_traffic",
|
||||
"text": {"ru": "📈 Докупить трафик", "en": "📈 Buy traffic"},
|
||||
"action": "buy_traffic",
|
||||
"enabled": True,
|
||||
"visibility": "subscribers",
|
||||
"conditions": {"has_traffic_limit": True},
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"balance": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "balance",
|
||||
"text": {"ru": "💰 Баланс: {balance}", "en": "💰 Balance: {balance}"},
|
||||
"action": "menu_balance",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": None,
|
||||
"dynamic_text": True,
|
||||
},
|
||||
"trial": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "trial",
|
||||
"text": {"ru": "🎁 Пробный период", "en": "🎁 Free trial"},
|
||||
"action": "menu_trial",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": {"show_trial": True},
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"buy_subscription": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "buy_subscription",
|
||||
"text": {"ru": "🛒 Купить подписку", "en": "🛒 Buy subscription"},
|
||||
"action": "menu_buy",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": {"show_buy": True},
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"simple_subscription": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "simple_subscription",
|
||||
"text": {"ru": "💳 Простая подписка", "en": "💳 Simple subscription"},
|
||||
"action": "simple_subscription_purchase",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"resume_checkout": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "resume_checkout",
|
||||
"text": {"ru": "↩️ Вернуться к оформлению", "en": "↩️ Resume checkout"},
|
||||
"action": "return_to_saved_cart",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"promocode": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "promocode",
|
||||
"text": {"ru": "🎟️ Промокод", "en": "🎟️ Promo code"},
|
||||
"action": "menu_promocode",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"referrals": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "referrals",
|
||||
"text": {"ru": "👥 Рефералы", "en": "👥 Referrals"},
|
||||
"action": "menu_referrals",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": {"referral_enabled": True},
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"contests": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "contests",
|
||||
"text": {"ru": "🎲 Конкурсы", "en": "🎲 Contests"},
|
||||
"action": "contests_menu",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"support": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "support",
|
||||
"text": {"ru": "💬 Поддержка", "en": "💬 Support"},
|
||||
"action": "menu_support",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": {"support_enabled": True},
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"info": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "info",
|
||||
"text": {"ru": "ℹ️ Инфо", "en": "ℹ️ Info"},
|
||||
"action": "menu_info",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"language": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "language",
|
||||
"text": {"ru": "🌐 Язык", "en": "🌐 Language"},
|
||||
"action": "menu_language",
|
||||
"enabled": True,
|
||||
"visibility": "all",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"admin_panel": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "admin_panel",
|
||||
"text": {"ru": "⚙️ Админ панель", "en": "⚙️ Admin panel"},
|
||||
"action": "admin_panel",
|
||||
"enabled": True,
|
||||
"visibility": "admins",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"moderator_panel": {
|
||||
"type": "builtin",
|
||||
"builtin_id": "moderator_panel",
|
||||
"text": {"ru": "🧑⚖️ Модерация", "en": "🧑⚖️ Moderation"},
|
||||
"action": "moderator_panel",
|
||||
"enabled": True,
|
||||
"visibility": "moderators",
|
||||
"conditions": None,
|
||||
"dynamic_text": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Информация о встроенных кнопках для API
|
||||
BUILTIN_BUTTONS_INFO: List[Dict[str, Any]] = [
|
||||
{
|
||||
"id": "connect",
|
||||
"default_text": {"ru": "🔗 Подключиться", "en": "🔗 Connect"},
|
||||
"callback_data": "subscription_connect",
|
||||
"default_conditions": {"has_active_subscription": True, "subscription_is_active": True},
|
||||
"supports_dynamic_text": False,
|
||||
"supports_direct_open": True,
|
||||
},
|
||||
{
|
||||
"id": "happ_download",
|
||||
"default_text": {"ru": "⬇️ Скачать Happ", "en": "⬇️ Download Happ"},
|
||||
"callback_data": "subscription_happ_download",
|
||||
"default_conditions": {"happ_enabled": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "subscription",
|
||||
"default_text": {"ru": "📊 Подписка", "en": "📊 Subscription"},
|
||||
"callback_data": "menu_subscription",
|
||||
"default_conditions": {"has_active_subscription": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "buy_traffic",
|
||||
"default_text": {"ru": "📈 Докупить трафик", "en": "📈 Buy traffic"},
|
||||
"callback_data": "buy_traffic",
|
||||
"default_conditions": {"has_traffic_limit": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "balance",
|
||||
"default_text": {"ru": "💰 Баланс: {balance}", "en": "💰 Balance: {balance}"},
|
||||
"callback_data": "menu_balance",
|
||||
"default_conditions": None,
|
||||
"supports_dynamic_text": True,
|
||||
},
|
||||
{
|
||||
"id": "trial",
|
||||
"default_text": {"ru": "🎁 Пробный период", "en": "🎁 Free trial"},
|
||||
"callback_data": "menu_trial",
|
||||
"default_conditions": {"show_trial": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "buy_subscription",
|
||||
"default_text": {"ru": "🛒 Купить подписку", "en": "🛒 Buy subscription"},
|
||||
"callback_data": "menu_buy",
|
||||
"default_conditions": {"show_buy": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "simple_subscription",
|
||||
"default_text": {"ru": "💳 Простая подписка", "en": "💳 Simple subscription"},
|
||||
"callback_data": "simple_subscription_purchase",
|
||||
"default_conditions": {"simple_subscription_enabled": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "resume_checkout",
|
||||
"default_text": {"ru": "↩️ Вернуться к оформлению", "en": "↩️ Resume checkout"},
|
||||
"callback_data": "return_to_saved_cart",
|
||||
"default_conditions": {"has_saved_cart": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "promocode",
|
||||
"default_text": {"ru": "🎟️ Промокод", "en": "🎟️ Promo code"},
|
||||
"callback_data": "menu_promocode",
|
||||
"default_conditions": None,
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "referrals",
|
||||
"default_text": {"ru": "👥 Рефералы", "en": "👥 Referrals"},
|
||||
"callback_data": "menu_referrals",
|
||||
"default_conditions": {"referral_enabled": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "contests",
|
||||
"default_text": {"ru": "🎲 Конкурсы", "en": "🎲 Contests"},
|
||||
"callback_data": "contests_menu",
|
||||
"default_conditions": {"contests_visible": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "support",
|
||||
"default_text": {"ru": "💬 Поддержка", "en": "💬 Support"},
|
||||
"callback_data": "menu_support",
|
||||
"default_conditions": {"support_enabled": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "info",
|
||||
"default_text": {"ru": "ℹ️ Инфо", "en": "ℹ️ Info"},
|
||||
"callback_data": "menu_info",
|
||||
"default_conditions": None,
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "language",
|
||||
"default_text": {"ru": "🌐 Язык", "en": "🌐 Language"},
|
||||
"callback_data": "menu_language",
|
||||
"default_conditions": {"language_selection_enabled": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "admin_panel",
|
||||
"default_text": {"ru": "⚙️ Админ панель", "en": "⚙️ Admin panel"},
|
||||
"callback_data": "admin_panel",
|
||||
"default_conditions": {"is_admin": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
{
|
||||
"id": "moderator_panel",
|
||||
"default_text": {"ru": "🧑⚖️ Модерация", "en": "🧑⚖️ Moderation"},
|
||||
"callback_data": "moderator_panel",
|
||||
"default_conditions": {"is_moderator": True},
|
||||
"supports_dynamic_text": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Все доступные callback_data в боте (для добавления кастомных кнопок)
|
||||
AVAILABLE_CALLBACKS: List[Dict[str, Any]] = [
|
||||
# Меню
|
||||
{"callback_data": "back_to_menu", "name": "Назад в меню", "category": "menu", "icon": "⬅️",
|
||||
"text": {"ru": "⬅️ Назад", "en": "⬅️ Back"}},
|
||||
{"callback_data": "menu_faq", "name": "FAQ", "category": "menu", "icon": "❓",
|
||||
"text": {"ru": "❓ FAQ", "en": "❓ FAQ"}},
|
||||
{"callback_data": "menu_info_promo_groups", "name": "Промо-группы", "category": "menu", "icon": "👥",
|
||||
"text": {"ru": "👥 Промо-группы", "en": "👥 Promo groups"}},
|
||||
{"callback_data": "menu_privacy_policy", "name": "Политика конфиденциальности", "category": "menu", "icon": "🔒",
|
||||
"text": {"ru": "🔒 Политика конфиденциальности", "en": "🔒 Privacy Policy"}},
|
||||
{"callback_data": "menu_public_offer", "name": "Публичная оферта", "category": "menu", "icon": "📜",
|
||||
"text": {"ru": "📜 Публичная оферта", "en": "📜 Public Offer"}},
|
||||
{"callback_data": "menu_rules", "name": "Правила", "category": "menu", "icon": "📋",
|
||||
"text": {"ru": "📋 Правила", "en": "📋 Rules"}},
|
||||
{"callback_data": "menu_server_status", "name": "Статус серверов", "category": "menu", "icon": "🖥️",
|
||||
"text": {"ru": "🖥️ Статус серверов", "en": "🖥️ Server Status"}},
|
||||
|
||||
# Баланс
|
||||
{"callback_data": "balance_history", "name": "История баланса", "category": "balance", "icon": "📜",
|
||||
"text": {"ru": "📜 История", "en": "📜 History"}},
|
||||
{"callback_data": "balance_topup", "name": "Пополнить баланс", "category": "balance", "icon": "💳",
|
||||
"text": {"ru": "💳 Пополнить", "en": "💳 Top up"}},
|
||||
|
||||
# Подписка
|
||||
{"callback_data": "subscription_extend", "name": "Продлить подписку", "category": "subscription", "icon": "📅",
|
||||
"text": {"ru": "📅 Продлить", "en": "📅 Extend"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_autopay", "name": "Автоплатёж", "category": "subscription", "icon": "🔄",
|
||||
"text": {"ru": "🔄 Автоплатёж", "en": "🔄 Autopay"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_settings", "name": "Настройки подписки", "category": "subscription", "icon": "⚙️",
|
||||
"text": {"ru": "⚙️ Настройки", "en": "⚙️ Settings"}, "requires_subscription": True},
|
||||
{"callback_data": "open_subscription_link", "name": "Показать ссылку подписки", "category": "subscription", "icon": "🔗",
|
||||
"text": {"ru": "🔗 Показать ссылку", "en": "🔗 Show link"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_add_countries", "name": "Добавить страны", "category": "subscription", "icon": "🌍",
|
||||
"text": {"ru": "🌍 Добавить страны", "en": "🌍 Add countries"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_reset_traffic", "name": "Сбросить трафик", "category": "subscription", "icon": "🔄",
|
||||
"text": {"ru": "🔄 Сбросить трафик", "en": "🔄 Reset traffic"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_switch_traffic", "name": "Переключить трафик", "category": "subscription", "icon": "🔀",
|
||||
"text": {"ru": "🔀 Переключить трафик", "en": "🔀 Switch traffic"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_change_devices", "name": "Изменить устройства", "category": "subscription", "icon": "📱",
|
||||
"text": {"ru": "📱 Изменить устройства", "en": "📱 Change devices"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_manage_devices", "name": "Управление устройствами", "category": "subscription", "icon": "📲",
|
||||
"text": {"ru": "📲 Управление устройствами", "en": "📲 Manage devices"}, "requires_subscription": True},
|
||||
{"callback_data": "subscription_upgrade", "name": "Улучшить подписку", "category": "subscription", "icon": "⬆️",
|
||||
"text": {"ru": "⬆️ Улучшить", "en": "⬆️ Upgrade"}, "requires_subscription": True},
|
||||
|
||||
# Подключение устройств
|
||||
{"callback_data": "device_guide_ios", "name": "Инструкция iOS", "category": "devices", "icon": "📱",
|
||||
"text": {"ru": "📱 iOS", "en": "📱 iOS"}, "requires_subscription": True},
|
||||
{"callback_data": "device_guide_android", "name": "Инструкция Android", "category": "devices", "icon": "🤖",
|
||||
"text": {"ru": "🤖 Android", "en": "🤖 Android"}, "requires_subscription": True},
|
||||
{"callback_data": "device_guide_windows", "name": "Инструкция Windows", "category": "devices", "icon": "💻",
|
||||
"text": {"ru": "💻 Windows", "en": "💻 Windows"}, "requires_subscription": True},
|
||||
{"callback_data": "device_guide_mac", "name": "Инструкция macOS", "category": "devices", "icon": "🎯",
|
||||
"text": {"ru": "🎯 macOS", "en": "🎯 macOS"}, "requires_subscription": True},
|
||||
{"callback_data": "device_guide_tv", "name": "Инструкция Android TV", "category": "devices", "icon": "📺",
|
||||
"text": {"ru": "📺 Android TV", "en": "📺 Android TV"}, "requires_subscription": True},
|
||||
{"callback_data": "device_guide_appletv", "name": "Инструкция Apple TV", "category": "devices", "icon": "📺",
|
||||
"text": {"ru": "📺 Apple TV", "en": "📺 Apple TV"}, "requires_subscription": True},
|
||||
|
||||
# Happ
|
||||
{"callback_data": "happ_download_ios", "name": "Скачать Happ iOS", "category": "happ", "icon": "🍎",
|
||||
"text": {"ru": "🍎 iOS", "en": "🍎 iOS"}},
|
||||
{"callback_data": "happ_download_android", "name": "Скачать Happ Android", "category": "happ", "icon": "🤖",
|
||||
"text": {"ru": "🤖 Android", "en": "🤖 Android"}},
|
||||
{"callback_data": "happ_download_macos", "name": "Скачать Happ macOS", "category": "happ", "icon": "🖥️",
|
||||
"text": {"ru": "🖥️ macOS", "en": "🖥️ macOS"}},
|
||||
{"callback_data": "happ_download_windows", "name": "Скачать Happ Windows", "category": "happ", "icon": "💻",
|
||||
"text": {"ru": "💻 Windows", "en": "💻 Windows"}},
|
||||
|
||||
# Рефералы
|
||||
{"callback_data": "referral_create_invite", "name": "Создать инвайт", "category": "referral", "icon": "✉️",
|
||||
"text": {"ru": "✉️ Создать инвайт", "en": "✉️ Create invite"}},
|
||||
{"callback_data": "referral_show_qr", "name": "QR код реферала", "category": "referral", "icon": "📱",
|
||||
"text": {"ru": "📱 QR код", "en": "📱 QR code"}},
|
||||
{"callback_data": "referral_list", "name": "Список рефералов", "category": "referral", "icon": "👥",
|
||||
"text": {"ru": "👥 Мои рефералы", "en": "👥 My referrals"}},
|
||||
{"callback_data": "referral_analytics", "name": "Аналитика рефералов", "category": "referral", "icon": "📊",
|
||||
"text": {"ru": "📊 Аналитика", "en": "📊 Analytics"}},
|
||||
|
||||
# Поддержка
|
||||
{"callback_data": "create_ticket", "name": "Создать тикет", "category": "support", "icon": "✏️",
|
||||
"text": {"ru": "✏️ Создать тикет", "en": "✏️ Create ticket"}},
|
||||
{"callback_data": "my_tickets", "name": "Мои тикеты", "category": "support", "icon": "📋",
|
||||
"text": {"ru": "📋 Мои тикеты", "en": "📋 My tickets"}},
|
||||
|
||||
# Триал
|
||||
{"callback_data": "trial_activate", "name": "Активировать триал", "category": "trial", "icon": "🎁",
|
||||
"text": {"ru": "🎁 Активировать", "en": "🎁 Activate"}},
|
||||
|
||||
# Покупка
|
||||
{"callback_data": "clear_saved_cart", "name": "Очистить корзину", "category": "purchase", "icon": "🗑️",
|
||||
"text": {"ru": "🗑️ Очистить корзину", "en": "🗑️ Clear cart"}},
|
||||
{"callback_data": "subscription_confirm", "name": "Подтвердить покупку", "category": "purchase", "icon": "✅",
|
||||
"text": {"ru": "✅ Подтвердить", "en": "✅ Confirm"}},
|
||||
{"callback_data": "subscription_cancel", "name": "Отменить покупку", "category": "purchase", "icon": "❌",
|
||||
"text": {"ru": "❌ Отменить", "en": "❌ Cancel"}},
|
||||
]
|
||||
|
||||
# Динамические плейсхолдеры для текста кнопок
|
||||
DYNAMIC_PLACEHOLDERS: List[Dict[str, str]] = [
|
||||
{"placeholder": "{balance}", "description": "Баланс пользователя", "example": "1 500 ₽", "category": "user"},
|
||||
{"placeholder": "{username}", "description": "Имя пользователя", "example": "John", "category": "user"},
|
||||
{"placeholder": "{subscription_days}", "description": "Дней до окончания подписки", "example": "14", "category": "subscription"},
|
||||
{"placeholder": "{traffic_used}", "description": "Использованный трафик", "example": "5.2 GB", "category": "subscription"},
|
||||
{"placeholder": "{traffic_left}", "description": "Оставшийся трафик", "example": "94.8 GB", "category": "subscription"},
|
||||
{"placeholder": "{referral_count}", "description": "Количество рефералов", "example": "12", "category": "referral"},
|
||||
{"placeholder": "{referral_earnings}", "description": "Заработок с рефералов", "example": "500 ₽", "category": "referral"},
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Контекст меню для построения кнопок."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuContext:
|
||||
"""Контекст пользователя для построения меню."""
|
||||
|
||||
language: str = "ru"
|
||||
is_admin: bool = False
|
||||
is_moderator: bool = False
|
||||
has_active_subscription: bool = False
|
||||
subscription_is_active: bool = False
|
||||
has_had_paid_subscription: bool = False
|
||||
balance_kopeks: int = 0
|
||||
subscription: Optional[Any] = None
|
||||
show_resume_checkout: bool = False
|
||||
has_saved_cart: bool = False
|
||||
custom_buttons: List[InlineKeyboardButton] = field(default_factory=list)
|
||||
# Расширенные поля для плейсхолдеров и условий
|
||||
username: str = ""
|
||||
subscription_days: int = 0
|
||||
traffic_used_gb: float = 0.0
|
||||
traffic_left_gb: float = 0.0
|
||||
referral_count: int = 0
|
||||
referral_earnings_kopeks: int = 0
|
||||
registration_days: int = 0
|
||||
promo_group_id: Optional[str] = None
|
||||
has_autopay: bool = False
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Сервис истории изменений конфигурации меню."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select, func, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import MenuLayoutHistory
|
||||
|
||||
|
||||
class MenuLayoutHistoryService:
|
||||
"""Сервис для управления историей изменений меню."""
|
||||
|
||||
@classmethod
|
||||
async def save_history(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
config: Dict[str, Any],
|
||||
action: str,
|
||||
changes_summary: Optional[str] = None,
|
||||
user_info: Optional[str] = None,
|
||||
) -> MenuLayoutHistory:
|
||||
"""Сохранить запись в историю изменений."""
|
||||
history = MenuLayoutHistory(
|
||||
config_json=json.dumps(config, ensure_ascii=False),
|
||||
action=action,
|
||||
changes_summary=changes_summary or f"Action: {action}",
|
||||
user_info=user_info,
|
||||
)
|
||||
db.add(history)
|
||||
await db.commit()
|
||||
await db.refresh(history)
|
||||
return history
|
||||
|
||||
@classmethod
|
||||
async def get_history(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить историю изменений."""
|
||||
result = await db.execute(
|
||||
select(MenuLayoutHistory)
|
||||
.order_by(desc(MenuLayoutHistory.created_at))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": entry.id,
|
||||
"action": entry.action,
|
||||
"changes_summary": entry.changes_summary,
|
||||
"user_info": entry.user_info,
|
||||
"created_at": entry.created_at,
|
||||
}
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def get_history_count(cls, db: AsyncSession) -> int:
|
||||
"""Получить общее количество записей истории."""
|
||||
result = await db.execute(
|
||||
select(func.count(MenuLayoutHistory.id))
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
@classmethod
|
||||
async def get_history_entry(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
history_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Получить конкретную запись истории с конфигурацией."""
|
||||
result = await db.execute(
|
||||
select(MenuLayoutHistory).where(MenuLayoutHistory.id == history_id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": entry.id,
|
||||
"action": entry.action,
|
||||
"changes_summary": entry.changes_summary,
|
||||
"user_info": entry.user_info,
|
||||
"created_at": entry.created_at,
|
||||
"config": json.loads(entry.config_json),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def rollback_to_history(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
history_id: int,
|
||||
get_config_func,
|
||||
save_config_func,
|
||||
user_info: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Откатить конфигурацию к записи из истории.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
history_id: ID записи истории
|
||||
get_config_func: Функция для получения текущей конфигурации
|
||||
save_config_func: Функция для сохранения конфигурации
|
||||
user_info: Информация о пользователе
|
||||
"""
|
||||
entry = await cls.get_history_entry(db, history_id)
|
||||
if not entry:
|
||||
raise KeyError(f"History entry {history_id} not found")
|
||||
|
||||
config = entry["config"]
|
||||
|
||||
# Сохраняем текущую конфигурацию в историю перед откатом
|
||||
current_config = await get_config_func(db)
|
||||
await cls.save_history(
|
||||
db, current_config, "rollback_backup",
|
||||
f"Backup before rollback to history #{history_id}",
|
||||
user_info
|
||||
)
|
||||
|
||||
# Применяем конфигурацию из истории
|
||||
await save_config_func(db, config)
|
||||
|
||||
# Сохраняем запись об откате
|
||||
await cls.save_history(
|
||||
db, config, "rollback",
|
||||
f"Rollback to history #{history_id}",
|
||||
user_info
|
||||
)
|
||||
|
||||
return config
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,500 @@
|
||||
"""Сервис статистики кликов по кнопкам меню."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select, func, and_, desc, case, Integer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import ButtonClickLog
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
"""Возвращает текущее UTC время как naive datetime (без timezone).
|
||||
|
||||
Замена deprecated datetime.utcnow().
|
||||
"""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class MenuLayoutStatsService:
|
||||
"""Сервис для сбора и анализа статистики кликов по кнопкам."""
|
||||
|
||||
@classmethod
|
||||
def _is_sqlite(cls) -> bool:
|
||||
"""Проверить, используется ли SQLite."""
|
||||
return settings.is_sqlite()
|
||||
|
||||
@classmethod
|
||||
def _get_hour_expr(cls, column):
|
||||
"""Получить выражение для извлечения часа (совместимо с SQLite и PostgreSQL)."""
|
||||
if cls._is_sqlite():
|
||||
# SQLite: strftime('%H', column) возвращает строку
|
||||
return func.cast(func.strftime('%H', column), Integer)
|
||||
else:
|
||||
# PostgreSQL: EXTRACT(hour FROM column)
|
||||
return func.extract('hour', column)
|
||||
|
||||
@classmethod
|
||||
def _get_weekday_expr(cls, column):
|
||||
"""Получить выражение для дня недели (0=Пн, 6=Вс) совместимо с SQLite и PostgreSQL."""
|
||||
if cls._is_sqlite():
|
||||
# SQLite: strftime('%w', column) возвращает 0=воскресенье, 1-6=пн-сб
|
||||
# Преобразуем: 0->6, 1->0, 2->1, ..., 6->5
|
||||
dow = func.cast(func.strftime('%w', column), Integer)
|
||||
return case(
|
||||
(dow == 0, 6),
|
||||
else_=dow - 1
|
||||
)
|
||||
else:
|
||||
# PostgreSQL: EXTRACT(dow FROM column) возвращает 0=воскресенье, 1-6=пн-сб
|
||||
dow = func.extract('dow', column)
|
||||
return case(
|
||||
(dow == 0, 6),
|
||||
else_=dow - 1
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def log_button_click(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: str,
|
||||
user_id: Optional[int] = None,
|
||||
callback_data: Optional[str] = None,
|
||||
button_type: Optional[str] = None,
|
||||
button_text: Optional[str] = None,
|
||||
) -> ButtonClickLog:
|
||||
"""Записать клик по кнопке."""
|
||||
click_log = ButtonClickLog(
|
||||
button_id=button_id,
|
||||
user_id=user_id,
|
||||
callback_data=callback_data,
|
||||
button_type=button_type,
|
||||
button_text=button_text,
|
||||
)
|
||||
db.add(click_log)
|
||||
await db.commit()
|
||||
return click_log
|
||||
|
||||
@classmethod
|
||||
async def get_button_stats(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: str,
|
||||
days: int = 30,
|
||||
) -> Dict[str, Any]:
|
||||
"""Получить статистику кликов по конкретной кнопке."""
|
||||
now = _utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=days)
|
||||
|
||||
# Общее количество кликов
|
||||
total_result = await db.execute(
|
||||
select(func.count(ButtonClickLog.id))
|
||||
.where(ButtonClickLog.button_id == button_id)
|
||||
)
|
||||
clicks_total = total_result.scalar() or 0
|
||||
|
||||
# Клики сегодня
|
||||
today_result = await db.execute(
|
||||
select(func.count(ButtonClickLog.id))
|
||||
.where(and_(
|
||||
ButtonClickLog.button_id == button_id,
|
||||
ButtonClickLog.clicked_at >= today_start
|
||||
))
|
||||
)
|
||||
clicks_today = today_result.scalar() or 0
|
||||
|
||||
# Клики за неделю
|
||||
week_result = await db.execute(
|
||||
select(func.count(ButtonClickLog.id))
|
||||
.where(and_(
|
||||
ButtonClickLog.button_id == button_id,
|
||||
ButtonClickLog.clicked_at >= week_ago
|
||||
))
|
||||
)
|
||||
clicks_week = week_result.scalar() or 0
|
||||
|
||||
# Клики за месяц
|
||||
month_result = await db.execute(
|
||||
select(func.count(ButtonClickLog.id))
|
||||
.where(and_(
|
||||
ButtonClickLog.button_id == button_id,
|
||||
ButtonClickLog.clicked_at >= month_ago
|
||||
))
|
||||
)
|
||||
clicks_month = month_result.scalar() or 0
|
||||
|
||||
# Уникальные пользователи
|
||||
unique_result = await db.execute(
|
||||
select(func.count(func.distinct(ButtonClickLog.user_id)))
|
||||
.where(ButtonClickLog.button_id == button_id)
|
||||
)
|
||||
unique_users = unique_result.scalar() or 0
|
||||
|
||||
# Последний клик
|
||||
last_click_result = await db.execute(
|
||||
select(ButtonClickLog.clicked_at)
|
||||
.where(ButtonClickLog.button_id == button_id)
|
||||
.order_by(desc(ButtonClickLog.clicked_at))
|
||||
.limit(1)
|
||||
)
|
||||
last_click = last_click_result.scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"button_id": button_id,
|
||||
"clicks_total": clicks_total,
|
||||
"clicks_today": clicks_today,
|
||||
"clicks_week": clicks_week,
|
||||
"clicks_month": clicks_month,
|
||||
"unique_users": unique_users,
|
||||
"last_click_at": last_click,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def get_button_clicks_by_day(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: str,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику кликов по дням."""
|
||||
start_date = _utcnow() - timedelta(days=days)
|
||||
|
||||
# Группировка по дате
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.date(ButtonClickLog.clicked_at).label("date"),
|
||||
func.count(ButtonClickLog.id).label("count")
|
||||
)
|
||||
.where(and_(
|
||||
ButtonClickLog.button_id == button_id,
|
||||
ButtonClickLog.clicked_at >= start_date
|
||||
))
|
||||
.group_by(func.date(ButtonClickLog.clicked_at))
|
||||
.order_by(func.date(ButtonClickLog.clicked_at))
|
||||
)
|
||||
|
||||
return [
|
||||
{"date": str(row.date), "count": row.count}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def get_all_buttons_stats(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику по всем кнопкам."""
|
||||
now = _utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=days)
|
||||
|
||||
# Для производительности используем один запрос с подзапросами через CASE
|
||||
result = await db.execute(
|
||||
select(
|
||||
ButtonClickLog.button_id,
|
||||
# Общее количество кликов (все клики без фильтра по датам)
|
||||
func.count(ButtonClickLog.id).label("clicks_total"),
|
||||
# Уникальные пользователи (все время)
|
||||
func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"),
|
||||
# Последний клик (все время)
|
||||
func.max(ButtonClickLog.clicked_at).label("last_click_at"),
|
||||
# Подсчет кликов за сегодня
|
||||
func.sum(
|
||||
case((ButtonClickLog.clicked_at >= today_start, 1), else_=0)
|
||||
).label("clicks_today"),
|
||||
# Подсчет кликов за неделю
|
||||
func.sum(
|
||||
case((ButtonClickLog.clicked_at >= week_ago, 1), else_=0)
|
||||
).label("clicks_week"),
|
||||
# Подсчет кликов за месяц
|
||||
func.sum(
|
||||
case((ButtonClickLog.clicked_at >= month_ago, 1), else_=0)
|
||||
).label("clicks_month"),
|
||||
)
|
||||
.group_by(ButtonClickLog.button_id)
|
||||
.order_by(desc(func.count(ButtonClickLog.id)))
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"button_id": row.button_id,
|
||||
"clicks_total": row.clicks_total,
|
||||
"clicks_today": row.clicks_today or 0,
|
||||
"clicks_week": row.clicks_week or 0,
|
||||
"clicks_month": row.clicks_month or 0,
|
||||
"unique_users": row.unique_users,
|
||||
"last_click_at": row.last_click_at,
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def get_total_clicks(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
days: int = 30,
|
||||
) -> int:
|
||||
"""Получить общее количество кликов за период."""
|
||||
start_date = _utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
select(func.count(ButtonClickLog.id))
|
||||
.where(ButtonClickLog.clicked_at >= start_date)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
@classmethod
|
||||
async def get_stats_by_button_type(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику кликов по типам кнопок."""
|
||||
start_date = _utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
ButtonClickLog.button_type,
|
||||
func.count(ButtonClickLog.id).label("clicks_total"),
|
||||
func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"),
|
||||
)
|
||||
.where(and_(
|
||||
ButtonClickLog.clicked_at >= start_date,
|
||||
ButtonClickLog.button_type.isnot(None)
|
||||
))
|
||||
.group_by(ButtonClickLog.button_type)
|
||||
.order_by(desc(func.count(ButtonClickLog.id)))
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"button_type": row.button_type or "unknown",
|
||||
"clicks_total": row.clicks_total,
|
||||
"unique_users": row.unique_users,
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def get_clicks_by_hour(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: Optional[str] = None,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику кликов по часам дня."""
|
||||
start_date = _utcnow() - timedelta(days=days)
|
||||
|
||||
# Используем helper-метод для совместимости с SQLite и PostgreSQL
|
||||
hour_expr = cls._get_hour_expr(ButtonClickLog.clicked_at).label("hour")
|
||||
|
||||
query = select(
|
||||
hour_expr,
|
||||
func.count(ButtonClickLog.id).label("count")
|
||||
).where(ButtonClickLog.clicked_at >= start_date)
|
||||
|
||||
if button_id:
|
||||
query = query.where(ButtonClickLog.button_id == button_id)
|
||||
|
||||
result = await db.execute(
|
||||
query
|
||||
.group_by(hour_expr)
|
||||
.order_by(hour_expr)
|
||||
)
|
||||
|
||||
# Создаем словарь для быстрого доступа по часу
|
||||
stats_dict = {
|
||||
int(row.hour): row.count
|
||||
for row in result.all()
|
||||
}
|
||||
|
||||
# Возвращаем все 24 часа, даже если count = 0
|
||||
return [
|
||||
{"hour": hour, "count": stats_dict.get(hour, 0)}
|
||||
for hour in range(24)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def get_clicks_by_weekday(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: Optional[str] = None,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику кликов по дням недели.
|
||||
|
||||
Возвращает 0=понедельник, 6=воскресенье.
|
||||
Поддерживает как PostgreSQL, так и SQLite.
|
||||
"""
|
||||
start_date = _utcnow() - timedelta(days=days)
|
||||
|
||||
# Используем helper-метод для совместимости с SQLite и PostgreSQL
|
||||
weekday_expr = cls._get_weekday_expr(ButtonClickLog.clicked_at).label("weekday")
|
||||
|
||||
query = select(
|
||||
weekday_expr,
|
||||
func.count(ButtonClickLog.id).label("count")
|
||||
).where(ButtonClickLog.clicked_at >= start_date)
|
||||
|
||||
if button_id:
|
||||
query = query.where(ButtonClickLog.button_id == button_id)
|
||||
|
||||
result = await db.execute(
|
||||
query
|
||||
.group_by(weekday_expr)
|
||||
.order_by(weekday_expr)
|
||||
)
|
||||
|
||||
weekday_names = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"]
|
||||
|
||||
# Создаем словарь для быстрого доступа по weekday
|
||||
stats_dict = {
|
||||
int(row.weekday): row.count
|
||||
for row in result.all()
|
||||
}
|
||||
|
||||
# Возвращаем все дни недели, даже если count = 0
|
||||
return [
|
||||
{
|
||||
"weekday": weekday,
|
||||
"weekday_name": weekday_names[weekday],
|
||||
"count": stats_dict.get(weekday, 0)
|
||||
}
|
||||
for weekday in range(7)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def get_top_users(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить топ пользователей по количеству кликов."""
|
||||
start_date = _utcnow() - timedelta(days=days)
|
||||
|
||||
query = select(
|
||||
ButtonClickLog.user_id,
|
||||
func.count(ButtonClickLog.id).label("clicks_count"),
|
||||
func.max(ButtonClickLog.clicked_at).label("last_click_at")
|
||||
).where(and_(
|
||||
ButtonClickLog.clicked_at >= start_date,
|
||||
ButtonClickLog.user_id.isnot(None)
|
||||
))
|
||||
|
||||
if button_id:
|
||||
query = query.where(ButtonClickLog.button_id == button_id)
|
||||
|
||||
result = await db.execute(
|
||||
query
|
||||
.group_by(ButtonClickLog.user_id)
|
||||
.order_by(desc(func.count(ButtonClickLog.id)))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"user_id": row.user_id,
|
||||
"clicks_count": row.clicks_count,
|
||||
"last_click_at": row.last_click_at,
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def get_period_comparison(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: Optional[str] = None,
|
||||
current_days: int = 7,
|
||||
previous_days: int = 7,
|
||||
) -> Dict[str, Any]:
|
||||
"""Сравнить статистику текущего и предыдущего периода."""
|
||||
now = _utcnow()
|
||||
current_start = now - timedelta(days=current_days)
|
||||
previous_start = current_start - timedelta(days=previous_days)
|
||||
previous_end = current_start
|
||||
|
||||
query_current = select(func.count(ButtonClickLog.id))
|
||||
query_previous = select(func.count(ButtonClickLog.id))
|
||||
|
||||
if button_id:
|
||||
query_current = query_current.where(ButtonClickLog.button_id == button_id)
|
||||
query_previous = query_previous.where(ButtonClickLog.button_id == button_id)
|
||||
|
||||
query_current = query_current.where(
|
||||
ButtonClickLog.clicked_at >= current_start
|
||||
)
|
||||
query_previous = query_previous.where(
|
||||
and_(
|
||||
ButtonClickLog.clicked_at >= previous_start,
|
||||
ButtonClickLog.clicked_at < previous_end
|
||||
)
|
||||
)
|
||||
|
||||
current_result = await db.execute(query_current)
|
||||
previous_result = await db.execute(query_previous)
|
||||
|
||||
current_count = current_result.scalar() or 0
|
||||
previous_count = previous_result.scalar() or 0
|
||||
|
||||
change_percent = 0
|
||||
if previous_count > 0:
|
||||
change_percent = ((current_count - previous_count) / previous_count) * 100
|
||||
|
||||
return {
|
||||
"current_period": {
|
||||
"clicks": current_count,
|
||||
"days": current_days,
|
||||
"start": current_start,
|
||||
"end": now,
|
||||
},
|
||||
"previous_period": {
|
||||
"clicks": previous_count,
|
||||
"days": previous_days,
|
||||
"start": previous_start,
|
||||
"end": previous_end,
|
||||
},
|
||||
"change": {
|
||||
"absolute": current_count - previous_count,
|
||||
"percent": round(change_percent, 2),
|
||||
"trend": "up" if change_percent > 0 else "down" if change_percent < 0 else "stable",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def get_click_sequences(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить последовательности кликов пользователя."""
|
||||
result = await db.execute(
|
||||
select(
|
||||
ButtonClickLog.button_id,
|
||||
ButtonClickLog.button_text,
|
||||
ButtonClickLog.clicked_at,
|
||||
)
|
||||
.where(ButtonClickLog.user_id == user_id)
|
||||
.order_by(desc(ButtonClickLog.clicked_at))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"button_id": row.button_id,
|
||||
"button_text": row.button_text,
|
||||
"clicked_at": row.clicked_at,
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Сервис конструктора меню - управление конфигурацией через API.
|
||||
|
||||
ВНИМАНИЕ: Этот файл оставлен для обратной совместимости.
|
||||
Фактическая реализация находится в app/services/menu_layout/
|
||||
|
||||
Структура модуля:
|
||||
- app/services/menu_layout/constants.py - константы
|
||||
- app/services/menu_layout/context.py - MenuContext
|
||||
- app/services/menu_layout/history_service.py - история изменений
|
||||
- app/services/menu_layout/stats_service.py - статистика кликов
|
||||
- app/services/menu_layout/service.py - основной сервис
|
||||
"""
|
||||
|
||||
# Реэкспорт для обратной совместимости
|
||||
from app.services.menu_layout import (
|
||||
# Константы
|
||||
MENU_LAYOUT_CONFIG_KEY,
|
||||
DEFAULT_MENU_CONFIG,
|
||||
BUILTIN_BUTTONS_INFO,
|
||||
AVAILABLE_CALLBACKS,
|
||||
DYNAMIC_PLACEHOLDERS,
|
||||
# Классы
|
||||
MenuContext,
|
||||
MenuLayoutService,
|
||||
MenuLayoutHistoryService,
|
||||
MenuLayoutStatsService,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MENU_LAYOUT_CONFIG_KEY",
|
||||
"DEFAULT_MENU_CONFIG",
|
||||
"BUILTIN_BUTTONS_INFO",
|
||||
"AVAILABLE_CALLBACKS",
|
||||
"DYNAMIC_PLACEHOLDERS",
|
||||
"MenuContext",
|
||||
"MenuLayoutService",
|
||||
"MenuLayoutHistoryService",
|
||||
"MenuLayoutStatsService",
|
||||
]
|
||||
@@ -214,7 +214,8 @@ class MonitoringService:
|
||||
await self._check_trial_inactivity_notifications(db)
|
||||
await self._check_trial_channel_subscriptions(db)
|
||||
await self._check_expired_subscription_followups(db)
|
||||
await self._process_autopayments(db)
|
||||
if settings.ENABLE_AUTOPAY:
|
||||
await self._process_autopayments(db)
|
||||
await self._cleanup_inactive_users(db)
|
||||
await self._sync_with_remnawave(db)
|
||||
|
||||
@@ -507,6 +508,12 @@ class MonitoringService:
|
||||
if not settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
return
|
||||
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
|
||||
logger.debug(
|
||||
"ℹ️ Проверка отписок от канала отключена — деактивация триальных подписок не требуется"
|
||||
)
|
||||
return
|
||||
|
||||
channel_id = settings.CHANNEL_SUB_ID
|
||||
if not channel_id:
|
||||
return
|
||||
@@ -931,7 +938,7 @@ class MonitoringService:
|
||||
autopay_subscriptions = []
|
||||
for sub in all_autopay_subscriptions:
|
||||
days_before_expiry = (sub.end_date - current_time).days
|
||||
if days_before_expiry <= sub.autopay_days_before:
|
||||
if days_before_expiry <= min(sub.autopay_days_before, 3):
|
||||
autopay_subscriptions.append(sub)
|
||||
|
||||
processed_count = 0
|
||||
@@ -1059,12 +1066,16 @@ class MonitoringService:
|
||||
texts = get_texts(user.language)
|
||||
days_text = format_days_declension(days, user.language)
|
||||
|
||||
if subscription.autopay_enabled:
|
||||
autopay_status = "✅ Включен - подписка продлится автоматически"
|
||||
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
|
||||
if settings.ENABLE_AUTOPAY:
|
||||
if subscription.autopay_enabled:
|
||||
autopay_status = "✅ Включен - подписка продлится автоматически"
|
||||
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
|
||||
else:
|
||||
autopay_status = "❌ Отключен - не забудьте продлить вручную!"
|
||||
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
|
||||
else:
|
||||
autopay_status = "❌ Отключен - не забудьте продлить вручную!"
|
||||
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
|
||||
action_text = "💡 Продлите подписку вручную"
|
||||
|
||||
message = f"""
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from decimal import Decimal
|
||||
|
||||
from nalogo import Client
|
||||
from nalogo.dto.income import IncomeClient, IncomeType
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NaloGoService:
|
||||
"""Сервис для работы с API NaloGO (налоговая служба самозанятых)."""
|
||||
|
||||
def __init__(self,
|
||||
inn: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
device_id: Optional[str] = None,
|
||||
storage_path: Optional[str] = None):
|
||||
|
||||
inn = inn or getattr(settings, 'NALOGO_INN', None)
|
||||
password = password or getattr(settings, 'NALOGO_PASSWORD', None)
|
||||
device_id = device_id or getattr(settings, 'NALOGO_DEVICE_ID', None)
|
||||
storage_path = storage_path or getattr(settings, 'NALOGO_STORAGE_PATH', './nalogo_tokens.json')
|
||||
|
||||
self.configured = False
|
||||
|
||||
if not inn or not password:
|
||||
logger.warning(
|
||||
"NaloGO INN или PASSWORD не настроены в settings. "
|
||||
"Функционал чеков будет ОТКЛЮЧЕН.")
|
||||
else:
|
||||
try:
|
||||
self.client = Client(
|
||||
base_url="https://lknpd.nalog.ru/api",
|
||||
storage_path=storage_path,
|
||||
device_id=device_id or "bot-device-123"
|
||||
)
|
||||
self.inn = inn
|
||||
self.password = password
|
||||
self.configured = True
|
||||
logger.info(f"NaloGO клиент инициализирован для ИНН: {inn[:5]}...")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка инициализации NaloGO клиента: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
self.configured = False
|
||||
|
||||
async def authenticate(self) -> bool:
|
||||
"""Аутентификация в сервисе NaloGO."""
|
||||
if not self.configured:
|
||||
return False
|
||||
|
||||
try:
|
||||
token = await self.client.create_new_access_token(self.inn, self.password)
|
||||
await self.client.authenticate(token)
|
||||
logger.info("Успешная аутентификация в NaloGO")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error("Ошибка аутентификации в NaloGO: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
async def create_receipt(self, name: str, amount: float, quantity: int = 1, client_info: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
"""Создание чека о доходе.
|
||||
|
||||
Args:
|
||||
name: Название услуги
|
||||
amount: Сумма в рублях
|
||||
quantity: Количество
|
||||
client_info: Информация о клиенте (опционально)
|
||||
|
||||
Returns:
|
||||
UUID чека или None при ошибке
|
||||
"""
|
||||
if not self.configured:
|
||||
logger.warning("NaloGO не настроен, чек не создан")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Аутентифицируемся, если нужно
|
||||
if not hasattr(self.client, '_access_token') or not self.client._access_token:
|
||||
auth_success = await self.authenticate()
|
||||
if not auth_success:
|
||||
return None
|
||||
|
||||
income_api = self.client.income()
|
||||
|
||||
# Создаем клиента, если передана информация
|
||||
income_client = None
|
||||
if client_info:
|
||||
income_client = IncomeClient(
|
||||
contact_phone=client_info.get("phone"),
|
||||
display_name=client_info.get("name"),
|
||||
income_type=client_info.get("income_type", IncomeType.FROM_INDIVIDUAL),
|
||||
inn=client_info.get("inn")
|
||||
)
|
||||
|
||||
result = await income_api.create(
|
||||
name=name,
|
||||
amount=Decimal(str(amount)),
|
||||
quantity=quantity,
|
||||
client=income_client
|
||||
)
|
||||
|
||||
receipt_uuid = result.get("approvedReceiptUuid")
|
||||
if receipt_uuid:
|
||||
logger.info(f"Чек создан успешно: {receipt_uuid} на сумму {amount}₽")
|
||||
return receipt_uuid
|
||||
else:
|
||||
logger.error(f"Ошибка создания чека: {result}")
|
||||
return None
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
|
||||
return None
|
||||
@@ -0,0 +1,625 @@
|
||||
"""Сервис расширенной статистики партнёров (рефереров)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import and_, case, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PartnerStatsService:
|
||||
"""Сервис для детальной статистики партнёров."""
|
||||
|
||||
@classmethod
|
||||
async def get_referrer_detailed_stats(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Получить детальную статистику реферера."""
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
year_ago = now - timedelta(days=365)
|
||||
|
||||
# Базовые данные о рефералах
|
||||
referrals_query = select(User).where(User.referred_by_id == user_id)
|
||||
referrals_result = await db.execute(referrals_query)
|
||||
referrals = referrals_result.scalars().all()
|
||||
referral_ids = [r.id for r in referrals]
|
||||
|
||||
total_referrals = len(referrals)
|
||||
|
||||
# Сколько сделали первое пополнение (has_made_first_topup)
|
||||
paid_referrals = sum(1 for r in referrals if r.has_made_first_topup)
|
||||
|
||||
# Активные рефералы (с активной подпиской)
|
||||
if referral_ids:
|
||||
active_result = await db.execute(
|
||||
select(func.count(func.distinct(User.id)))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
and_(
|
||||
User.id.in_(referral_ids),
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
)
|
||||
)
|
||||
active_referrals = active_result.scalar() or 0
|
||||
else:
|
||||
active_referrals = 0
|
||||
|
||||
# Заработки по периодам - один запрос с CASE WHEN
|
||||
earnings_result = await db.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("all_time"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= today_start, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("today"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= week_ago, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("week"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= month_ago, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("month"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= year_ago, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("year"),
|
||||
).where(ReferralEarning.user_id == user_id)
|
||||
)
|
||||
earnings_row = earnings_result.one()
|
||||
earnings_all_time = int(earnings_row.all_time)
|
||||
earnings_today = int(earnings_row.today)
|
||||
earnings_week = int(earnings_row.week)
|
||||
earnings_month = int(earnings_row.month)
|
||||
earnings_year = int(earnings_row.year)
|
||||
|
||||
# Рефералы по периодам
|
||||
referrals_today = sum(1 for r in referrals if r.created_at >= today_start)
|
||||
referrals_week = sum(1 for r in referrals if r.created_at >= week_ago)
|
||||
referrals_month = sum(1 for r in referrals if r.created_at >= month_ago)
|
||||
referrals_year = sum(1 for r in referrals if r.created_at >= year_ago)
|
||||
|
||||
# Конверсии
|
||||
conversion_to_paid = round((paid_referrals / total_referrals * 100), 2) if total_referrals > 0 else 0
|
||||
conversion_to_active = round((active_referrals / total_referrals * 100), 2) if total_referrals > 0 else 0
|
||||
|
||||
# Средний доход с реферала
|
||||
avg_earnings_per_referral = round(earnings_all_time / paid_referrals, 2) if paid_referrals > 0 else 0
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"summary": {
|
||||
"total_referrals": total_referrals,
|
||||
"paid_referrals": paid_referrals,
|
||||
"active_referrals": active_referrals,
|
||||
"conversion_to_paid_percent": conversion_to_paid,
|
||||
"conversion_to_active_percent": conversion_to_active,
|
||||
"avg_earnings_per_referral_kopeks": avg_earnings_per_referral,
|
||||
},
|
||||
"earnings": {
|
||||
"all_time_kopeks": earnings_all_time,
|
||||
"year_kopeks": earnings_year,
|
||||
"month_kopeks": earnings_month,
|
||||
"week_kopeks": earnings_week,
|
||||
"today_kopeks": earnings_today,
|
||||
},
|
||||
"referrals_count": {
|
||||
"all_time": total_referrals,
|
||||
"year": referrals_year,
|
||||
"month": referrals_month,
|
||||
"week": referrals_week,
|
||||
"today": referrals_today,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def get_referrer_daily_stats(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику реферера по дням."""
|
||||
now = datetime.utcnow()
|
||||
start_date = now - timedelta(days=days)
|
||||
|
||||
# Рефералы по дням
|
||||
referrals_by_day = await db.execute(
|
||||
select(
|
||||
func.date(User.created_at).label("date"),
|
||||
func.count(User.id).label("referrals_count"),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id == user_id,
|
||||
User.created_at >= start_date,
|
||||
)
|
||||
)
|
||||
.group_by(func.date(User.created_at))
|
||||
.order_by(func.date(User.created_at))
|
||||
)
|
||||
referrals_dict = {str(row.date): row.referrals_count for row in referrals_by_day.all()}
|
||||
|
||||
# Заработки по дням (из ReferralEarning)
|
||||
earnings_by_day = await db.execute(
|
||||
select(
|
||||
func.date(ReferralEarning.created_at).label("date"),
|
||||
func.sum(ReferralEarning.amount_kopeks).label("earnings"),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
ReferralEarning.user_id == user_id,
|
||||
ReferralEarning.created_at >= start_date,
|
||||
)
|
||||
)
|
||||
.group_by(func.date(ReferralEarning.created_at))
|
||||
)
|
||||
earnings_dict = {str(row.date): int(row.earnings or 0) for row in earnings_by_day.all()}
|
||||
|
||||
# Формируем массив за все дни
|
||||
result = []
|
||||
for i in range(days):
|
||||
date = (start_date + timedelta(days=i)).date()
|
||||
date_str = str(date)
|
||||
result.append({
|
||||
"date": date_str,
|
||||
"referrals_count": referrals_dict.get(date_str, 0),
|
||||
"earnings_kopeks": earnings_dict.get(date_str, 0),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def get_referrer_top_referrals(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить топ рефералов по доходу для реферера."""
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Получаем рефералов с их доходами
|
||||
result = await db.execute(
|
||||
select(
|
||||
User.id,
|
||||
User.telegram_id,
|
||||
User.username,
|
||||
User.first_name,
|
||||
User.last_name,
|
||||
User.created_at,
|
||||
User.has_made_first_topup,
|
||||
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("total_earnings"),
|
||||
)
|
||||
.outerjoin(ReferralEarning, ReferralEarning.referral_id == User.id)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.group_by(User.id)
|
||||
.order_by(desc(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
|
||||
.limit(limit)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# Собираем user_id для проверки активных подписок одним запросом
|
||||
user_ids = [row.id for row in rows]
|
||||
|
||||
# Получаем все активные подписки для этих пользователей одним запросом
|
||||
active_subs_result = await db.execute(
|
||||
select(Subscription.user_id)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.user_id.in_(user_ids),
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
)
|
||||
)
|
||||
active_user_ids = {row.user_id for row in active_subs_result.all()}
|
||||
|
||||
referrals = []
|
||||
for row in rows:
|
||||
referrals.append({
|
||||
"id": row.id,
|
||||
"telegram_id": row.telegram_id,
|
||||
"username": row.username,
|
||||
"first_name": row.first_name,
|
||||
"last_name": row.last_name,
|
||||
"full_name": f"{row.first_name or ''} {row.last_name or ''}".strip() or f"User {row.telegram_id}",
|
||||
"created_at": row.created_at,
|
||||
"has_made_first_topup": row.has_made_first_topup,
|
||||
"is_active": row.id in active_user_ids,
|
||||
"total_earnings_kopeks": int(row.total_earnings),
|
||||
})
|
||||
|
||||
return referrals
|
||||
|
||||
@classmethod
|
||||
async def get_referrer_period_comparison(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
current_days: int = 7,
|
||||
previous_days: int = 7,
|
||||
) -> Dict[str, Any]:
|
||||
"""Сравнить текущий и предыдущий период."""
|
||||
now = datetime.utcnow()
|
||||
current_start = now - timedelta(days=current_days)
|
||||
previous_start = current_start - timedelta(days=previous_days)
|
||||
previous_end = current_start
|
||||
|
||||
# Рефералы за текущий период
|
||||
current_referrals = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id == user_id,
|
||||
User.created_at >= current_start,
|
||||
)
|
||||
)
|
||||
)
|
||||
current_referrals_count = current_referrals.scalar() or 0
|
||||
|
||||
# Рефералы за предыдущий период
|
||||
previous_referrals = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id == user_id,
|
||||
User.created_at >= previous_start,
|
||||
User.created_at < previous_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
previous_referrals_count = previous_referrals.scalar() or 0
|
||||
|
||||
# Заработки за текущий период
|
||||
current_earnings = await cls._get_earnings_for_period(db, user_id, current_start)
|
||||
|
||||
# Заработки за предыдущий период
|
||||
previous_earnings = await cls._get_earnings_for_period(
|
||||
db, user_id, previous_start, previous_end
|
||||
)
|
||||
|
||||
# Расчёт изменений
|
||||
referrals_change = current_referrals_count - previous_referrals_count
|
||||
referrals_change_percent = (
|
||||
round((referrals_change / previous_referrals_count * 100), 2)
|
||||
if previous_referrals_count > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
earnings_change = current_earnings - previous_earnings
|
||||
earnings_change_percent = (
|
||||
round((earnings_change / previous_earnings * 100), 2)
|
||||
if previous_earnings > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
return {
|
||||
"current_period": {
|
||||
"days": current_days,
|
||||
"start": current_start.isoformat(),
|
||||
"end": now.isoformat(),
|
||||
"referrals_count": current_referrals_count,
|
||||
"earnings_kopeks": current_earnings,
|
||||
},
|
||||
"previous_period": {
|
||||
"days": previous_days,
|
||||
"start": previous_start.isoformat(),
|
||||
"end": previous_end.isoformat(),
|
||||
"referrals_count": previous_referrals_count,
|
||||
"earnings_kopeks": previous_earnings,
|
||||
},
|
||||
"change": {
|
||||
"referrals": {
|
||||
"absolute": referrals_change,
|
||||
"percent": referrals_change_percent,
|
||||
"trend": "up" if referrals_change > 0 else "down" if referrals_change < 0 else "stable",
|
||||
},
|
||||
"earnings": {
|
||||
"absolute": earnings_change,
|
||||
"percent": earnings_change_percent,
|
||||
"trend": "up" if earnings_change > 0 else "down" if earnings_change < 0 else "stable",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def get_global_partner_stats(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
days: int = 30,
|
||||
) -> Dict[str, Any]:
|
||||
"""Глобальная статистика партнёрской программы."""
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
year_ago = now - timedelta(days=365)
|
||||
start_date = now - timedelta(days=days)
|
||||
|
||||
# Всего рефереров (у кого есть рефералы)
|
||||
total_referrers = await db.execute(
|
||||
select(func.count(func.distinct(User.referred_by_id)))
|
||||
.where(User.referred_by_id.isnot(None))
|
||||
)
|
||||
total_referrers_count = total_referrers.scalar() or 0
|
||||
|
||||
# Всего рефералов
|
||||
total_referrals = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(User.referred_by_id.isnot(None))
|
||||
)
|
||||
total_referrals_count = total_referrals.scalar() or 0
|
||||
|
||||
# Рефералы которые заплатили
|
||||
paid_referrals = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id.isnot(None),
|
||||
User.has_made_first_topup.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
paid_referrals_count = paid_referrals.scalar() or 0
|
||||
|
||||
# Всего выплачено - один запрос с CASE WHEN
|
||||
payouts_result = await db.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("all_time"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= today_start, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("today"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= week_ago, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("week"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= month_ago, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("month"),
|
||||
func.coalesce(func.sum(
|
||||
case((ReferralEarning.created_at >= year_ago, ReferralEarning.amount_kopeks), else_=0)
|
||||
), 0).label("year"),
|
||||
)
|
||||
)
|
||||
payouts_row = payouts_result.one()
|
||||
total_paid = int(payouts_row.all_time)
|
||||
today_paid = int(payouts_row.today)
|
||||
week_paid = int(payouts_row.week)
|
||||
month_paid = int(payouts_row.month)
|
||||
year_paid = int(payouts_row.year)
|
||||
|
||||
# Новые рефералы по периодам - один запрос с CASE WHEN
|
||||
new_referrals_result = await db.execute(
|
||||
select(
|
||||
func.sum(case((User.created_at >= today_start, 1), else_=0)).label("today"),
|
||||
func.sum(case((User.created_at >= week_ago, 1), else_=0)).label("week"),
|
||||
func.sum(case((User.created_at >= month_ago, 1), else_=0)).label("month"),
|
||||
).where(User.referred_by_id.isnot(None))
|
||||
)
|
||||
new_referrals_row = new_referrals_result.one()
|
||||
new_referrals_today_count = int(new_referrals_row.today or 0)
|
||||
new_referrals_week_count = int(new_referrals_row.week or 0)
|
||||
new_referrals_month_count = int(new_referrals_row.month or 0)
|
||||
|
||||
# Конверсия
|
||||
conversion_rate = (
|
||||
round((paid_referrals_count / total_referrals_count * 100), 2)
|
||||
if total_referrals_count > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
# Средний доход с реферала
|
||||
avg_per_referral = (
|
||||
round(total_paid / paid_referrals_count, 2)
|
||||
if paid_referrals_count > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
"total_referrers": total_referrers_count,
|
||||
"total_referrals": total_referrals_count,
|
||||
"paid_referrals": paid_referrals_count,
|
||||
"conversion_rate_percent": conversion_rate,
|
||||
"avg_earnings_per_referral_kopeks": avg_per_referral,
|
||||
},
|
||||
"payouts": {
|
||||
"all_time_kopeks": total_paid,
|
||||
"year_kopeks": year_paid,
|
||||
"month_kopeks": month_paid,
|
||||
"week_kopeks": week_paid,
|
||||
"today_kopeks": today_paid,
|
||||
},
|
||||
"new_referrals": {
|
||||
"today": new_referrals_today_count,
|
||||
"week": new_referrals_week_count,
|
||||
"month": new_referrals_month_count,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def get_global_daily_stats(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Глобальная статистика по дням."""
|
||||
now = datetime.utcnow()
|
||||
start_date = now - timedelta(days=days)
|
||||
|
||||
# Рефералы по дням
|
||||
referrals_by_day = await db.execute(
|
||||
select(
|
||||
func.date(User.created_at).label("date"),
|
||||
func.count(User.id).label("referrals_count"),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id.isnot(None),
|
||||
User.created_at >= start_date,
|
||||
)
|
||||
)
|
||||
.group_by(func.date(User.created_at))
|
||||
)
|
||||
referrals_dict = {str(row.date): row.referrals_count for row in referrals_by_day.all()}
|
||||
|
||||
# Выплаты по дням
|
||||
earnings_by_day = await db.execute(
|
||||
select(
|
||||
func.date(ReferralEarning.created_at).label("date"),
|
||||
func.sum(ReferralEarning.amount_kopeks).label("earnings"),
|
||||
)
|
||||
.where(ReferralEarning.created_at >= start_date)
|
||||
.group_by(func.date(ReferralEarning.created_at))
|
||||
)
|
||||
earnings_dict = {str(row.date): int(row.earnings or 0) for row in earnings_by_day.all()}
|
||||
|
||||
result = []
|
||||
for i in range(days):
|
||||
date = (start_date + timedelta(days=i)).date()
|
||||
date_str = str(date)
|
||||
result.append({
|
||||
"date": date_str,
|
||||
"referrals_count": referrals_dict.get(date_str, 0),
|
||||
"earnings_kopeks": earnings_dict.get(date_str, 0),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def get_top_referrers(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
limit: int = 10,
|
||||
days: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить топ рефереров."""
|
||||
now = datetime.utcnow()
|
||||
start_date = now - timedelta(days=days) if days else None
|
||||
|
||||
# Подсчёт рефералов и заработков
|
||||
earnings_query = (
|
||||
select(
|
||||
ReferralEarning.user_id,
|
||||
func.sum(ReferralEarning.amount_kopeks).label("total_earnings"),
|
||||
)
|
||||
.group_by(ReferralEarning.user_id)
|
||||
)
|
||||
if start_date:
|
||||
earnings_query = earnings_query.where(ReferralEarning.created_at >= start_date)
|
||||
|
||||
earnings_result = await db.execute(earnings_query)
|
||||
earnings_dict = {row.user_id: int(row.total_earnings or 0) for row in earnings_result.all()}
|
||||
|
||||
# Подсчёт рефералов
|
||||
referrals_query = (
|
||||
select(
|
||||
User.referred_by_id,
|
||||
func.count(User.id).label("referrals_count"),
|
||||
)
|
||||
.where(User.referred_by_id.isnot(None))
|
||||
.group_by(User.referred_by_id)
|
||||
)
|
||||
if start_date:
|
||||
referrals_query = referrals_query.where(User.created_at >= start_date)
|
||||
|
||||
referrals_result = await db.execute(referrals_query)
|
||||
referrals_dict = {row.referred_by_id: row.referrals_count for row in referrals_result.all()}
|
||||
|
||||
# Объединяем данные
|
||||
all_referrer_ids = set(earnings_dict.keys()) | set(referrals_dict.keys())
|
||||
referrers_data = []
|
||||
|
||||
for referrer_id in all_referrer_ids:
|
||||
referrers_data.append({
|
||||
"user_id": referrer_id,
|
||||
"referrals_count": referrals_dict.get(referrer_id, 0),
|
||||
"total_earnings": earnings_dict.get(referrer_id, 0),
|
||||
})
|
||||
|
||||
# Сортируем по заработку
|
||||
referrers_data.sort(key=lambda x: x["total_earnings"], reverse=True)
|
||||
top_referrers = referrers_data[:limit]
|
||||
|
||||
if not top_referrers:
|
||||
return []
|
||||
|
||||
# Получаем данные всех пользователей одним запросом
|
||||
top_user_ids = [data["user_id"] for data in top_referrers]
|
||||
users_result = await db.execute(
|
||||
select(User).where(User.id.in_(top_user_ids))
|
||||
)
|
||||
users_dict = {user.id: user for user in users_result.scalars().all()}
|
||||
|
||||
# Формируем результат с сохранением порядка сортировки
|
||||
result = []
|
||||
for data in top_referrers:
|
||||
user = users_dict.get(data["user_id"])
|
||||
if user:
|
||||
result.append({
|
||||
"id": user.id,
|
||||
"telegram_id": user.telegram_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"full_name": f"{user.first_name or ''} {user.last_name or ''}".strip() or f"User {user.telegram_id}",
|
||||
"referral_code": user.referral_code,
|
||||
"referrals_count": data["referrals_count"],
|
||||
"total_earnings_kopeks": data["total_earnings"],
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def _get_earnings_for_period(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
start_date: Optional[datetime],
|
||||
end_date: Optional[datetime] = None,
|
||||
) -> int:
|
||||
"""Получить заработки за период."""
|
||||
query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
|
||||
ReferralEarning.user_id == user_id
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.where(ReferralEarning.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.where(ReferralEarning.created_at < end_date)
|
||||
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@classmethod
|
||||
async def _get_total_earnings(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
start_date: Optional[datetime],
|
||||
) -> int:
|
||||
"""Получить общие выплаты за период."""
|
||||
query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
|
||||
if start_date:
|
||||
query = query.where(ReferralEarning.created_at >= start_date)
|
||||
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
@@ -952,6 +952,17 @@ class YooKassaPaymentMixin:
|
||||
payment.amount_kopeks / 100,
|
||||
)
|
||||
|
||||
# Создаем чек через NaloGO для всех платежей
|
||||
if hasattr(self, "nalogo_service") and self.nalogo_service:
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
if SupportSettingsService.is_nalogo_receipts_enabled():
|
||||
await self._create_nalogo_receipt(payment)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка проверки настройки NALOGO_RECEIPTS_ENABLED: %s", error)
|
||||
if settings.NALOGO_RECEIPTS_ENABLED:
|
||||
await self._create_nalogo_receipt(payment)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
@@ -1002,6 +1013,38 @@ class YooKassaPaymentMixin:
|
||||
|
||||
return updated_metadata
|
||||
|
||||
async def _create_nalogo_receipt(
|
||||
self,
|
||||
payment: "YooKassaPayment",
|
||||
) -> None:
|
||||
"""Создание чека через NaloGO для успешного платежа."""
|
||||
if not hasattr(self, "nalogo_service") or not self.nalogo_service:
|
||||
logger.debug("NaloGO сервис не инициализирован, чек не создан")
|
||||
return
|
||||
|
||||
try:
|
||||
amount_rubles = payment.amount_kopeks / 100
|
||||
receipt_name = "Интернет-сервис - Пополнение баланса"
|
||||
|
||||
receipt_uuid = await self.nalogo_service.create_receipt(
|
||||
name=receipt_name,
|
||||
amount=amount_rubles,
|
||||
quantity=1
|
||||
)
|
||||
|
||||
if receipt_uuid:
|
||||
logger.info(f"Чек NaloGO создан для платежа {payment.yookassa_payment_id}: {receipt_uuid}")
|
||||
else:
|
||||
logger.warning(f"Не удалось создать чек NaloGO для платежа {payment.yookassa_payment_id}")
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка создания чека NaloGO для платежа %s: %s",
|
||||
payment.yookassa_payment_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def process_yookassa_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.services.payment import (
|
||||
)
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
from app.services.wata_service import WataService
|
||||
from app.services.nalogo_service import NaloGoService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -300,6 +301,7 @@ class PaymentService(
|
||||
PlategaService() if settings.is_platega_enabled() else None
|
||||
)
|
||||
self.wata_service = WataService() if settings.is_wata_enabled() else None
|
||||
self.nalogo_service = NaloGoService() if settings.is_nalogo_enabled() else None
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.debug(
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, date, time, timedelta, timezone
|
||||
from typing import Optional, Sequence, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramNotFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.referral_contest import (
|
||||
add_contest_event,
|
||||
get_contest_events_count,
|
||||
get_contest_leaderboard,
|
||||
get_contests_for_events,
|
||||
get_contests_for_summaries,
|
||||
get_referrer_score,
|
||||
mark_daily_summary_sent,
|
||||
mark_final_summary_sent,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import ReferralContest, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReferralContestService:
|
||||
def __init__(self) -> None:
|
||||
self.bot: Optional[Bot] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._poll_interval_seconds = 60
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
self.bot = bot
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop()
|
||||
|
||||
if not settings.is_contests_enabled():
|
||||
logger.info("Сервис конкурсов отключен настройками")
|
||||
return
|
||||
|
||||
if not self.bot:
|
||||
logger.warning("Невозможно запустить сервис конкурсов без экземпляра бота")
|
||||
return
|
||||
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
logger.info("🏆 Сервис конкурсов запущен")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
await self._process_summaries()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Ошибка сервиса конкурсов: %s", exc)
|
||||
|
||||
await asyncio.sleep(self._poll_interval_seconds)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Сервис конкурсов остановлен")
|
||||
raise
|
||||
|
||||
async def _process_summaries(self) -> None:
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
contests = await get_contests_for_summaries(db)
|
||||
now_utc = datetime.utcnow()
|
||||
|
||||
for contest in contests:
|
||||
try:
|
||||
await self._maybe_send_daily_summary(db, contest, now_utc)
|
||||
await self._maybe_send_final_summary(db, contest, now_utc)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Ошибка обработки конкурса %s (%s): %s",
|
||||
contest.id,
|
||||
contest.title,
|
||||
exc,
|
||||
)
|
||||
|
||||
async def _maybe_send_daily_summary(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
now_utc: datetime,
|
||||
) -> None:
|
||||
tz = self._get_timezone(contest)
|
||||
now_local = now_utc.replace(tzinfo=timezone.utc).astimezone(tz)
|
||||
start_local = self._to_local(contest.start_at, tz)
|
||||
end_local = self._to_local(contest.end_at, tz)
|
||||
|
||||
if now_local.date() < start_local.date() or now_local.date() > end_local.date():
|
||||
return
|
||||
|
||||
summary_times = self._get_summary_times(contest)
|
||||
for summary_time in summary_times:
|
||||
summary_dt = datetime.combine(now_local.date(), summary_time, tzinfo=tz)
|
||||
summary_dt_utc = summary_dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if now_utc < summary_dt_utc:
|
||||
continue
|
||||
last_sent = contest.last_daily_summary_at
|
||||
if last_sent and last_sent >= summary_dt_utc:
|
||||
continue
|
||||
|
||||
await self._send_summary(
|
||||
db,
|
||||
contest,
|
||||
now_utc,
|
||||
now_local.date(),
|
||||
is_final=False,
|
||||
summary_dt_utc=summary_dt_utc,
|
||||
)
|
||||
|
||||
async def _maybe_send_final_summary(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
now_utc: datetime,
|
||||
) -> None:
|
||||
if contest.final_summary_sent:
|
||||
return
|
||||
|
||||
tz = self._get_timezone(contest)
|
||||
end_local = self._to_local(contest.end_at, tz)
|
||||
summary_times = self._get_summary_times(contest)
|
||||
summary_time = summary_times[-1] if summary_times else time(hour=12, minute=0)
|
||||
summary_dt = datetime.combine(end_local.date(), summary_time, tzinfo=tz)
|
||||
summary_dt_utc = summary_dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if now_utc < contest.end_at:
|
||||
return
|
||||
|
||||
if now_utc < summary_dt_utc:
|
||||
return
|
||||
|
||||
await self._send_summary(db, contest, now_utc, end_local.date(), is_final=True)
|
||||
|
||||
async def _send_summary(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
now_utc: datetime,
|
||||
target_date: date,
|
||||
*,
|
||||
is_final: bool,
|
||||
summary_dt_utc: Optional[datetime] = None,
|
||||
) -> None:
|
||||
tz = self._get_timezone(contest)
|
||||
day_start_local = datetime.combine(target_date, time.min, tzinfo=tz)
|
||||
day_end_local = day_start_local + timedelta(days=1)
|
||||
day_start_utc = day_start_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
day_end_utc = day_end_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
leaderboard = list(await get_contest_leaderboard(db, contest.id))
|
||||
total_events = await get_contest_events_count(db, contest.id)
|
||||
today_events = await get_contest_events_count(
|
||||
db,
|
||||
contest.id,
|
||||
start=day_start_utc,
|
||||
end=day_end_utc,
|
||||
)
|
||||
|
||||
await self._notify_admins(
|
||||
contest=contest,
|
||||
leaderboard=leaderboard,
|
||||
total_events=total_events,
|
||||
today_events=today_events,
|
||||
is_final=is_final,
|
||||
tz=tz,
|
||||
)
|
||||
|
||||
await self._notify_public_channel(
|
||||
contest=contest,
|
||||
leaderboard=leaderboard,
|
||||
total_events=total_events,
|
||||
today_events=today_events,
|
||||
is_final=is_final,
|
||||
tz=tz,
|
||||
)
|
||||
|
||||
if not leaderboard:
|
||||
logger.info("Конкурс %s: пока нет участников", contest.id)
|
||||
|
||||
if is_final:
|
||||
await mark_final_summary_sent(db, contest)
|
||||
else:
|
||||
await mark_daily_summary_sent(db, contest, target_date, summary_dt_utc)
|
||||
|
||||
async def _notify_participants(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
contest: ReferralContest,
|
||||
leaderboard: Sequence[Tuple[User, int, int]],
|
||||
total_events: int,
|
||||
today_events: int,
|
||||
day_start_utc: datetime,
|
||||
day_end_utc: datetime,
|
||||
is_final: bool,
|
||||
) -> None:
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
# leaderboard already sorted by helper
|
||||
score_map = {user.id: (idx + 1, score) for idx, (user, score, _) in enumerate(leaderboard)}
|
||||
|
||||
for user, score, _ in leaderboard:
|
||||
rank = score_map.get(user.id, (None, score))[0]
|
||||
today_score = await get_referrer_score(
|
||||
db=db,
|
||||
contest_id=contest.id,
|
||||
referrer_id=user.id,
|
||||
start=day_start_utc,
|
||||
end=day_end_utc,
|
||||
) if score else 0
|
||||
|
||||
text = self._build_participant_message(
|
||||
contest=contest,
|
||||
rank=rank or 0,
|
||||
score=score,
|
||||
today_score=today_score,
|
||||
total_events=total_events,
|
||||
today_events=today_events,
|
||||
is_final=is_final,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.bot.send_message(user.telegram_id, text, disable_web_page_preview=True)
|
||||
except (TelegramForbiddenError, TelegramNotFound):
|
||||
logger.info(
|
||||
"Не удалось отправить сообщение участнику %s (вероятно, блокировка)",
|
||||
user.telegram_id,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Ошибка отправки участнику конкурса %s: %s", user.telegram_id, exc)
|
||||
|
||||
async def _notify_admins(
|
||||
self,
|
||||
*,
|
||||
contest: ReferralContest,
|
||||
leaderboard: Sequence[Tuple[User, int, int]],
|
||||
total_events: int,
|
||||
today_events: int,
|
||||
is_final: bool,
|
||||
tz: ZoneInfo,
|
||||
) -> None:
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
chat_id = settings.ADMIN_NOTIFICATIONS_CHAT_ID
|
||||
if not chat_id:
|
||||
return
|
||||
|
||||
lines = [
|
||||
"🏆 <b>Конкурс рефералов</b>",
|
||||
f"Название: <b>{contest.title}</b>",
|
||||
f"Статус: {'финал' if is_final else 'дневная сводка'}",
|
||||
f"Временная зона: <code>{tz.key}</code>",
|
||||
f"Всего рефералов: <b>{total_events}</b>",
|
||||
"",
|
||||
"Топ участников:",
|
||||
]
|
||||
|
||||
if leaderboard:
|
||||
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
|
||||
name = user.full_name
|
||||
lines.append(f"{idx}. {name} ({user.telegram_id}) — {score}")
|
||||
else:
|
||||
lines.append("Пока нет участников.")
|
||||
|
||||
if contest.prize_text:
|
||||
lines.append("")
|
||||
lines.append(f"Приз: {contest.prize_text}")
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text="\n".join(lines),
|
||||
disable_web_page_preview=True,
|
||||
message_thread_id=settings.ADMIN_NOTIFICATIONS_TOPIC_ID,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Не удалось отправить админскую сводку конкурса: %s", exc)
|
||||
|
||||
async def _notify_public_channel(
|
||||
self,
|
||||
*,
|
||||
contest: ReferralContest,
|
||||
leaderboard: Sequence[Tuple[User, int, int]],
|
||||
total_events: int,
|
||||
today_events: int,
|
||||
is_final: bool,
|
||||
tz: ZoneInfo,
|
||||
) -> None:
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
channel_id_raw = settings.CHANNEL_SUB_ID
|
||||
if not channel_id_raw:
|
||||
return
|
||||
|
||||
try:
|
||||
channel_id = int(channel_id_raw)
|
||||
except Exception:
|
||||
channel_id = channel_id_raw
|
||||
|
||||
lines = [
|
||||
f"🏆 {contest.title}",
|
||||
"🏁 Итоги конкурса" if is_final else "📊 Промежуточные итоги",
|
||||
f"Время зоны: {tz.key}",
|
||||
f"Всего участников: <b>{len(leaderboard)}</b>",
|
||||
"",
|
||||
"Топ участников:",
|
||||
]
|
||||
|
||||
if leaderboard:
|
||||
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
|
||||
lines.append(f"{idx}. {user.full_name} — {score}")
|
||||
else:
|
||||
lines.append("Пока нет участников.")
|
||||
|
||||
if contest.prize_text:
|
||||
lines.append("")
|
||||
lines.append(f"Приз: {contest.prize_text}")
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=channel_id,
|
||||
text="\n".join(lines),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except (TelegramForbiddenError, TelegramNotFound):
|
||||
logger.info("Не удалось отправить сводку конкурса в канал %s", channel_id_raw)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Ошибка отправки сводки конкурса в канал %s: %s", channel_id_raw, exc)
|
||||
|
||||
def _build_participant_message(
|
||||
self,
|
||||
*,
|
||||
contest: ReferralContest,
|
||||
rank: int,
|
||||
score: int,
|
||||
today_score: int,
|
||||
total_events: int,
|
||||
today_events: int,
|
||||
is_final: bool,
|
||||
) -> str:
|
||||
status_line = "🏁 Итоги конкурса" if is_final else "📊 Промежуточные итоги"
|
||||
lines = [
|
||||
f"🏆 {contest.title}",
|
||||
status_line,
|
||||
"",
|
||||
f"Ваше место: <b>{rank}</b>",
|
||||
f"Зачётов за всё время: <b>{score}</b>",
|
||||
f"За сегодня: <b>{today_score}</b>",
|
||||
f"Общий пул зачётов: <b>{total_events}</b> (сегодня {today_events})",
|
||||
]
|
||||
|
||||
if contest.prize_text:
|
||||
lines.append("")
|
||||
lines.append(f"Призовой фонд: {contest.prize_text}")
|
||||
|
||||
if not is_final:
|
||||
remaining = contest.end_at - datetime.now(timezone.utc)
|
||||
if remaining.total_seconds() > 0:
|
||||
hours_left = int(remaining.total_seconds() // 3600)
|
||||
lines.append("")
|
||||
lines.append(f"До окончания: ~{hours_left} ч.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def get_detailed_contest_stats(self, db: AsyncSession, contest_id: int) -> dict:
|
||||
from app.database.crud.referral_contest import get_contest_leaderboard, get_referral_contest
|
||||
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
return {
|
||||
'total_participants': 0,
|
||||
'total_invited': 0,
|
||||
'total_paid_amount': 0,
|
||||
'total_unpaid': 0,
|
||||
'participants': [],
|
||||
}
|
||||
|
||||
# Get leaderboard - already includes User objects
|
||||
leaderboard = await get_contest_leaderboard(db, contest_id)
|
||||
if not leaderboard:
|
||||
return {
|
||||
'total_participants': 0,
|
||||
'total_invited': 0,
|
||||
'total_paid_amount': 0,
|
||||
'total_unpaid': 0,
|
||||
'participants': [],
|
||||
}
|
||||
|
||||
total_participants = len(leaderboard)
|
||||
total_invited = sum(score for _, score, _ in leaderboard)
|
||||
total_paid_amount = sum(amount for _, _, amount in leaderboard)
|
||||
total_unpaid = 0
|
||||
|
||||
# Build participants stats directly from leaderboard (already has User objects)
|
||||
participants_stats = []
|
||||
for user, score, amount in leaderboard:
|
||||
participants_stats.append({
|
||||
'referrer_id': user.id,
|
||||
'full_name': user.full_name,
|
||||
'total_referrals': score,
|
||||
'paid_referrals': score,
|
||||
'unpaid_referrals': 0,
|
||||
'total_paid_amount': amount,
|
||||
})
|
||||
|
||||
return {
|
||||
'total_participants': total_participants,
|
||||
'total_invited': total_invited,
|
||||
'total_paid_amount': total_paid_amount,
|
||||
'total_unpaid': total_unpaid,
|
||||
'participants': participants_stats,
|
||||
}
|
||||
|
||||
def _get_timezone(self, contest: ReferralContest) -> ZoneInfo:
|
||||
tz_name = contest.timezone or settings.TIMEZONE
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name)
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
def _parse_times(self, times_str: Optional[str]) -> list[time]:
|
||||
if not times_str:
|
||||
return []
|
||||
parsed: list[time] = []
|
||||
for part in times_str.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
parsed.append(datetime.strptime(part, "%H:%M").time())
|
||||
except Exception:
|
||||
continue
|
||||
return parsed
|
||||
|
||||
def _get_summary_times(self, contest: ReferralContest) -> list[time]:
|
||||
times = self._parse_times(contest.daily_summary_times)
|
||||
if not times and contest.daily_summary_time:
|
||||
times.append(contest.daily_summary_time)
|
||||
if not times:
|
||||
times.append(time(hour=12, minute=0))
|
||||
return sorted(times)
|
||||
|
||||
def _to_local(self, dt_value: datetime, tz: ZoneInfo) -> datetime:
|
||||
base = dt_value
|
||||
if dt_value.tzinfo is None:
|
||||
base = dt_value.replace(tzinfo=timezone.utc)
|
||||
return base.astimezone(tz)
|
||||
|
||||
async def on_subscription_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_kopeks: int = 0,
|
||||
) -> None:
|
||||
if not settings.is_contests_enabled():
|
||||
return
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.referred_by_id:
|
||||
return
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
contests = await get_contests_for_events(
|
||||
db,
|
||||
now_utc,
|
||||
contest_types=["referral_paid"],
|
||||
)
|
||||
if not contests:
|
||||
return
|
||||
|
||||
for contest in contests:
|
||||
try:
|
||||
event = await add_contest_event(
|
||||
db,
|
||||
contest_id=contest.id,
|
||||
referrer_id=user.referred_by_id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
event_type="subscription_purchase",
|
||||
)
|
||||
if event:
|
||||
logger.info(
|
||||
"Записан зачёт конкурса %s: реферер %s, реферал %s",
|
||||
contest.id,
|
||||
user.referred_by_id,
|
||||
user.id,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Не удалось записать зачёт конкурса %s: %s", contest.id, exc)
|
||||
|
||||
async def on_referral_registration(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
if not settings.is_contests_enabled():
|
||||
return
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.referred_by_id:
|
||||
return
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
contests = await get_contests_for_events(
|
||||
db,
|
||||
now_utc,
|
||||
contest_types=["referral_registered"],
|
||||
)
|
||||
if not contests:
|
||||
return
|
||||
|
||||
for contest in contests:
|
||||
try:
|
||||
event = await add_contest_event(
|
||||
db,
|
||||
contest_id=contest.id,
|
||||
referrer_id=user.referred_by_id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=0,
|
||||
event_type="referral_registration",
|
||||
)
|
||||
if event:
|
||||
logger.info(
|
||||
"Записан зачёт конкурса регистрации %s: реферер %s, реферал %s",
|
||||
contest.id,
|
||||
user.referred_by_id,
|
||||
user.id,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Не удалось записать зачёт регистрации для конкурса %s: %s", contest.id, exc)
|
||||
|
||||
referral_contest_service = ReferralContestService()
|
||||
@@ -50,6 +50,13 @@ async def process_referral_registration(
|
||||
reason="referral_registration_pending"
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
|
||||
await referral_contest_service.on_referral_registration(db, new_user_id)
|
||||
except Exception as exc:
|
||||
logger.debug("Не удалось записать конкурсную регистрацию: %s", exc)
|
||||
|
||||
if bot:
|
||||
commission_percent = get_effective_referral_commission_percent(referrer)
|
||||
referral_notification = (
|
||||
|
||||
@@ -1945,8 +1945,8 @@ class RemnaWaveService:
|
||||
}
|
||||
|
||||
usage_data = await api._make_request(
|
||||
'GET',
|
||||
f'/api/nodes/usage/{node_uuid}/users/range',
|
||||
'GET',
|
||||
f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy',
|
||||
params=params
|
||||
)
|
||||
|
||||
|
||||
@@ -219,3 +219,17 @@ class SupportSettingsService:
|
||||
return cls._save()
|
||||
return True
|
||||
|
||||
# NaloGO receipts
|
||||
@classmethod
|
||||
def is_nalogo_receipts_enabled(cls) -> bool:
|
||||
cls._load()
|
||||
if "nalogo_receipts_enabled" in cls._data:
|
||||
return bool(cls._data["nalogo_receipts_enabled"])
|
||||
return bool(settings.NALOGO_RECEIPTS_ENABLED)
|
||||
|
||||
@classmethod
|
||||
def set_nalogo_receipts_enabled(cls, enabled: bool) -> bool:
|
||||
cls._load()
|
||||
cls._data["nalogo_receipts_enabled"] = bool(enabled)
|
||||
return cls._save()
|
||||
|
||||
|
||||
@@ -90,6 +90,17 @@ class AdminStates(StatesGroup):
|
||||
editing_promo_group_device_discount = State()
|
||||
editing_promo_group_period_discount = State()
|
||||
editing_promo_group_auto_assign = State()
|
||||
|
||||
creating_referral_contest_title = State()
|
||||
creating_referral_contest_description = State()
|
||||
creating_referral_contest_prize = State()
|
||||
creating_referral_contest_mode = State()
|
||||
creating_referral_contest_start = State()
|
||||
creating_referral_contest_end = State()
|
||||
creating_referral_contest_time = State()
|
||||
editing_referral_contest_summary_times = State()
|
||||
editing_daily_contest_field = State()
|
||||
editing_daily_contest_value = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
editing_traffic_price = State()
|
||||
@@ -190,6 +201,9 @@ class SquadMigrationStates(StatesGroup):
|
||||
class RemnaWaveSyncStates(StatesGroup):
|
||||
waiting_for_schedule = State()
|
||||
|
||||
class ContestStates(StatesGroup):
|
||||
waiting_for_answer = State()
|
||||
|
||||
|
||||
class AdminSubmenuStates(StatesGroup):
|
||||
in_users_submenu = State()
|
||||
@@ -197,3 +211,7 @@ class AdminSubmenuStates(StatesGroup):
|
||||
in_communications_submenu = State()
|
||||
in_settings_submenu = State()
|
||||
in_system_submenu = State()
|
||||
|
||||
|
||||
class BlacklistStates(StatesGroup):
|
||||
waiting_for_blacklist_url = State()
|
||||
|
||||
@@ -45,6 +45,23 @@ def admin_required(func: Callable) -> Callable:
|
||||
return wrapper
|
||||
|
||||
|
||||
def auth_required(func: Callable) -> Callable:
|
||||
"""
|
||||
Простая проверка на наличие пользователя в апдейте. Middleware уже подтягивает db_user,
|
||||
но здесь страхуемся от вызовов без from_user.
|
||||
"""
|
||||
@functools.wraps(func)
|
||||
async def wrapper(event: types.Update, *args, **kwargs) -> Any:
|
||||
user = None
|
||||
if isinstance(event, (types.Message, types.CallbackQuery)):
|
||||
user = event.from_user
|
||||
if not user:
|
||||
logger.warning("auth_required: нет from_user, пропускаем")
|
||||
return
|
||||
return await func(event, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def error_handler(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
|
||||
@@ -15,6 +15,7 @@ from .routes import (
|
||||
health,
|
||||
main_menu_buttons,
|
||||
media,
|
||||
menu_layout,
|
||||
miniapp,
|
||||
partners,
|
||||
polls,
|
||||
@@ -25,7 +26,9 @@ from .routes import (
|
||||
welcome_texts,
|
||||
pages,
|
||||
remnawave,
|
||||
contests,
|
||||
servers,
|
||||
support_settings,
|
||||
subscription_events,
|
||||
stats,
|
||||
subscriptions,
|
||||
@@ -54,6 +57,10 @@ OPENAPI_TAGS = [
|
||||
"name": "main-menu",
|
||||
"description": "Управление кнопками и сообщениями главного меню Telegram-бота.",
|
||||
},
|
||||
{
|
||||
"name": "menu-layout",
|
||||
"description": "API конструктор меню: управление расположением и настройками кнопок.",
|
||||
},
|
||||
{
|
||||
"name": "welcome-texts",
|
||||
"description": "Создание, редактирование и управление приветственными текстами.",
|
||||
@@ -134,6 +141,10 @@ OPENAPI_TAGS = [
|
||||
"сменах промогрупп пользователей для административной панели."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "contests",
|
||||
"description": "Управление конкурсами: реферальными и ежедневными играми/раундами.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -175,15 +186,22 @@ def create_web_api_app() -> FastAPI:
|
||||
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(support_settings.router, prefix="/settings/support", tags=["support-settings"])
|
||||
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
|
||||
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
|
||||
app.include_router(promo_offers.router, prefix="/promo-offers", tags=["promo-offers"])
|
||||
app.include_router(servers.router, prefix="/servers", tags=["servers"])
|
||||
app.include_router(contests.router, prefix="/contests", tags=["contests"])
|
||||
app.include_router(
|
||||
main_menu_buttons.router,
|
||||
prefix="/main-menu/buttons",
|
||||
tags=["main-menu"],
|
||||
)
|
||||
app.include_router(
|
||||
menu_layout.router,
|
||||
prefix="/menu-layout",
|
||||
tags=["menu-layout"],
|
||||
)
|
||||
app.include_router(
|
||||
user_messages.router,
|
||||
prefix="/main-menu/messages",
|
||||
|
||||
@@ -13,6 +13,7 @@ from . import (
|
||||
promo_groups,
|
||||
servers,
|
||||
remnawave,
|
||||
contests,
|
||||
subscription_events,
|
||||
stats,
|
||||
subscriptions,
|
||||
@@ -38,6 +39,7 @@ __all__ = [
|
||||
"promo_groups",
|
||||
"servers",
|
||||
"remnawave",
|
||||
"contests",
|
||||
"subscription_events",
|
||||
"stats",
|
||||
"subscriptions",
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone, time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status, Response
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased, selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
create_round,
|
||||
finish_round,
|
||||
get_active_round_by_template,
|
||||
get_active_rounds,
|
||||
list_templates,
|
||||
update_template_fields,
|
||||
get_template_by_id,
|
||||
)
|
||||
from app.database.crud.referral_contest import (
|
||||
create_referral_contest,
|
||||
get_contest_events_count,
|
||||
get_contest_leaderboard,
|
||||
get_referral_contest,
|
||||
get_referral_contests_count,
|
||||
list_referral_contests,
|
||||
toggle_referral_contest,
|
||||
update_referral_contest,
|
||||
delete_referral_contest,
|
||||
)
|
||||
from app.database.models import (
|
||||
ContestAttempt,
|
||||
ContestRound,
|
||||
ContestTemplate,
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
User,
|
||||
)
|
||||
from app.services.contest_rotation_service import contest_rotation_service
|
||||
from app.webapi.dependencies import get_db_session, require_api_token
|
||||
from app.webapi.schemas.contests import (
|
||||
ContestAttemptListResponse,
|
||||
ContestAttemptResponse,
|
||||
ContestAttemptUser,
|
||||
ContestRoundListResponse,
|
||||
ContestRoundResponse,
|
||||
ContestTemplateListResponse,
|
||||
ContestTemplateResponse,
|
||||
ContestTemplateUpdateRequest,
|
||||
ReferralContestCreateRequest,
|
||||
ReferralContestDetailResponse,
|
||||
ReferralContestDetailedStatsResponse,
|
||||
ReferralContestEventListResponse,
|
||||
ReferralContestEventResponse,
|
||||
ReferralContestEventUser,
|
||||
ReferralContestLeaderboardItem,
|
||||
ReferralContestListResponse,
|
||||
ReferralContestParticipant,
|
||||
ReferralContestResponse,
|
||||
ReferralContestUpdateRequest,
|
||||
StartRoundRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------- Helpers ----------
|
||||
|
||||
|
||||
def _to_utc_naive(dt: datetime, tz_name: Optional[str] = None) -> datetime:
|
||||
tz = ZoneInfo(tz_name or settings.TIMEZONE)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=tz)
|
||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _serialize_template(tpl: ContestTemplate) -> ContestTemplateResponse:
|
||||
return ContestTemplateResponse(
|
||||
id=tpl.id,
|
||||
name=tpl.name,
|
||||
slug=tpl.slug,
|
||||
description=tpl.description,
|
||||
prize_days=tpl.prize_days,
|
||||
max_winners=tpl.max_winners,
|
||||
attempts_per_user=tpl.attempts_per_user,
|
||||
times_per_day=tpl.times_per_day,
|
||||
schedule_times=tpl.schedule_times,
|
||||
cooldown_hours=tpl.cooldown_hours,
|
||||
payload=tpl.payload or {},
|
||||
is_enabled=tpl.is_enabled,
|
||||
created_at=tpl.created_at,
|
||||
updated_at=tpl.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_round(round_obj: ContestRound) -> ContestRoundResponse:
|
||||
tpl = round_obj.template
|
||||
return ContestRoundResponse(
|
||||
id=round_obj.id,
|
||||
template_id=round_obj.template_id,
|
||||
template_slug=tpl.slug if tpl else "",
|
||||
template_name=tpl.name if tpl else None,
|
||||
starts_at=round_obj.starts_at,
|
||||
ends_at=round_obj.ends_at,
|
||||
status=round_obj.status,
|
||||
payload=round_obj.payload or {},
|
||||
winners_count=round_obj.winners_count,
|
||||
max_winners=round_obj.max_winners,
|
||||
attempts_per_user=round_obj.attempts_per_user,
|
||||
created_at=round_obj.created_at,
|
||||
updated_at=round_obj.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_attempt(attempt: ContestAttempt, user: User) -> ContestAttemptResponse:
|
||||
return ContestAttemptResponse(
|
||||
id=attempt.id,
|
||||
round_id=attempt.round_id,
|
||||
user=ContestAttemptUser(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
full_name=user.full_name,
|
||||
),
|
||||
answer=attempt.answer,
|
||||
is_winner=attempt.is_winner,
|
||||
created_at=attempt.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_referral_contest(contest: ReferralContest) -> ReferralContestResponse:
|
||||
return ReferralContestResponse(
|
||||
id=contest.id,
|
||||
title=contest.title,
|
||||
description=contest.description,
|
||||
prize_text=contest.prize_text,
|
||||
contest_type=contest.contest_type,
|
||||
start_at=contest.start_at,
|
||||
end_at=contest.end_at,
|
||||
daily_summary_time=contest.daily_summary_time,
|
||||
daily_summary_times=contest.daily_summary_times,
|
||||
timezone=contest.timezone,
|
||||
is_active=contest.is_active,
|
||||
last_daily_summary_date=contest.last_daily_summary_date,
|
||||
last_daily_summary_at=contest.last_daily_summary_at,
|
||||
final_summary_sent=contest.final_summary_sent,
|
||||
created_by=contest.created_by,
|
||||
created_at=contest.created_at,
|
||||
updated_at=contest.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _parse_times_str(times_str: Optional[str]) -> List[time]:
|
||||
if not times_str:
|
||||
return []
|
||||
parsed: List[time] = []
|
||||
for part in times_str.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
parsed.append(datetime.strptime(part, "%H:%M").time())
|
||||
except Exception:
|
||||
continue
|
||||
return parsed
|
||||
|
||||
|
||||
def _primary_time(times_str: Optional[str], fallback: Optional[time]) -> time:
|
||||
parsed = _parse_times_str(times_str)
|
||||
if parsed:
|
||||
return parsed[0]
|
||||
return fallback or time(hour=12)
|
||||
|
||||
|
||||
def _serialize_leaderboard_item(row) -> ReferralContestLeaderboardItem:
|
||||
user, referrals_count, total_amount = row
|
||||
total_amount_kopeks = int(total_amount or 0)
|
||||
return ReferralContestLeaderboardItem(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
full_name=user.full_name,
|
||||
referrals_count=int(referrals_count or 0),
|
||||
total_amount_kopeks=total_amount_kopeks,
|
||||
total_amount_rubles=round(total_amount_kopeks / 100, 2),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_event(
|
||||
event: ReferralContestEvent,
|
||||
referrer: User,
|
||||
referral: User,
|
||||
) -> ReferralContestEventResponse:
|
||||
amount_kopeks = int(event.amount_kopeks or 0)
|
||||
return ReferralContestEventResponse(
|
||||
id=event.id,
|
||||
contest_id=event.contest_id,
|
||||
referrer=ReferralContestEventUser(
|
||||
id=referrer.id,
|
||||
telegram_id=referrer.telegram_id,
|
||||
username=referrer.username,
|
||||
full_name=referrer.full_name,
|
||||
),
|
||||
referral=ReferralContestEventUser(
|
||||
id=referral.id,
|
||||
telegram_id=referral.telegram_id,
|
||||
username=referral.username,
|
||||
full_name=referral.full_name,
|
||||
),
|
||||
event_type=event.event_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
amount_rubles=round(amount_kopeks / 100, 2),
|
||||
occurred_at=event.occurred_at,
|
||||
)
|
||||
|
||||
|
||||
# --------- Daily contests (мини-игры) ----------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/daily/templates",
|
||||
response_model=ContestTemplateListResponse,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def list_daily_templates(
|
||||
enabled_only: bool = Query(False, description="Показывать только включенные игры"),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ContestTemplateListResponse:
|
||||
templates = await list_templates(db, enabled_only=enabled_only)
|
||||
return ContestTemplateListResponse(items=[_serialize_template(tpl) for tpl in templates])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/daily/templates/{template_id}",
|
||||
response_model=ContestTemplateResponse,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def get_daily_template(
|
||||
template_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ContestTemplateResponse:
|
||||
tpl = await get_template_by_id(db, template_id)
|
||||
if not tpl:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found")
|
||||
return _serialize_template(tpl)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/daily/templates/{template_id}",
|
||||
response_model=ContestTemplateResponse,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def update_daily_template(
|
||||
template_id: int,
|
||||
payload: ContestTemplateUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ContestTemplateResponse:
|
||||
tpl = await get_template_by_id(db, template_id)
|
||||
if not tpl:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found")
|
||||
|
||||
update_fields = payload.model_dump(exclude_none=True)
|
||||
if not update_fields:
|
||||
return _serialize_template(tpl)
|
||||
|
||||
tpl = await update_template_fields(db, tpl, **update_fields)
|
||||
return _serialize_template(tpl)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/daily/templates/{template_id}/start-round",
|
||||
response_model=ContestRoundResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def start_round_now(
|
||||
template_id: int,
|
||||
payload: StartRoundRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ContestRoundResponse:
|
||||
tpl = await get_template_by_id(db, template_id)
|
||||
if not tpl:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found")
|
||||
|
||||
if not tpl.is_enabled:
|
||||
tpl = await update_template_fields(db, tpl, is_enabled=True)
|
||||
|
||||
existing = await get_active_round_by_template(db, tpl.id)
|
||||
if existing and not payload.force:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Active round already exists for this template. Set force=true to start a new one.",
|
||||
)
|
||||
if existing and payload.force:
|
||||
await finish_round(db, existing)
|
||||
|
||||
starts_at = payload.starts_at or datetime.utcnow()
|
||||
ends_at = payload.ends_at
|
||||
if payload.cooldown_hours and not ends_at:
|
||||
ends_at = starts_at + timedelta(hours=payload.cooldown_hours)
|
||||
elif not ends_at:
|
||||
ends_at = starts_at + timedelta(hours=tpl.cooldown_hours)
|
||||
|
||||
starts_at = _to_utc_naive(starts_at, settings.TIMEZONE)
|
||||
ends_at = _to_utc_naive(ends_at, settings.TIMEZONE)
|
||||
|
||||
round_payload: Dict[str, Any] = payload.payload or contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
|
||||
|
||||
round_obj = await create_round(
|
||||
db,
|
||||
template=tpl,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
payload=round_payload,
|
||||
)
|
||||
round_obj.template = tpl
|
||||
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
|
||||
tpl,
|
||||
starts_at,
|
||||
ends_at,
|
||||
)
|
||||
return _serialize_round(round_obj)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/daily/rounds",
|
||||
response_model=ContestRoundListResponse,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def list_rounds(
|
||||
status_filter: str = Query("active", regex="^(active|finished|any)$"),
|
||||
template_id: Optional[int] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ContestRoundListResponse:
|
||||
if status_filter == "active":
|
||||
rounds = await get_active_rounds(db)
|
||||
if template_id:
|
||||
rounds = [r for r in rounds if r.template_id == template_id]
|
||||
total = len(rounds)
|
||||
items = rounds[offset : offset + limit]
|
||||
return ContestRoundListResponse(
|
||||
items=[_serialize_round(r) for r in items],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
query = (
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
.order_by(ContestRound.starts_at.desc())
|
||||
)
|
||||
count_query = select(func.count(ContestRound.id))
|
||||
if status_filter != "any":
|
||||
query = query.where(ContestRound.status == status_filter)
|
||||
count_query = count_query.where(ContestRound.status == status_filter)
|
||||
if template_id:
|
||||
query = query.where(ContestRound.template_id == template_id)
|
||||
count_query = count_query.where(ContestRound.template_id == template_id)
|
||||
|
||||
total = await db.scalar(count_query) or 0
|
||||
result = await db.execute(query.offset(offset).limit(limit))
|
||||
rounds = result.scalars().unique().all()
|
||||
|
||||
return ContestRoundListResponse(
|
||||
items=[_serialize_round(r) for r in rounds],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/daily/rounds/{round_id}",
|
||||
response_model=ContestRoundResponse,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def get_round(
|
||||
round_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ContestRoundResponse:
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
.where(ContestRound.id == round_id)
|
||||
)
|
||||
round_obj = result.scalar_one_or_none()
|
||||
if not round_obj:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Round not found")
|
||||
return _serialize_round(round_obj)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/daily/rounds/{round_id}/finish",
|
||||
response_model=ContestRoundResponse,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def finish_round_now(
|
||||
round_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ContestRoundResponse:
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
.where(ContestRound.id == round_id)
|
||||
)
|
||||
round_obj = result.scalar_one_or_none()
|
||||
if not round_obj:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Round not found")
|
||||
if round_obj.status != "finished":
|
||||
round_obj = await finish_round(db, round_obj)
|
||||
return _serialize_round(round_obj)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/daily/rounds/{round_id}/attempts",
|
||||
response_model=ContestAttemptListResponse,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def list_attempts(
|
||||
round_id: int,
|
||||
winners_only: bool = Query(False, description="Вернуть только победителей"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ContestAttemptListResponse:
|
||||
conditions = [ContestAttempt.round_id == round_id]
|
||||
if winners_only:
|
||||
conditions.append(ContestAttempt.is_winner.is_(True))
|
||||
|
||||
total = await db.scalar(
|
||||
select(func.count(ContestAttempt.id)).where(and_(*conditions))
|
||||
) or 0
|
||||
|
||||
query = (
|
||||
select(ContestAttempt, User)
|
||||
.join(User, User.id == ContestAttempt.user_id)
|
||||
.where(and_(*conditions))
|
||||
.order_by(ContestAttempt.created_at)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
return ContestAttemptListResponse(
|
||||
items=[_serialize_attempt(attempt, user) for attempt, user in rows],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
# --------- Referral contests ----------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/referral",
|
||||
response_model=ReferralContestListResponse,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def list_referral(
|
||||
contest_type: Optional[str] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ReferralContestListResponse:
|
||||
contests = await list_referral_contests(
|
||||
db,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
contest_type=contest_type,
|
||||
)
|
||||
total = await get_referral_contests_count(db, contest_type=contest_type)
|
||||
return ReferralContestListResponse(
|
||||
items=[_serialize_referral_contest(c) for c in contests],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/contests/referral",
|
||||
response_model=ReferralContestResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def create_referral(
|
||||
payload: ReferralContestCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ReferralContestResponse:
|
||||
start_at = _to_utc_naive(payload.start_at, payload.timezone)
|
||||
end_at = _to_utc_naive(payload.end_at, payload.timezone)
|
||||
if end_at <= start_at:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "end_at must be after start_at")
|
||||
|
||||
summary_time = _primary_time(payload.daily_summary_times, payload.daily_summary_time)
|
||||
|
||||
contest = await create_referral_contest(
|
||||
db,
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
prize_text=payload.prize_text,
|
||||
contest_type=payload.contest_type,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
daily_summary_time=summary_time,
|
||||
daily_summary_times=payload.daily_summary_times,
|
||||
timezone_name=payload.timezone,
|
||||
created_by=payload.created_by,
|
||||
)
|
||||
|
||||
if payload.is_active is False:
|
||||
contest = await toggle_referral_contest(db, contest, False)
|
||||
|
||||
return _serialize_referral_contest(contest)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/referral/{contest_id}",
|
||||
response_model=ReferralContestDetailResponse,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def get_referral(
|
||||
contest_id: int,
|
||||
leaderboard_limit: int = Query(5, ge=1, le=50),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ReferralContestDetailResponse:
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
|
||||
|
||||
total_events = await get_contest_events_count(db, contest.id)
|
||||
leaderboard_rows = await get_contest_leaderboard(db, contest.id, limit=leaderboard_limit)
|
||||
leaderboard = [_serialize_leaderboard_item(row) for row in leaderboard_rows]
|
||||
|
||||
return ReferralContestDetailResponse(
|
||||
**_serialize_referral_contest(contest).model_dump(),
|
||||
total_events=int(total_events),
|
||||
leaderboard=leaderboard,
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/referral/{contest_id}",
|
||||
response_model=ReferralContestResponse,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def update_referral(
|
||||
contest_id: int,
|
||||
payload: ReferralContestUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ReferralContestResponse:
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
|
||||
|
||||
fields = payload.model_dump(exclude_none=True)
|
||||
|
||||
if "start_at" in fields:
|
||||
fields["start_at"] = _to_utc_naive(fields["start_at"], fields.get("timezone") or contest.timezone)
|
||||
if "end_at" in fields:
|
||||
fields["end_at"] = _to_utc_naive(fields["end_at"], fields.get("timezone") or contest.timezone)
|
||||
if "daily_summary_times" in fields:
|
||||
fields["daily_summary_time"] = _primary_time(fields["daily_summary_times"], fields.get("daily_summary_time") or contest.daily_summary_time)
|
||||
elif "daily_summary_time" in fields:
|
||||
# ensure type is time (pydantic provides time)
|
||||
pass
|
||||
|
||||
new_start = fields.get("start_at", contest.start_at)
|
||||
new_end = fields.get("end_at", contest.end_at)
|
||||
if new_end <= new_start:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "end_at must be after start_at")
|
||||
|
||||
if fields:
|
||||
contest = await update_referral_contest(db, contest, **fields)
|
||||
|
||||
return _serialize_referral_contest(contest)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/referral/{contest_id}/toggle",
|
||||
response_model=ReferralContestResponse,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def toggle_referral(
|
||||
contest_id: int,
|
||||
is_active: bool = Query(..., description="Активировать или остановить конкурс"),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ReferralContestResponse:
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
|
||||
contest = await toggle_referral_contest(db, contest, is_active)
|
||||
return _serialize_referral_contest(contest)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/referral/{contest_id}",
|
||||
status_code=status.HTTP_200_OK,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def delete_referral(
|
||||
contest_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Dict[str, str]:
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
|
||||
now_utc = datetime.utcnow()
|
||||
if contest.is_active or contest.end_at > now_utc:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Можно удалять только завершённые конкурсы",
|
||||
)
|
||||
await delete_referral_contest(db, contest)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/referral/{contest_id}/events",
|
||||
response_model=ReferralContestEventListResponse,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def list_referral_events(
|
||||
contest_id: int,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ReferralContestEventListResponse:
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
|
||||
|
||||
referrer_user = aliased(User)
|
||||
referral_user = aliased(User)
|
||||
|
||||
base_conditions = [ReferralContestEvent.contest_id == contest_id]
|
||||
total = await db.scalar(
|
||||
select(func.count(ReferralContestEvent.id)).where(and_(*base_conditions))
|
||||
) or 0
|
||||
|
||||
query = (
|
||||
select(ReferralContestEvent, referrer_user, referral_user)
|
||||
.join(referrer_user, referrer_user.id == ReferralContestEvent.referrer_id)
|
||||
.join(referral_user, referral_user.id == ReferralContestEvent.referral_id)
|
||||
.where(and_(*base_conditions))
|
||||
.order_by(ReferralContestEvent.occurred_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
return ReferralContestEventListResponse(
|
||||
items=[_serialize_event(event, referrer, referral) for event, referrer, referral in rows],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/referral/{contest_id}/detailed-stats",
|
||||
response_model=ReferralContestDetailedStatsResponse,
|
||||
tags=["contests"],
|
||||
)
|
||||
async def get_referral_detailed_stats(
|
||||
contest_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ReferralContestDetailedStatsResponse:
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
|
||||
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||||
return ReferralContestDetailedStatsResponse(**stats)
|
||||
@@ -0,0 +1,991 @@
|
||||
"""API эндпоинты для конструктора меню."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.services.menu_layout_service import (
|
||||
MenuContext,
|
||||
MenuLayoutService,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.menu_layout import (
|
||||
AddCustomButtonRequest,
|
||||
AddRowRequest,
|
||||
AvailableCallback,
|
||||
AvailableCallbacksResponse,
|
||||
BuiltinButtonInfo,
|
||||
BuiltinButtonsListResponse,
|
||||
ButtonClickStats,
|
||||
ButtonClickStatsResponse,
|
||||
ButtonConditions,
|
||||
ButtonTypeStats,
|
||||
ButtonTypeStatsResponse,
|
||||
ButtonUpdateRequest,
|
||||
DynamicPlaceholder,
|
||||
DynamicPlaceholdersResponse,
|
||||
HourlyStats,
|
||||
HourlyStatsResponse,
|
||||
MenuButtonConfig,
|
||||
MenuClickStatsResponse,
|
||||
MenuLayoutConfig,
|
||||
MenuLayoutExportResponse,
|
||||
MenuLayoutHistoryEntry,
|
||||
MenuLayoutHistoryResponse,
|
||||
MenuLayoutImportRequest,
|
||||
MenuLayoutImportResponse,
|
||||
MenuLayoutResponse,
|
||||
MenuLayoutRollbackRequest,
|
||||
MenuLayoutUpdateRequest,
|
||||
MenuLayoutValidateRequest,
|
||||
MenuLayoutValidateResponse,
|
||||
MenuPreviewButton,
|
||||
MenuPreviewRequest,
|
||||
MenuPreviewResponse,
|
||||
MenuPreviewRow,
|
||||
MenuRowConfig,
|
||||
MoveButtonResponse,
|
||||
MoveButtonToRowRequest,
|
||||
PeriodComparisonResponse,
|
||||
ReorderButtonsInRowRequest,
|
||||
TopUserStats,
|
||||
TopUsersResponse,
|
||||
UserClickSequence,
|
||||
UserClickSequencesResponse,
|
||||
WeekdayStats,
|
||||
WeekdayStatsResponse,
|
||||
ReorderButtonsResponse,
|
||||
RowsReorderRequest,
|
||||
SwapButtonsRequest,
|
||||
SwapButtonsResponse,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_config(config: dict, is_enabled: bool, updated_at) -> MenuLayoutResponse:
|
||||
"""Сериализовать конфигурацию в response."""
|
||||
rows = []
|
||||
for row_data in config.get("rows", []):
|
||||
rows.append(
|
||||
MenuRowConfig(
|
||||
id=row_data["id"],
|
||||
buttons=row_data.get("buttons", []),
|
||||
conditions=ButtonConditions(**row_data["conditions"])
|
||||
if row_data.get("conditions")
|
||||
else None,
|
||||
max_per_row=row_data.get("max_per_row", 2),
|
||||
)
|
||||
)
|
||||
|
||||
buttons = {}
|
||||
for btn_id, btn_data in config.get("buttons", {}).items():
|
||||
buttons[btn_id] = MenuButtonConfig(
|
||||
type=btn_data["type"],
|
||||
builtin_id=btn_data.get("builtin_id"),
|
||||
text=btn_data.get("text", {}),
|
||||
icon=btn_data.get("icon"),
|
||||
action=btn_data.get("action", ""),
|
||||
enabled=btn_data.get("enabled", True),
|
||||
visibility=btn_data.get("visibility", "all"),
|
||||
conditions=ButtonConditions(**btn_data["conditions"])
|
||||
if btn_data.get("conditions")
|
||||
else None,
|
||||
dynamic_text=btn_data.get("dynamic_text", False),
|
||||
open_mode=btn_data.get("open_mode", "callback"),
|
||||
webapp_url=btn_data.get("webapp_url"),
|
||||
description=btn_data.get("description"),
|
||||
sort_order=btn_data.get("sort_order"),
|
||||
)
|
||||
|
||||
return MenuLayoutResponse(
|
||||
version=config.get("version", 1),
|
||||
rows=rows,
|
||||
buttons=buttons,
|
||||
is_enabled=is_enabled,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=MenuLayoutResponse)
|
||||
async def get_menu_layout(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutResponse:
|
||||
"""Получить текущую конфигурацию меню."""
|
||||
config = await MenuLayoutService.get_config(db)
|
||||
updated_at = await MenuLayoutService.get_config_updated_at(db)
|
||||
return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at)
|
||||
|
||||
|
||||
@router.put("", response_model=MenuLayoutResponse)
|
||||
async def update_menu_layout(
|
||||
payload: MenuLayoutUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutResponse:
|
||||
"""Обновить конфигурацию меню полностью."""
|
||||
config = await MenuLayoutService.get_config(db)
|
||||
config = config.copy()
|
||||
|
||||
if payload.rows is not None:
|
||||
config["rows"] = [row.model_dump() for row in payload.rows]
|
||||
|
||||
if payload.buttons is not None:
|
||||
buttons_config = {}
|
||||
for btn_id, btn in payload.buttons.items():
|
||||
btn_dict = btn.model_dump()
|
||||
# Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен
|
||||
if not btn_dict.get("dynamic_text", False):
|
||||
from app.services.menu_layout.service import MenuLayoutService
|
||||
btn_dict["dynamic_text"] = MenuLayoutService._text_has_placeholders(btn_dict.get("text", {}))
|
||||
buttons_config[btn_id] = btn_dict
|
||||
config["buttons"] = buttons_config
|
||||
|
||||
await MenuLayoutService.save_config(db, config)
|
||||
updated_at = await MenuLayoutService.get_config_updated_at(db)
|
||||
return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at)
|
||||
|
||||
|
||||
@router.post("/reset", response_model=MenuLayoutResponse)
|
||||
async def reset_menu_layout(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutResponse:
|
||||
"""Сбросить конфигурацию к дефолтной."""
|
||||
config = await MenuLayoutService.reset_to_default(db)
|
||||
updated_at = await MenuLayoutService.get_config_updated_at(db)
|
||||
return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at)
|
||||
|
||||
|
||||
@router.get("/builtin-buttons", response_model=BuiltinButtonsListResponse)
|
||||
async def list_builtin_buttons(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> BuiltinButtonsListResponse:
|
||||
"""Получить список встроенных кнопок."""
|
||||
items = []
|
||||
for btn_info in MenuLayoutService.get_builtin_buttons_info():
|
||||
items.append(
|
||||
BuiltinButtonInfo(
|
||||
id=btn_info["id"],
|
||||
default_text=btn_info["default_text"],
|
||||
callback_data=btn_info["callback_data"],
|
||||
default_conditions=ButtonConditions(**btn_info["default_conditions"])
|
||||
if btn_info.get("default_conditions")
|
||||
else None,
|
||||
supports_dynamic_text=btn_info.get("supports_dynamic_text", False),
|
||||
supports_direct_open=btn_info.get("supports_direct_open", False),
|
||||
)
|
||||
)
|
||||
|
||||
return BuiltinButtonsListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.patch("/buttons/{button_id}")
|
||||
async def update_button(
|
||||
button_id: str,
|
||||
payload: ButtonUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuButtonConfig:
|
||||
"""Обновить конфигурацию отдельной кнопки."""
|
||||
try:
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
# Конвертируем visibility в строку если есть
|
||||
if "visibility" in updates and updates["visibility"] is not None:
|
||||
if hasattr(updates["visibility"], "value"):
|
||||
updates["visibility"] = updates["visibility"].value
|
||||
# Конвертируем open_mode в строку если есть
|
||||
if "open_mode" in updates and updates["open_mode"] is not None:
|
||||
if hasattr(updates["open_mode"], "value"):
|
||||
updates["open_mode"] = updates["open_mode"].value
|
||||
# Конвертируем conditions - убираем None значения если это dict
|
||||
if "conditions" in updates and updates["conditions"] is not None:
|
||||
if isinstance(updates["conditions"], dict):
|
||||
updates["conditions"] = {k: v for k, v in updates["conditions"].items() if v is not None}
|
||||
elif hasattr(updates["conditions"], "model_dump"):
|
||||
updates["conditions"] = updates["conditions"].model_dump(exclude_none=True)
|
||||
|
||||
button = await MenuLayoutService.update_button(db, button_id, updates)
|
||||
|
||||
return MenuButtonConfig(
|
||||
type=button["type"],
|
||||
builtin_id=button.get("builtin_id"),
|
||||
text=button.get("text", {}),
|
||||
icon=button.get("icon"),
|
||||
action=button.get("action", ""),
|
||||
enabled=button.get("enabled", True),
|
||||
visibility=button.get("visibility", "all"),
|
||||
conditions=ButtonConditions(**button["conditions"])
|
||||
if button.get("conditions")
|
||||
else None,
|
||||
dynamic_text=button.get("dynamic_text", False),
|
||||
open_mode=button.get("open_mode", "callback"),
|
||||
webapp_url=button.get("webapp_url"),
|
||||
description=button.get("description"),
|
||||
)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/rows/reorder", response_model=List[MenuRowConfig])
|
||||
async def reorder_rows(
|
||||
payload: RowsReorderRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> List[MenuRowConfig]:
|
||||
"""Изменить порядок строк."""
|
||||
try:
|
||||
rows = await MenuLayoutService.reorder_rows(db, payload.ordered_ids)
|
||||
return [
|
||||
MenuRowConfig(
|
||||
id=row["id"],
|
||||
buttons=row.get("buttons", []),
|
||||
conditions=ButtonConditions(**row["conditions"])
|
||||
if row.get("conditions")
|
||||
else None,
|
||||
max_per_row=row.get("max_per_row", 2),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/rows", response_model=MenuRowConfig, status_code=status.HTTP_201_CREATED)
|
||||
async def add_row(
|
||||
payload: AddRowRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuRowConfig:
|
||||
"""Добавить новую строку."""
|
||||
try:
|
||||
row_config = {
|
||||
"id": payload.id,
|
||||
"buttons": payload.buttons,
|
||||
"conditions": payload.conditions.model_dump(exclude_none=True)
|
||||
if payload.conditions
|
||||
else None,
|
||||
"max_per_row": payload.max_per_row,
|
||||
}
|
||||
row = await MenuLayoutService.add_row(db, row_config, payload.position)
|
||||
|
||||
return MenuRowConfig(
|
||||
id=row["id"],
|
||||
buttons=row.get("buttons", []),
|
||||
conditions=ButtonConditions(**row["conditions"])
|
||||
if row.get("conditions")
|
||||
else None,
|
||||
max_per_row=row.get("max_per_row", 2),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/rows/{row_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
async def delete_row(
|
||||
row_id: str,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
"""Удалить строку."""
|
||||
try:
|
||||
await MenuLayoutService.delete_row(db, row_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
|
||||
|
||||
@router.post(
|
||||
"/buttons", response_model=MenuButtonConfig, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def add_custom_button(
|
||||
payload: AddCustomButtonRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuButtonConfig:
|
||||
"""Добавить кастомную кнопку (URL, MiniApp или callback)."""
|
||||
try:
|
||||
# Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен
|
||||
dynamic_text = payload.dynamic_text
|
||||
if not dynamic_text:
|
||||
from app.services.menu_layout.service import MenuLayoutService
|
||||
dynamic_text = MenuLayoutService._text_has_placeholders(payload.text)
|
||||
|
||||
button_config = {
|
||||
"type": payload.type.value,
|
||||
"text": payload.text,
|
||||
"icon": payload.icon,
|
||||
"action": payload.action,
|
||||
"visibility": payload.visibility.value,
|
||||
"conditions": payload.conditions.model_dump(exclude_none=True)
|
||||
if payload.conditions
|
||||
else None,
|
||||
"dynamic_text": dynamic_text,
|
||||
"description": payload.description,
|
||||
}
|
||||
button = await MenuLayoutService.add_custom_button(
|
||||
db, payload.id, button_config, payload.row_id
|
||||
)
|
||||
|
||||
return MenuButtonConfig(
|
||||
type=button["type"],
|
||||
builtin_id=button.get("builtin_id"),
|
||||
text=button.get("text", {}),
|
||||
icon=button.get("icon"),
|
||||
action=button.get("action", ""),
|
||||
enabled=button.get("enabled", True),
|
||||
visibility=button.get("visibility", "all"),
|
||||
conditions=ButtonConditions(**button["conditions"])
|
||||
if button.get("conditions")
|
||||
else None,
|
||||
dynamic_text=button.get("dynamic_text", False),
|
||||
open_mode=button.get("open_mode", "callback"),
|
||||
webapp_url=button.get("webapp_url"),
|
||||
description=button.get("description"),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
@router.delete("/buttons/{button_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
async def delete_custom_button(
|
||||
button_id: str,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
"""Удалить кастомную кнопку."""
|
||||
try:
|
||||
await MenuLayoutService.delete_custom_button(db, button_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/preview", response_model=MenuPreviewResponse)
|
||||
async def preview_menu(
|
||||
payload: MenuPreviewRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuPreviewResponse:
|
||||
"""Предпросмотр меню для указанного контекста пользователя."""
|
||||
context = MenuContext(
|
||||
language=payload.language,
|
||||
is_admin=payload.is_admin,
|
||||
is_moderator=payload.is_moderator,
|
||||
has_active_subscription=payload.has_active_subscription,
|
||||
subscription_is_active=payload.subscription_is_active,
|
||||
balance_kopeks=payload.balance_kopeks,
|
||||
)
|
||||
|
||||
preview_rows = await MenuLayoutService.preview_keyboard(db, context)
|
||||
|
||||
rows = []
|
||||
total_buttons = 0
|
||||
for row_data in preview_rows:
|
||||
buttons = [
|
||||
MenuPreviewButton(
|
||||
text=btn["text"],
|
||||
action=btn["action"],
|
||||
type=btn["type"],
|
||||
)
|
||||
for btn in row_data["buttons"]
|
||||
]
|
||||
total_buttons += len(buttons)
|
||||
rows.append(MenuPreviewRow(buttons=buttons))
|
||||
|
||||
return MenuPreviewResponse(rows=rows, total_buttons=total_buttons)
|
||||
|
||||
|
||||
# --- Эндпоинты для перемещения кнопок ---
|
||||
|
||||
|
||||
@router.post("/buttons/{button_id}/move-up", response_model=MoveButtonResponse)
|
||||
async def move_button_up(
|
||||
button_id: str,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MoveButtonResponse:
|
||||
"""Переместить кнопку вверх (в предыдущую строку или на позицию выше в текущей строке)."""
|
||||
try:
|
||||
result = await MenuLayoutService.move_button_up(db, button_id)
|
||||
return MoveButtonResponse(
|
||||
button_id=button_id,
|
||||
new_row_index=result.get("new_row_index"),
|
||||
position=result.get("new_position"),
|
||||
)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/buttons/{button_id}/move-down", response_model=MoveButtonResponse)
|
||||
async def move_button_down(
|
||||
button_id: str,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MoveButtonResponse:
|
||||
"""Переместить кнопку вниз (в следующую строку или на позицию ниже в текущей строке)."""
|
||||
try:
|
||||
result = await MenuLayoutService.move_button_down(db, button_id)
|
||||
return MoveButtonResponse(
|
||||
button_id=button_id,
|
||||
new_row_index=result.get("new_row_index"),
|
||||
position=result.get("new_position"),
|
||||
)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/buttons/{button_id}/move-to-row", response_model=MoveButtonResponse)
|
||||
async def move_button_to_row(
|
||||
button_id: str,
|
||||
payload: MoveButtonToRowRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MoveButtonResponse:
|
||||
"""Переместить кнопку в указанную строку."""
|
||||
try:
|
||||
result = await MenuLayoutService.move_button_to_row(
|
||||
db, button_id, payload.target_row_id, payload.position
|
||||
)
|
||||
return MoveButtonResponse(
|
||||
button_id=button_id,
|
||||
target_row_id=payload.target_row_id,
|
||||
position=result.get("new_position"),
|
||||
)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/rows/{row_id}/reorder-buttons", response_model=ReorderButtonsResponse)
|
||||
async def reorder_buttons_in_row(
|
||||
row_id: str,
|
||||
payload: ReorderButtonsInRowRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ReorderButtonsResponse:
|
||||
"""Изменить порядок кнопок в строке."""
|
||||
try:
|
||||
result = await MenuLayoutService.reorder_buttons_in_row(
|
||||
db, row_id, payload.ordered_button_ids
|
||||
)
|
||||
return ReorderButtonsResponse(
|
||||
row_id=row_id,
|
||||
buttons=result["buttons"],
|
||||
)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/buttons/swap", response_model=SwapButtonsResponse)
|
||||
async def swap_buttons(
|
||||
payload: SwapButtonsRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SwapButtonsResponse:
|
||||
"""Обменять местами две кнопки (даже из разных строк)."""
|
||||
try:
|
||||
result = await MenuLayoutService.swap_buttons(
|
||||
db, payload.button_id_1, payload.button_id_2
|
||||
)
|
||||
return SwapButtonsResponse(
|
||||
button_1=result["button_1"],
|
||||
button_2=result["button_2"],
|
||||
)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
|
||||
|
||||
# --- Новые эндпоинты ---
|
||||
|
||||
|
||||
@router.get("/available-callbacks", response_model=AvailableCallbacksResponse)
|
||||
async def list_available_callbacks(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> AvailableCallbacksResponse:
|
||||
"""Получить список всех доступных callback_data для создания кнопок."""
|
||||
callbacks = await MenuLayoutService.get_available_callbacks(db)
|
||||
|
||||
items = [
|
||||
AvailableCallback(
|
||||
callback_data=cb["callback_data"],
|
||||
name=cb["name"],
|
||||
description=cb.get("description"),
|
||||
category=cb["category"],
|
||||
default_text=cb.get("default_text"),
|
||||
default_icon=cb.get("default_icon"),
|
||||
requires_subscription=cb.get("requires_subscription", False),
|
||||
is_in_menu=cb.get("is_in_menu", False),
|
||||
)
|
||||
for cb in callbacks
|
||||
]
|
||||
|
||||
categories = list(set(cb["category"] for cb in callbacks))
|
||||
|
||||
return AvailableCallbacksResponse(
|
||||
items=items,
|
||||
total=len(items),
|
||||
categories=sorted(categories),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/placeholders", response_model=DynamicPlaceholdersResponse)
|
||||
async def list_dynamic_placeholders(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> DynamicPlaceholdersResponse:
|
||||
"""Получить список доступных динамических плейсхолдеров для текста кнопок."""
|
||||
placeholders = MenuLayoutService.get_dynamic_placeholders()
|
||||
|
||||
items = [
|
||||
DynamicPlaceholder(
|
||||
placeholder=p["placeholder"],
|
||||
description=p["description"],
|
||||
example=p["example"],
|
||||
category=p["category"],
|
||||
)
|
||||
for p in placeholders
|
||||
]
|
||||
|
||||
return DynamicPlaceholdersResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/export", response_model=MenuLayoutExportResponse)
|
||||
async def export_menu_layout(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutExportResponse:
|
||||
"""Экспортировать конфигурацию меню."""
|
||||
from datetime import datetime
|
||||
|
||||
export_data = await MenuLayoutService.export_config(db)
|
||||
|
||||
rows = []
|
||||
for row_data in export_data.get("rows", []):
|
||||
rows.append(
|
||||
MenuRowConfig(
|
||||
id=row_data["id"],
|
||||
buttons=row_data.get("buttons", []),
|
||||
conditions=ButtonConditions(**row_data["conditions"])
|
||||
if row_data.get("conditions")
|
||||
else None,
|
||||
max_per_row=row_data.get("max_per_row", 2),
|
||||
)
|
||||
)
|
||||
|
||||
buttons = {}
|
||||
for btn_id, btn_data in export_data.get("buttons", {}).items():
|
||||
buttons[btn_id] = MenuButtonConfig(
|
||||
type=btn_data["type"],
|
||||
builtin_id=btn_data.get("builtin_id"),
|
||||
text=btn_data.get("text", {}),
|
||||
icon=btn_data.get("icon"),
|
||||
action=btn_data.get("action", ""),
|
||||
enabled=btn_data.get("enabled", True),
|
||||
visibility=btn_data.get("visibility", "all"),
|
||||
conditions=ButtonConditions(**btn_data["conditions"])
|
||||
if btn_data.get("conditions")
|
||||
else None,
|
||||
dynamic_text=btn_data.get("dynamic_text", False),
|
||||
open_mode=btn_data.get("open_mode", "callback"),
|
||||
webapp_url=btn_data.get("webapp_url"),
|
||||
description=btn_data.get("description"),
|
||||
)
|
||||
|
||||
return MenuLayoutExportResponse(
|
||||
version=export_data.get("version", 1),
|
||||
rows=rows,
|
||||
buttons=buttons,
|
||||
exported_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import", response_model=MenuLayoutImportResponse)
|
||||
async def import_menu_layout(
|
||||
payload: MenuLayoutImportRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutImportResponse:
|
||||
"""Импортировать конфигурацию меню."""
|
||||
import_data = {
|
||||
"version": payload.version,
|
||||
"rows": [row.model_dump() for row in payload.rows],
|
||||
"buttons": {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()},
|
||||
}
|
||||
|
||||
result = await MenuLayoutService.import_config(db, import_data, payload.merge_mode)
|
||||
|
||||
return MenuLayoutImportResponse(
|
||||
success=result["success"],
|
||||
imported_rows=result["imported_rows"],
|
||||
imported_buttons=result["imported_buttons"],
|
||||
warnings=result["warnings"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/validate", response_model=MenuLayoutValidateResponse)
|
||||
async def validate_menu_layout(
|
||||
payload: MenuLayoutValidateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutValidateResponse:
|
||||
"""Валидировать конфигурацию меню без сохранения."""
|
||||
# Если данные не переданы, валидируем текущую конфигурацию
|
||||
if payload.rows is None and payload.buttons is None:
|
||||
config = await MenuLayoutService.get_config(db)
|
||||
else:
|
||||
config = {
|
||||
"rows": [row.model_dump() for row in payload.rows] if payload.rows else [],
|
||||
"buttons": {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()}
|
||||
if payload.buttons
|
||||
else {},
|
||||
}
|
||||
|
||||
result = MenuLayoutService.validate_config(config)
|
||||
|
||||
return MenuLayoutValidateResponse(
|
||||
is_valid=result["is_valid"],
|
||||
errors=[
|
||||
ValidationError(
|
||||
field=e["field"],
|
||||
message=e["message"],
|
||||
severity=e["severity"],
|
||||
)
|
||||
for e in result["errors"]
|
||||
],
|
||||
warnings=[
|
||||
ValidationError(
|
||||
field=w["field"],
|
||||
message=w["message"],
|
||||
severity=w["severity"],
|
||||
)
|
||||
for w in result["warnings"]
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# --- Эндпоинты истории изменений ---
|
||||
|
||||
|
||||
@router.get("/history", response_model=MenuLayoutHistoryResponse)
|
||||
async def get_menu_layout_history(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutHistoryResponse:
|
||||
"""Получить историю изменений меню."""
|
||||
entries = await MenuLayoutService.get_history(db, limit, offset)
|
||||
total = await MenuLayoutService.get_history_count(db)
|
||||
|
||||
return MenuLayoutHistoryResponse(
|
||||
items=[
|
||||
MenuLayoutHistoryEntry(
|
||||
id=entry["id"],
|
||||
created_at=entry["created_at"],
|
||||
action=entry["action"],
|
||||
changes_summary=entry["changes_summary"] or "",
|
||||
user_info=entry["user_info"],
|
||||
)
|
||||
for entry in entries
|
||||
],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history/{history_id}")
|
||||
async def get_history_entry(
|
||||
history_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Получить конкретную запись истории с полной конфигурацией."""
|
||||
entry = await MenuLayoutService.get_history_entry(db, history_id)
|
||||
if not entry:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, f"History entry {history_id} not found")
|
||||
|
||||
return {
|
||||
"id": entry["id"],
|
||||
"action": entry["action"],
|
||||
"changes_summary": entry["changes_summary"],
|
||||
"user_info": entry["user_info"],
|
||||
"created_at": entry["created_at"].isoformat() if entry["created_at"] else None,
|
||||
"config": entry["config"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/history/{history_id}/rollback", response_model=MenuLayoutResponse)
|
||||
async def rollback_to_history(
|
||||
history_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutResponse:
|
||||
"""Откатить конфигурацию к записи из истории."""
|
||||
try:
|
||||
config = await MenuLayoutService.rollback_to_history(db, history_id)
|
||||
updated_at = await MenuLayoutService.get_config_updated_at(db)
|
||||
return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e
|
||||
|
||||
|
||||
# --- Эндпоинты статистики кликов ---
|
||||
|
||||
|
||||
@router.get("/stats", response_model=MenuClickStatsResponse)
|
||||
async def get_menu_click_stats(
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuClickStatsResponse:
|
||||
"""Получить общую статистику кликов по всем кнопкам."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
stats = await MenuLayoutService.get_all_buttons_stats(db, days)
|
||||
total_clicks = await MenuLayoutService.get_total_clicks(db, days)
|
||||
|
||||
now = datetime.utcnow()
|
||||
period_start = now - timedelta(days=days)
|
||||
|
||||
return MenuClickStatsResponse(
|
||||
items=[
|
||||
ButtonClickStats(
|
||||
button_id=s["button_id"],
|
||||
clicks_total=s["clicks_total"],
|
||||
clicks_today=s.get("clicks_today", 0),
|
||||
clicks_week=s.get("clicks_week", 0),
|
||||
clicks_month=s.get("clicks_month", 0),
|
||||
unique_users=s["unique_users"],
|
||||
last_click_at=s["last_click_at"],
|
||||
)
|
||||
for s in stats
|
||||
],
|
||||
total_clicks=total_clicks,
|
||||
period_start=period_start,
|
||||
period_end=now,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/buttons/{button_id}", response_model=ButtonClickStatsResponse)
|
||||
async def get_button_click_stats(
|
||||
button_id: str,
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ButtonClickStatsResponse:
|
||||
"""Получить статистику кликов по конкретной кнопке."""
|
||||
stats = await MenuLayoutService.get_button_stats(db, button_id, days)
|
||||
clicks_by_day = await MenuLayoutService.get_button_clicks_by_day(db, button_id, days)
|
||||
|
||||
return ButtonClickStatsResponse(
|
||||
button_id=button_id,
|
||||
stats=ButtonClickStats(
|
||||
button_id=stats["button_id"],
|
||||
clicks_total=stats["clicks_total"],
|
||||
clicks_today=stats["clicks_today"],
|
||||
clicks_week=stats["clicks_week"],
|
||||
clicks_month=stats["clicks_month"],
|
||||
unique_users=stats["unique_users"],
|
||||
last_click_at=stats["last_click_at"],
|
||||
),
|
||||
clicks_by_day=clicks_by_day,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stats/log-click")
|
||||
async def log_button_click(
|
||||
button_id: str,
|
||||
user_id: Optional[int] = None,
|
||||
callback_data: Optional[str] = None,
|
||||
button_type: Optional[str] = None,
|
||||
button_text: Optional[str] = None,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Записать клик по кнопке (для внешней интеграции)."""
|
||||
await MenuLayoutService.log_button_click(
|
||||
db,
|
||||
button_id=button_id,
|
||||
user_id=user_id,
|
||||
callback_data=callback_data,
|
||||
button_type=button_type,
|
||||
button_text=button_text,
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/stats/by-type", response_model=ButtonTypeStatsResponse)
|
||||
async def get_stats_by_button_type(
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ButtonTypeStatsResponse:
|
||||
"""Получить статистику кликов по типам кнопок (builtin, callback, url, mini_app)."""
|
||||
try:
|
||||
stats = await MenuLayoutService.get_stats_by_button_type(db, days)
|
||||
total_clicks = sum(s["clicks_total"] for s in stats)
|
||||
|
||||
return ButtonTypeStatsResponse(
|
||||
items=[
|
||||
ButtonTypeStats(
|
||||
button_type=s["button_type"],
|
||||
clicks_total=s["clicks_total"],
|
||||
unique_users=s["unique_users"],
|
||||
)
|
||||
for s in stats
|
||||
],
|
||||
total_clicks=total_clicks,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stats by type: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/stats/by-hour", response_model=HourlyStatsResponse)
|
||||
async def get_clicks_by_hour(
|
||||
button_id: Optional[str] = None,
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> HourlyStatsResponse:
|
||||
"""Получить статистику кликов по часам дня (0-23)."""
|
||||
stats = await MenuLayoutService.get_clicks_by_hour(db, button_id, days)
|
||||
|
||||
return HourlyStatsResponse(
|
||||
items=[
|
||||
HourlyStats(hour=s["hour"], count=s["count"])
|
||||
for s in stats
|
||||
],
|
||||
button_id=button_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/by-weekday", response_model=WeekdayStatsResponse)
|
||||
async def get_clicks_by_weekday(
|
||||
button_id: Optional[str] = None,
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> WeekdayStatsResponse:
|
||||
"""Получить статистику кликов по дням недели."""
|
||||
stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id, days)
|
||||
|
||||
return WeekdayStatsResponse(
|
||||
items=[
|
||||
WeekdayStats(
|
||||
weekday=s["weekday"],
|
||||
weekday_name=s["weekday_name"],
|
||||
count=s["count"]
|
||||
)
|
||||
for s in stats
|
||||
],
|
||||
button_id=button_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/top-users", response_model=TopUsersResponse)
|
||||
async def get_top_users(
|
||||
button_id: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TopUsersResponse:
|
||||
"""Получить топ пользователей по количеству кликов."""
|
||||
try:
|
||||
stats = await MenuLayoutService.get_top_users(db, button_id, limit, days)
|
||||
|
||||
return TopUsersResponse(
|
||||
items=[
|
||||
TopUserStats(
|
||||
user_id=s["user_id"],
|
||||
clicks_count=s["clicks_count"],
|
||||
last_click_at=s["last_click_at"],
|
||||
)
|
||||
for s in stats
|
||||
],
|
||||
button_id=button_id,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting top users: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/stats/compare", response_model=PeriodComparisonResponse)
|
||||
async def get_period_comparison(
|
||||
button_id: Optional[str] = None,
|
||||
current_days: int = 7,
|
||||
previous_days: int = 7,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PeriodComparisonResponse:
|
||||
"""Сравнить статистику текущего и предыдущего периода."""
|
||||
try:
|
||||
comparison = await MenuLayoutService.get_period_comparison(
|
||||
db, button_id, current_days, previous_days
|
||||
)
|
||||
|
||||
logger.debug(f"Period comparison: button_id={button_id}, current_days={current_days}, previous_days={previous_days}, trend={comparison.get('change', {}).get('trend')}")
|
||||
|
||||
return PeriodComparisonResponse(
|
||||
current_period=comparison["current_period"],
|
||||
previous_period=comparison["previous_period"],
|
||||
change=comparison["change"],
|
||||
button_id=button_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting period comparison: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/stats/users/{user_id}/sequences", response_model=UserClickSequencesResponse)
|
||||
async def get_user_click_sequences(
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserClickSequencesResponse:
|
||||
"""Получить последовательности кликов пользователя."""
|
||||
try:
|
||||
sequences = await MenuLayoutService.get_user_click_sequences(db, user_id, limit)
|
||||
|
||||
logger.debug(f"User sequences: user_id={user_id}, limit={limit}, found={len(sequences)} sequences")
|
||||
|
||||
return UserClickSequencesResponse(
|
||||
user_id=user_id,
|
||||
items=[
|
||||
UserClickSequence(
|
||||
button_id=s["button_id"],
|
||||
button_text=s["button_text"],
|
||||
clicked_at=s["clicked_at"],
|
||||
)
|
||||
for s in sequences
|
||||
],
|
||||
total=len(sequences),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user sequences: user_id={user_id}, error={e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
@@ -14,6 +15,7 @@ from app.database.crud.user import (
|
||||
update_user,
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.services.partner_stats_service import PartnerStatsService
|
||||
from app.utils.user_utils import (
|
||||
get_detailed_referral_list,
|
||||
get_effective_referral_commission_percent,
|
||||
@@ -21,14 +23,34 @@ from app.utils.user_utils import (
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.partners import (
|
||||
ChangeData,
|
||||
DailyStats,
|
||||
DailyStatsResponse,
|
||||
EarningsByPeriod,
|
||||
GlobalPartnerStats,
|
||||
GlobalPartnerSummary,
|
||||
NewReferralsByPeriod,
|
||||
PartnerReferralCommissionUpdate,
|
||||
PartnerReferralItem,
|
||||
PartnerReferralList,
|
||||
PartnerReferralCommissionUpdate,
|
||||
PartnerReferrerDetail,
|
||||
PartnerReferrerItem,
|
||||
PartnerReferrerListResponse,
|
||||
PayoutsByPeriod,
|
||||
PeriodChange,
|
||||
PeriodComparisonResponse,
|
||||
PeriodData,
|
||||
ReferralsCountByPeriod,
|
||||
ReferrerDetailedStats,
|
||||
ReferrerSummary,
|
||||
TopReferralItem,
|
||||
TopReferralsResponse,
|
||||
TopReferrerItem,
|
||||
TopReferrersResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -197,3 +219,158 @@ async def update_referrer_commission(
|
||||
|
||||
stats = await get_user_referral_stats(db, user.id)
|
||||
return _serialize_referrer(user, stats)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# РАСШИРЕННАЯ СТАТИСТИКА ПАРТНЁРОВ
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/stats", response_model=GlobalPartnerStats)
|
||||
async def get_global_partner_stats(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> GlobalPartnerStats:
|
||||
"""Глобальная статистика партнёрской программы."""
|
||||
data = await PartnerStatsService.get_global_partner_stats(db, days)
|
||||
|
||||
return GlobalPartnerStats(
|
||||
summary=GlobalPartnerSummary(**data["summary"]),
|
||||
payouts=PayoutsByPeriod(**data["payouts"]),
|
||||
new_referrals=NewReferralsByPeriod(**data["new_referrals"]),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/daily", response_model=DailyStatsResponse)
|
||||
async def get_global_daily_stats(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> DailyStatsResponse:
|
||||
"""Глобальная статистика по дням."""
|
||||
data = await PartnerStatsService.get_global_daily_stats(db, days)
|
||||
|
||||
return DailyStatsResponse(
|
||||
items=[DailyStats(**item) for item in data],
|
||||
days=days,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/top-referrers", response_model=TopReferrersResponse)
|
||||
async def get_top_referrers(
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
days: Optional[int] = Query(None, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TopReferrersResponse:
|
||||
"""Топ рефереров по заработку."""
|
||||
data = await PartnerStatsService.get_top_referrers(db, limit, days)
|
||||
|
||||
return TopReferrersResponse(
|
||||
items=[TopReferrerItem(**item) for item in data],
|
||||
days=days,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/referrers/{user_id}/stats", response_model=ReferrerDetailedStats)
|
||||
async def get_referrer_detailed_stats(
|
||||
user_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ReferrerDetailedStats:
|
||||
"""Детальная статистика реферера."""
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
data = await PartnerStatsService.get_referrer_detailed_stats(db, user.id)
|
||||
|
||||
return ReferrerDetailedStats(
|
||||
user_id=data["user_id"],
|
||||
summary=ReferrerSummary(**data["summary"]),
|
||||
earnings=EarningsByPeriod(**data["earnings"]),
|
||||
referrals_count=ReferralsCountByPeriod(**data["referrals_count"]),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/referrers/{user_id}/stats/daily", response_model=DailyStatsResponse)
|
||||
async def get_referrer_daily_stats(
|
||||
user_id: int,
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> DailyStatsResponse:
|
||||
"""Статистика реферера по дням."""
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
data = await PartnerStatsService.get_referrer_daily_stats(db, user.id, days)
|
||||
|
||||
return DailyStatsResponse(
|
||||
items=[DailyStats(**item) for item in data],
|
||||
days=days,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/referrers/{user_id}/stats/top-referrals", response_model=TopReferralsResponse)
|
||||
async def get_referrer_top_referrals(
|
||||
user_id: int,
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TopReferralsResponse:
|
||||
"""Топ рефералов реферера по принесённому доходу."""
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
data = await PartnerStatsService.get_referrer_top_referrals(db, user.id, limit)
|
||||
|
||||
return TopReferralsResponse(
|
||||
items=[TopReferralItem(**item) for item in data],
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/referrers/{user_id}/stats/compare", response_model=PeriodComparisonResponse)
|
||||
async def get_referrer_period_comparison(
|
||||
user_id: int,
|
||||
current_days: int = Query(7, ge=1, le=365),
|
||||
previous_days: int = Query(7, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PeriodComparisonResponse:
|
||||
"""Сравнение периодов для реферера."""
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
data = await PartnerStatsService.get_referrer_period_comparison(
|
||||
db, user.id, current_days, previous_days
|
||||
)
|
||||
|
||||
return PeriodComparisonResponse(
|
||||
current_period=PeriodData(**data["current_period"]),
|
||||
previous_period=PeriodData(**data["previous_period"]),
|
||||
change=PeriodChange(
|
||||
referrals=ChangeData(**data["change"]["referrals"]),
|
||||
earnings=ChangeData(**data["change"]["earnings"]),
|
||||
),
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Security
|
||||
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
from ..dependencies import require_api_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/nalogo_receipts_enabled")
|
||||
async def get_nalogo_receipts_enabled(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> bool:
|
||||
return SupportSettingsService.is_nalogo_receipts_enabled()
|
||||
|
||||
|
||||
@router.put("/nalogo_receipts_enabled")
|
||||
async def set_nalogo_receipts_enabled(
|
||||
enabled: bool,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> bool:
|
||||
return SupportSettingsService.set_nalogo_receipts_enabled(enabled)
|
||||
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ContestTemplateResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
prize_days: int
|
||||
max_winners: int
|
||||
attempts_per_user: int
|
||||
times_per_day: int
|
||||
schedule_times: Optional[str] = None
|
||||
cooldown_hours: int
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
is_enabled: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ContestTemplateListResponse(BaseModel):
|
||||
items: List[ContestTemplateResponse]
|
||||
|
||||
|
||||
class ContestTemplateUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
prize_days: Optional[int] = Field(None, ge=1)
|
||||
max_winners: Optional[int] = Field(None, ge=1)
|
||||
attempts_per_user: Optional[int] = Field(None, ge=1)
|
||||
times_per_day: Optional[int] = Field(None, ge=1)
|
||||
schedule_times: Optional[str] = None
|
||||
cooldown_hours: Optional[int] = Field(None, ge=1)
|
||||
payload: Optional[Dict[str, Any]] = None
|
||||
is_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class StartRoundRequest(BaseModel):
|
||||
starts_at: Optional[datetime] = None
|
||||
ends_at: Optional[datetime] = None
|
||||
cooldown_hours: Optional[int] = Field(None, ge=1)
|
||||
payload: Optional[Dict[str, Any]] = None
|
||||
force: bool = False
|
||||
|
||||
|
||||
class ContestRoundResponse(BaseModel):
|
||||
id: int
|
||||
template_id: int
|
||||
template_slug: str
|
||||
template_name: Optional[str] = None
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
status: str
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
winners_count: int
|
||||
max_winners: int
|
||||
attempts_per_user: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ContestRoundListResponse(BaseModel):
|
||||
items: List[ContestRoundResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class ContestAttemptUser(BaseModel):
|
||||
id: int
|
||||
telegram_id: Optional[int] = None
|
||||
username: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
|
||||
|
||||
class ContestAttemptResponse(BaseModel):
|
||||
id: int
|
||||
round_id: int
|
||||
user: ContestAttemptUser
|
||||
answer: Optional[str] = None
|
||||
is_winner: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ContestAttemptListResponse(BaseModel):
|
||||
items: List[ContestAttemptResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class ReferralContestResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
prize_text: Optional[str] = None
|
||||
contest_type: str
|
||||
start_at: datetime
|
||||
end_at: datetime
|
||||
daily_summary_time: time
|
||||
daily_summary_times: Optional[str] = None
|
||||
timezone: str
|
||||
is_active: bool
|
||||
last_daily_summary_date: Optional[date] = None
|
||||
last_daily_summary_at: Optional[datetime] = None
|
||||
final_summary_sent: bool
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ReferralContestListResponse(BaseModel):
|
||||
items: List[ReferralContestResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class ReferralContestCreateRequest(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
prize_text: Optional[str] = None
|
||||
contest_type: str = Field("referral_paid", min_length=1)
|
||||
start_at: datetime
|
||||
end_at: datetime
|
||||
daily_summary_time: time = Field(default=time(hour=12))
|
||||
daily_summary_times: Optional[str] = Field(
|
||||
default=None, description="Список времён ЧЧ:ММ через запятую (например, 12:00,18:00)"
|
||||
)
|
||||
timezone: str = Field(default="UTC")
|
||||
is_active: bool = True
|
||||
created_by: Optional[int] = None
|
||||
|
||||
|
||||
class ReferralContestUpdateRequest(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
prize_text: Optional[str] = None
|
||||
contest_type: Optional[str] = Field(None, min_length=1)
|
||||
start_at: Optional[datetime] = None
|
||||
end_at: Optional[datetime] = None
|
||||
daily_summary_time: Optional[time] = None
|
||||
daily_summary_times: Optional[str] = Field(
|
||||
default=None, description="Список времён ЧЧ:ММ через запятую"
|
||||
)
|
||||
timezone: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
final_summary_sent: Optional[bool] = None
|
||||
created_by: Optional[int] = None
|
||||
|
||||
|
||||
class ReferralContestLeaderboardItem(BaseModel):
|
||||
user_id: int
|
||||
telegram_id: Optional[int] = None
|
||||
username: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
referrals_count: int
|
||||
total_amount_kopeks: int
|
||||
total_amount_rubles: float
|
||||
|
||||
|
||||
class ReferralContestDetailResponse(ReferralContestResponse):
|
||||
total_events: Optional[int] = None
|
||||
leaderboard: Optional[List[ReferralContestLeaderboardItem]] = None
|
||||
|
||||
|
||||
class ReferralContestEventUser(BaseModel):
|
||||
id: int
|
||||
telegram_id: Optional[int] = None
|
||||
username: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
|
||||
|
||||
class ReferralContestEventResponse(BaseModel):
|
||||
id: int
|
||||
contest_id: int
|
||||
referrer: ReferralContestEventUser
|
||||
referral: ReferralContestEventUser
|
||||
event_type: str
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
occurred_at: datetime
|
||||
|
||||
|
||||
class ReferralContestEventListResponse(BaseModel):
|
||||
items: List[ReferralContestEventResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class ReferralContestParticipant(BaseModel):
|
||||
referrer_id: int
|
||||
full_name: str
|
||||
total_referrals: int
|
||||
paid_referrals: int
|
||||
unpaid_referrals: int
|
||||
total_paid_amount: int
|
||||
|
||||
|
||||
class ReferralContestDetailedStatsResponse(BaseModel):
|
||||
total_participants: int
|
||||
total_invited: int
|
||||
total_paid_amount: int
|
||||
total_unpaid: int
|
||||
participants: List[ReferralContestParticipant]
|
||||
@@ -0,0 +1,691 @@
|
||||
"""Pydantic схемы для API конструктора меню."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ButtonType(str, Enum):
|
||||
"""Тип кнопки меню."""
|
||||
|
||||
BUILTIN = "builtin" # Встроенная кнопка с callback_data
|
||||
URL = "url" # Внешняя ссылка
|
||||
MINI_APP = "mini_app" # Telegram Mini App
|
||||
CALLBACK = "callback" # Кастомная кнопка с любым callback_data
|
||||
|
||||
|
||||
class ButtonVisibility(str, Enum):
|
||||
"""Видимость кнопки."""
|
||||
|
||||
ALL = "all" # Видна всем
|
||||
ADMINS = "admins" # Только админам
|
||||
MODERATORS = "moderators" # Только модераторам
|
||||
SUBSCRIBERS = "subscribers" # Только подписчикам
|
||||
|
||||
|
||||
class ButtonOpenMode(str, Enum):
|
||||
"""Режим открытия кнопки."""
|
||||
|
||||
CALLBACK = "callback" # Отправляет callback_data боту (по умолчанию)
|
||||
DIRECT = "direct" # Сразу открывает Mini App через WebAppInfo
|
||||
|
||||
|
||||
class ButtonConditions(BaseModel):
|
||||
"""Условия показа кнопки."""
|
||||
|
||||
# Существующие условия
|
||||
has_active_subscription: Optional[bool] = Field(
|
||||
default=None, description="Требуется активная подписка"
|
||||
)
|
||||
subscription_is_active: Optional[bool] = Field(
|
||||
default=None, description="Подписка должна быть активна (не приостановлена)"
|
||||
)
|
||||
has_traffic_limit: Optional[bool] = Field(
|
||||
default=None, description="Подписка с лимитом трафика"
|
||||
)
|
||||
is_admin: Optional[bool] = Field(default=None, description="Пользователь - админ")
|
||||
is_moderator: Optional[bool] = Field(
|
||||
default=None, description="Пользователь - модератор"
|
||||
)
|
||||
referral_enabled: Optional[bool] = Field(
|
||||
default=None, description="Реферальная программа включена"
|
||||
)
|
||||
contests_visible: Optional[bool] = Field(
|
||||
default=None, description="Конкурсы видимы"
|
||||
)
|
||||
support_enabled: Optional[bool] = Field(
|
||||
default=None, description="Поддержка включена"
|
||||
)
|
||||
language_selection_enabled: Optional[bool] = Field(
|
||||
default=None, description="Выбор языка включен"
|
||||
)
|
||||
happ_enabled: Optional[bool] = Field(
|
||||
default=None, description="Кнопка Happ включена"
|
||||
)
|
||||
simple_subscription_enabled: Optional[bool] = Field(
|
||||
default=None, description="Простая подписка включена"
|
||||
)
|
||||
show_trial: Optional[bool] = Field(
|
||||
default=None, description="Показать пробный период"
|
||||
)
|
||||
show_buy: Optional[bool] = Field(
|
||||
default=None, description="Показать кнопку покупки"
|
||||
)
|
||||
has_saved_cart: Optional[bool] = Field(
|
||||
default=None, description="Есть сохраненная корзина"
|
||||
)
|
||||
|
||||
# Расширенные условия
|
||||
min_balance_kopeks: Optional[int] = Field(
|
||||
default=None, ge=0, description="Минимальный баланс в копейках"
|
||||
)
|
||||
max_balance_kopeks: Optional[int] = Field(
|
||||
default=None, ge=0, description="Максимальный баланс в копейках"
|
||||
)
|
||||
min_registration_days: Optional[int] = Field(
|
||||
default=None, ge=0, description="Минимум дней с регистрации"
|
||||
)
|
||||
max_registration_days: Optional[int] = Field(
|
||||
default=None, ge=0, description="Максимум дней с регистрации"
|
||||
)
|
||||
min_referrals: Optional[int] = Field(
|
||||
default=None, ge=0, description="Минимальное количество рефералов"
|
||||
)
|
||||
has_referrals: Optional[bool] = Field(
|
||||
default=None, description="Есть рефералы"
|
||||
)
|
||||
promo_group_ids: Optional[List[str]] = Field(
|
||||
default=None, description="Список ID промо-групп (пользователь должен быть в одной из них)"
|
||||
)
|
||||
exclude_promo_group_ids: Optional[List[str]] = Field(
|
||||
default=None, description="Исключить пользователей из этих промо-групп"
|
||||
)
|
||||
has_subscription_days_left: Optional[int] = Field(
|
||||
default=None, ge=0, description="Минимум дней до окончания подписки"
|
||||
)
|
||||
max_subscription_days_left: Optional[int] = Field(
|
||||
default=None, ge=0, description="Максимум дней до окончания подписки"
|
||||
)
|
||||
is_trial_user: Optional[bool] = Field(
|
||||
default=None, description="Пользователь на пробном периоде"
|
||||
)
|
||||
has_autopay: Optional[bool] = Field(
|
||||
default=None, description="Автоплатёж включён"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MenuButtonConfig(BaseModel):
|
||||
"""Конфигурация отдельной кнопки."""
|
||||
|
||||
type: ButtonType = Field(..., description="Тип кнопки")
|
||||
builtin_id: Optional[str] = Field(
|
||||
default=None, description="ID встроенной кнопки (для type=builtin)"
|
||||
)
|
||||
text: Dict[str, str] = Field(
|
||||
..., description="Локализованные тексты кнопки: {lang_code: text}"
|
||||
)
|
||||
icon: Optional[str] = Field(
|
||||
default=None, max_length=10, description="Эмодзи/иконка кнопки (отдельно от текста)"
|
||||
)
|
||||
action: str = Field(
|
||||
..., description="callback_data или URL в зависимости от типа"
|
||||
)
|
||||
enabled: bool = Field(default=True, description="Кнопка активна")
|
||||
visibility: ButtonVisibility = Field(
|
||||
default=ButtonVisibility.ALL, description="Видимость кнопки"
|
||||
)
|
||||
conditions: Optional[ButtonConditions] = Field(
|
||||
default=None, description="Дополнительные условия показа"
|
||||
)
|
||||
dynamic_text: bool = Field(
|
||||
default=False, description="Текст содержит плейсхолдеры ({balance}, {username} и т.д.)"
|
||||
)
|
||||
open_mode: ButtonOpenMode = Field(
|
||||
default=ButtonOpenMode.CALLBACK,
|
||||
description="Режим открытия: callback (через бота) или direct (сразу Mini App)",
|
||||
)
|
||||
webapp_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="URL для Mini App при open_mode=direct",
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
default=None, max_length=200, description="Описание кнопки для админ-панели"
|
||||
)
|
||||
sort_order: Optional[int] = Field(
|
||||
default=None, description="Порядок сортировки (для отображения в админке)"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MenuRowConfig(BaseModel):
|
||||
"""Конфигурация строки меню."""
|
||||
|
||||
id: str = Field(..., min_length=1, max_length=50, description="Уникальный ID строки")
|
||||
buttons: List[str] = Field(
|
||||
..., description="Список ID кнопок в строке"
|
||||
)
|
||||
conditions: Optional[ButtonConditions] = Field(
|
||||
default=None, description="Условия показа всей строки"
|
||||
)
|
||||
max_per_row: int = Field(
|
||||
default=2, ge=1, le=4, description="Максимум кнопок в строке"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MenuLayoutConfig(BaseModel):
|
||||
"""Полная конфигурация меню."""
|
||||
|
||||
version: int = Field(default=1, description="Версия формата конфигурации")
|
||||
rows: List[MenuRowConfig] = Field(
|
||||
default_factory=list, description="Строки меню"
|
||||
)
|
||||
buttons: Dict[str, MenuButtonConfig] = Field(
|
||||
default_factory=dict, description="Конфигурации кнопок"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
# --- Response schemas ---
|
||||
|
||||
|
||||
class MenuLayoutResponse(BaseModel):
|
||||
"""Ответ с конфигурацией меню."""
|
||||
|
||||
version: int
|
||||
rows: List[MenuRowConfig]
|
||||
buttons: Dict[str, MenuButtonConfig]
|
||||
is_enabled: bool = Field(description="Включен ли конструктор меню")
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class BuiltinButtonInfo(BaseModel):
|
||||
"""Информация о встроенной кнопке."""
|
||||
|
||||
id: str = Field(description="Идентификатор кнопки")
|
||||
default_text: Dict[str, str] = Field(description="Текст по умолчанию")
|
||||
callback_data: str = Field(description="callback_data кнопки")
|
||||
default_conditions: Optional[ButtonConditions] = Field(
|
||||
default=None, description="Условия показа по умолчанию"
|
||||
)
|
||||
supports_dynamic_text: bool = Field(
|
||||
default=False, description="Поддерживает ли динамический текст"
|
||||
)
|
||||
supports_direct_open: bool = Field(
|
||||
default=False, description="Поддерживает ли прямое открытие Mini App"
|
||||
)
|
||||
|
||||
|
||||
class BuiltinButtonsListResponse(BaseModel):
|
||||
"""Список встроенных кнопок."""
|
||||
|
||||
items: List[BuiltinButtonInfo]
|
||||
total: int
|
||||
|
||||
|
||||
# --- Request schemas ---
|
||||
|
||||
|
||||
class MenuLayoutUpdateRequest(BaseModel):
|
||||
"""Запрос на обновление конфигурации меню."""
|
||||
|
||||
rows: Optional[List[MenuRowConfig]] = Field(
|
||||
default=None, description="Новая конфигурация строк"
|
||||
)
|
||||
buttons: Optional[Dict[str, MenuButtonConfig]] = Field(
|
||||
default=None, description="Новая конфигурация кнопок"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ButtonUpdateRequest(BaseModel):
|
||||
"""Запрос на обновление отдельной кнопки."""
|
||||
|
||||
text: Optional[Dict[str, str]] = Field(
|
||||
default=None, description="Новые локализованные тексты"
|
||||
)
|
||||
icon: Optional[str] = Field(
|
||||
default=None, max_length=10, description="Эмодзи/иконка кнопки"
|
||||
)
|
||||
enabled: Optional[bool] = Field(default=None, description="Включить/выключить")
|
||||
visibility: Optional[ButtonVisibility] = Field(
|
||||
default=None, description="Новая видимость"
|
||||
)
|
||||
conditions: Optional[ButtonConditions] = Field(
|
||||
default=None, description="Новые условия показа"
|
||||
)
|
||||
action: Optional[str] = Field(
|
||||
default=None, description="Новый action (callback_data или URL)"
|
||||
)
|
||||
dynamic_text: Optional[bool] = Field(
|
||||
default=None, description="Текст содержит плейсхолдеры"
|
||||
)
|
||||
open_mode: Optional[ButtonOpenMode] = Field(
|
||||
default=None, description="Режим открытия: callback или direct"
|
||||
)
|
||||
webapp_url: Optional[str] = Field(
|
||||
default=None, description="URL для Mini App при open_mode=direct"
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
default=None, max_length=200, description="Описание кнопки"
|
||||
)
|
||||
sort_order: Optional[int] = Field(
|
||||
default=None, description="Порядок сортировки"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RowsReorderRequest(BaseModel):
|
||||
"""Запрос на изменение порядка строк."""
|
||||
|
||||
ordered_ids: List[str] = Field(
|
||||
..., min_length=1, description="Список ID строк в новом порядке"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AddRowRequest(BaseModel):
|
||||
"""Запрос на добавление новой строки."""
|
||||
|
||||
id: str = Field(..., min_length=1, max_length=50, description="ID новой строки")
|
||||
buttons: List[str] = Field(..., description="Список ID кнопок")
|
||||
conditions: Optional[ButtonConditions] = Field(
|
||||
default=None, description="Условия показа"
|
||||
)
|
||||
max_per_row: int = Field(default=2, ge=1, le=4, description="Макс. кнопок в строке")
|
||||
position: Optional[int] = Field(
|
||||
default=None, ge=0, description="Позиция вставки (по умолчанию - в конец)"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AddCustomButtonRequest(BaseModel):
|
||||
"""Запрос на добавление кастомной кнопки."""
|
||||
|
||||
id: str = Field(
|
||||
..., min_length=1, max_length=50, description="ID кнопки (уникальный)"
|
||||
)
|
||||
type: ButtonType = Field(..., description="Тип кнопки (url, mini_app или callback)")
|
||||
text: Dict[str, str] = Field(..., description="Локализованные тексты")
|
||||
icon: Optional[str] = Field(
|
||||
default=None, max_length=10, description="Эмодзи/иконка кнопки"
|
||||
)
|
||||
action: str = Field(..., min_length=1, description="URL или callback_data")
|
||||
visibility: ButtonVisibility = Field(
|
||||
default=ButtonVisibility.ALL, description="Видимость"
|
||||
)
|
||||
conditions: Optional[ButtonConditions] = Field(
|
||||
default=None, description="Условия показа"
|
||||
)
|
||||
dynamic_text: bool = Field(
|
||||
default=False, description="Текст содержит плейсхолдеры"
|
||||
)
|
||||
row_id: Optional[str] = Field(
|
||||
default=None, description="ID строки для добавления кнопки"
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
default=None, max_length=200, description="Описание кнопки для админ-панели"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MenuPreviewRequest(BaseModel):
|
||||
"""Запрос на предпросмотр меню."""
|
||||
|
||||
language: str = Field(default="ru", description="Язык для предпросмотра")
|
||||
is_admin: bool = Field(default=False, description="Режим админа")
|
||||
is_moderator: bool = Field(default=False, description="Режим модератора")
|
||||
has_active_subscription: bool = Field(
|
||||
default=False, description="Есть активная подписка"
|
||||
)
|
||||
subscription_is_active: bool = Field(
|
||||
default=False, description="Подписка активна"
|
||||
)
|
||||
balance_kopeks: int = Field(default=0, ge=0, description="Баланс в копейках")
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MenuPreviewButton(BaseModel):
|
||||
"""Кнопка в предпросмотре."""
|
||||
|
||||
text: str
|
||||
action: str
|
||||
type: ButtonType
|
||||
|
||||
|
||||
class MenuPreviewRow(BaseModel):
|
||||
"""Строка в предпросмотре."""
|
||||
|
||||
buttons: List[MenuPreviewButton]
|
||||
|
||||
|
||||
class MenuPreviewResponse(BaseModel):
|
||||
"""Ответ с предпросмотром меню."""
|
||||
|
||||
rows: List[MenuPreviewRow]
|
||||
total_buttons: int
|
||||
|
||||
|
||||
# --- Схемы для перемещения кнопок ---
|
||||
|
||||
|
||||
class MoveButtonToRowRequest(BaseModel):
|
||||
"""Запрос на перемещение кнопки в другую строку."""
|
||||
|
||||
target_row_id: str = Field(..., description="ID целевой строки")
|
||||
position: Optional[int] = Field(
|
||||
default=None, ge=0, description="Позиция в строке (по умолчанию - в конец)"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ReorderButtonsInRowRequest(BaseModel):
|
||||
"""Запрос на изменение порядка кнопок в строке."""
|
||||
|
||||
ordered_button_ids: List[str] = Field(
|
||||
..., min_length=1, description="Список ID кнопок в новом порядке"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SwapButtonsRequest(BaseModel):
|
||||
"""Запрос на обмен местами двух кнопок."""
|
||||
|
||||
button_id_1: str = Field(..., description="ID первой кнопки")
|
||||
button_id_2: str = Field(..., description="ID второй кнопки")
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MoveButtonResponse(BaseModel):
|
||||
"""Ответ на перемещение кнопки."""
|
||||
|
||||
button_id: str
|
||||
new_row_index: Optional[int] = None
|
||||
target_row_id: Optional[str] = None
|
||||
position: Optional[int] = None
|
||||
|
||||
|
||||
class SwapButtonsResponse(BaseModel):
|
||||
"""Ответ на обмен кнопок."""
|
||||
|
||||
button_1: Dict[str, Any]
|
||||
button_2: Dict[str, Any]
|
||||
|
||||
|
||||
class ReorderButtonsResponse(BaseModel):
|
||||
"""Ответ на изменение порядка кнопок."""
|
||||
|
||||
row_id: str
|
||||
buttons: List[str]
|
||||
|
||||
|
||||
# --- Схемы для доступных callback_data ---
|
||||
|
||||
|
||||
class AvailableCallback(BaseModel):
|
||||
"""Информация о доступном callback_data."""
|
||||
|
||||
callback_data: str = Field(description="callback_data для кнопки")
|
||||
name: str = Field(description="Человекочитаемое название")
|
||||
description: Optional[str] = Field(default=None, description="Описание действия")
|
||||
category: str = Field(description="Категория: menu, subscription, balance, referral, support, etc.")
|
||||
default_text: Optional[Dict[str, str]] = Field(default=None, description="Текст по умолчанию")
|
||||
default_icon: Optional[str] = Field(default=None, description="Иконка по умолчанию")
|
||||
requires_subscription: bool = Field(default=False, description="Требует активную подписку")
|
||||
is_in_menu: bool = Field(default=False, description="Уже добавлена в меню")
|
||||
|
||||
|
||||
class AvailableCallbacksResponse(BaseModel):
|
||||
"""Список всех доступных callback_data."""
|
||||
|
||||
items: List[AvailableCallback]
|
||||
total: int
|
||||
categories: List[str] = Field(description="Список всех категорий")
|
||||
|
||||
|
||||
# --- Схемы для импорта/экспорта ---
|
||||
|
||||
|
||||
class MenuLayoutExportResponse(BaseModel):
|
||||
"""Экспорт конфигурации меню."""
|
||||
|
||||
version: int
|
||||
rows: List[MenuRowConfig]
|
||||
buttons: Dict[str, MenuButtonConfig]
|
||||
exported_at: datetime
|
||||
bot_version: Optional[str] = None
|
||||
|
||||
|
||||
class MenuLayoutImportRequest(BaseModel):
|
||||
"""Импорт конфигурации меню."""
|
||||
|
||||
version: int
|
||||
rows: List[MenuRowConfig]
|
||||
buttons: Dict[str, MenuButtonConfig]
|
||||
merge_mode: str = Field(
|
||||
default="replace",
|
||||
description="Режим импорта: replace (заменить всё), merge (объединить)"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MenuLayoutImportResponse(BaseModel):
|
||||
"""Результат импорта."""
|
||||
|
||||
success: bool
|
||||
imported_rows: int
|
||||
imported_buttons: int
|
||||
warnings: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# --- Схемы для истории изменений ---
|
||||
|
||||
|
||||
class MenuLayoutHistoryEntry(BaseModel):
|
||||
"""Запись в истории изменений."""
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
action: str = Field(description="Тип действия: update, reset, import")
|
||||
changes_summary: str = Field(description="Краткое описание изменений")
|
||||
user_info: Optional[str] = Field(default=None, description="Информация о пользователе")
|
||||
|
||||
|
||||
class MenuLayoutHistoryResponse(BaseModel):
|
||||
"""История изменений."""
|
||||
|
||||
items: List[MenuLayoutHistoryEntry]
|
||||
total: int
|
||||
|
||||
|
||||
class MenuLayoutRollbackRequest(BaseModel):
|
||||
"""Запрос на откат к предыдущей версии."""
|
||||
|
||||
history_id: int = Field(description="ID записи в истории для отката")
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
# --- Схемы для валидации ---
|
||||
|
||||
|
||||
class ValidationError(BaseModel):
|
||||
"""Ошибка валидации."""
|
||||
|
||||
field: str
|
||||
message: str
|
||||
severity: str = Field(description="error или warning")
|
||||
|
||||
|
||||
class MenuLayoutValidateRequest(BaseModel):
|
||||
"""Запрос на валидацию конфигурации."""
|
||||
|
||||
rows: Optional[List[MenuRowConfig]] = None
|
||||
buttons: Optional[Dict[str, MenuButtonConfig]] = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MenuLayoutValidateResponse(BaseModel):
|
||||
"""Результат валидации."""
|
||||
|
||||
is_valid: bool
|
||||
errors: List[ValidationError] = Field(default_factory=list)
|
||||
warnings: List[ValidationError] = Field(default_factory=list)
|
||||
|
||||
|
||||
# --- Схемы для статистики кликов ---
|
||||
|
||||
|
||||
class ButtonClickStats(BaseModel):
|
||||
"""Статистика кликов по кнопке."""
|
||||
|
||||
button_id: str
|
||||
clicks_total: int = Field(default=0)
|
||||
clicks_today: int = Field(default=0)
|
||||
clicks_week: int = Field(default=0)
|
||||
clicks_month: int = Field(default=0)
|
||||
last_click_at: Optional[datetime] = None
|
||||
unique_users: int = Field(default=0, description="Уникальные пользователи")
|
||||
|
||||
|
||||
class ButtonClickStatsResponse(BaseModel):
|
||||
"""Статистика кликов для одной кнопки."""
|
||||
|
||||
button_id: str
|
||||
stats: ButtonClickStats
|
||||
clicks_by_day: List[Dict[str, Any]] = Field(
|
||||
default_factory=list, description="Клики по дням [{date, count}]"
|
||||
)
|
||||
|
||||
|
||||
class MenuClickStatsResponse(BaseModel):
|
||||
"""Общая статистика кликов по всем кнопкам."""
|
||||
|
||||
items: List[ButtonClickStats]
|
||||
total_clicks: int
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
|
||||
|
||||
class ButtonTypeStats(BaseModel):
|
||||
"""Статистика по типу кнопки."""
|
||||
|
||||
button_type: str
|
||||
clicks_total: int
|
||||
unique_users: int
|
||||
|
||||
|
||||
class ButtonTypeStatsResponse(BaseModel):
|
||||
"""Статистика кликов по типам кнопок."""
|
||||
|
||||
items: List[ButtonTypeStats]
|
||||
total_clicks: int
|
||||
|
||||
|
||||
class HourlyStats(BaseModel):
|
||||
"""Статистика по часам."""
|
||||
|
||||
hour: int
|
||||
count: int
|
||||
|
||||
|
||||
class HourlyStatsResponse(BaseModel):
|
||||
"""Статистика кликов по часам дня."""
|
||||
|
||||
items: List[HourlyStats]
|
||||
button_id: Optional[str] = None
|
||||
|
||||
|
||||
class WeekdayStats(BaseModel):
|
||||
"""Статистика по дням недели."""
|
||||
|
||||
weekday: int
|
||||
weekday_name: str
|
||||
count: int
|
||||
|
||||
|
||||
class WeekdayStatsResponse(BaseModel):
|
||||
"""Статистика кликов по дням недели."""
|
||||
|
||||
items: List[WeekdayStats]
|
||||
button_id: Optional[str] = None
|
||||
|
||||
|
||||
class TopUserStats(BaseModel):
|
||||
"""Статистика пользователя."""
|
||||
|
||||
user_id: int
|
||||
clicks_count: int
|
||||
last_click_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TopUsersResponse(BaseModel):
|
||||
"""Топ пользователей по кликам."""
|
||||
|
||||
items: List[TopUserStats]
|
||||
button_id: Optional[str] = None
|
||||
limit: int
|
||||
|
||||
|
||||
class PeriodComparisonResponse(BaseModel):
|
||||
"""Сравнение периодов."""
|
||||
|
||||
current_period: Dict[str, Any]
|
||||
previous_period: Dict[str, Any]
|
||||
change: Dict[str, Any]
|
||||
button_id: Optional[str] = None
|
||||
|
||||
|
||||
class UserClickSequence(BaseModel):
|
||||
"""Последовательность кликов пользователя."""
|
||||
|
||||
button_id: str
|
||||
button_text: Optional[str] = None
|
||||
clicked_at: datetime
|
||||
|
||||
|
||||
class UserClickSequencesResponse(BaseModel):
|
||||
"""Последовательности кликов пользователя."""
|
||||
|
||||
user_id: int
|
||||
items: List[UserClickSequence]
|
||||
total: int
|
||||
|
||||
|
||||
# --- Схемы для плейсхолдеров ---
|
||||
|
||||
|
||||
class DynamicPlaceholder(BaseModel):
|
||||
"""Информация о динамическом плейсхолдере."""
|
||||
|
||||
placeholder: str = Field(description="Плейсхолдер, например {balance}")
|
||||
description: str = Field(description="Описание")
|
||||
example: str = Field(description="Пример значения")
|
||||
category: str = Field(description="Категория: user, subscription, referral, etc.")
|
||||
|
||||
|
||||
class DynamicPlaceholdersResponse(BaseModel):
|
||||
"""Список доступных плейсхолдеров."""
|
||||
|
||||
items: List[DynamicPlaceholder]
|
||||
total: int
|
||||
@@ -73,3 +73,159 @@ class PartnerReferralCommissionUpdate(BaseModel):
|
||||
le=100,
|
||||
description="Индивидуальный процент реферальной комиссии для пользователя",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# РАСШИРЕННАЯ СТАТИСТИКА ПАРТНЁРОВ
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class EarningsByPeriod(BaseModel):
|
||||
"""Заработки по периодам."""
|
||||
all_time_kopeks: int
|
||||
year_kopeks: int
|
||||
month_kopeks: int
|
||||
week_kopeks: int
|
||||
today_kopeks: int
|
||||
|
||||
|
||||
class ReferralsCountByPeriod(BaseModel):
|
||||
"""Количество рефералов по периодам."""
|
||||
all_time: int
|
||||
year: int
|
||||
month: int
|
||||
week: int
|
||||
today: int
|
||||
|
||||
|
||||
class ReferrerSummary(BaseModel):
|
||||
"""Сводка по рефереру."""
|
||||
total_referrals: int
|
||||
paid_referrals: int
|
||||
active_referrals: int
|
||||
conversion_to_paid_percent: float
|
||||
conversion_to_active_percent: float
|
||||
avg_earnings_per_referral_kopeks: float
|
||||
|
||||
|
||||
class ReferrerDetailedStats(BaseModel):
|
||||
"""Детальная статистика реферера."""
|
||||
user_id: int
|
||||
summary: ReferrerSummary
|
||||
earnings: EarningsByPeriod
|
||||
referrals_count: ReferralsCountByPeriod
|
||||
|
||||
|
||||
class DailyStats(BaseModel):
|
||||
"""Статистика за день."""
|
||||
date: str
|
||||
referrals_count: int
|
||||
earnings_kopeks: int
|
||||
|
||||
|
||||
class DailyStatsResponse(BaseModel):
|
||||
"""Ответ со статистикой по дням."""
|
||||
items: List[DailyStats]
|
||||
days: int
|
||||
user_id: Optional[int] = None
|
||||
|
||||
|
||||
class TopReferralItem(BaseModel):
|
||||
"""Топ реферал."""
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
full_name: str
|
||||
created_at: datetime
|
||||
has_made_first_topup: bool
|
||||
is_active: bool
|
||||
total_earnings_kopeks: int
|
||||
|
||||
|
||||
class TopReferralsResponse(BaseModel):
|
||||
"""Топ рефералов реферера."""
|
||||
items: List[TopReferralItem]
|
||||
user_id: int
|
||||
|
||||
|
||||
class PeriodData(BaseModel):
|
||||
"""Данные за период."""
|
||||
days: int
|
||||
start: str
|
||||
end: str
|
||||
referrals_count: int
|
||||
earnings_kopeks: int
|
||||
|
||||
|
||||
class ChangeData(BaseModel):
|
||||
"""Данные об изменении."""
|
||||
absolute: int
|
||||
percent: float
|
||||
trend: str # up, down, stable
|
||||
|
||||
|
||||
class PeriodChange(BaseModel):
|
||||
"""Изменения между периодами."""
|
||||
referrals: ChangeData
|
||||
earnings: ChangeData
|
||||
|
||||
|
||||
class PeriodComparisonResponse(BaseModel):
|
||||
"""Сравнение периодов."""
|
||||
current_period: PeriodData
|
||||
previous_period: PeriodData
|
||||
change: PeriodChange
|
||||
user_id: Optional[int] = None
|
||||
|
||||
|
||||
class GlobalPartnerSummary(BaseModel):
|
||||
"""Глобальная сводка партнёрской программы."""
|
||||
total_referrers: int
|
||||
total_referrals: int
|
||||
paid_referrals: int
|
||||
conversion_rate_percent: float
|
||||
avg_earnings_per_referral_kopeks: float
|
||||
|
||||
|
||||
class PayoutsByPeriod(BaseModel):
|
||||
"""Выплаты по периодам."""
|
||||
all_time_kopeks: int
|
||||
year_kopeks: int
|
||||
month_kopeks: int
|
||||
week_kopeks: int
|
||||
today_kopeks: int
|
||||
|
||||
|
||||
class NewReferralsByPeriod(BaseModel):
|
||||
"""Новые рефералы по периодам."""
|
||||
today: int
|
||||
week: int
|
||||
month: int
|
||||
|
||||
|
||||
class GlobalPartnerStats(BaseModel):
|
||||
"""Глобальная статистика партнёрской программы."""
|
||||
summary: GlobalPartnerSummary
|
||||
payouts: PayoutsByPeriod
|
||||
new_referrals: NewReferralsByPeriod
|
||||
|
||||
|
||||
class TopReferrerItem(BaseModel):
|
||||
"""Топ реферер."""
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
full_name: str
|
||||
referral_code: Optional[str] = None
|
||||
referrals_count: int
|
||||
total_earnings_kopeks: int
|
||||
|
||||
|
||||
class TopReferrersResponse(BaseModel):
|
||||
"""Топ рефереров."""
|
||||
items: List[TopReferrerItem]
|
||||
days: Optional[int] = None
|
||||
|
||||
@@ -74,12 +74,22 @@ class PromoOfferBroadcastRequest(PromoOfferCreateRequest):
|
||||
"all",
|
||||
"active",
|
||||
"trial",
|
||||
"trial_ending",
|
||||
"trial_expired",
|
||||
"no",
|
||||
"expiring",
|
||||
"expiring_subscribers",
|
||||
"expired",
|
||||
"expired_subscribers",
|
||||
"canceled_subscribers",
|
||||
"active_zero",
|
||||
"trial_zero",
|
||||
"zero",
|
||||
"autopay_failed",
|
||||
"low_balance",
|
||||
"inactive_30d",
|
||||
"inactive_60d",
|
||||
"inactive_90d",
|
||||
}
|
||||
_CUSTOM_TARGETS: ClassVar[set[str]] = {
|
||||
"today",
|
||||
@@ -93,6 +103,9 @@ class PromoOfferBroadcastRequest(PromoOfferCreateRequest):
|
||||
}
|
||||
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
|
||||
"no_sub": "no",
|
||||
"all_users": "all",
|
||||
"active_subscribers": "active",
|
||||
"trial_users": "trial",
|
||||
}
|
||||
|
||||
@validator("target")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Contests API (admin)
|
||||
|
||||
Админский REST API для конкурсов: ежедневные игры и реферальные конкурсы. Авторизация как в остальных методах — `X-API-Key` или Bearer.
|
||||
|
||||
## Ежедневные игры (`/contests/daily`)
|
||||
|
||||
- `GET /contests/daily/templates?enabled_only=false` — список шаблонов игр.
|
||||
- `GET /contests/daily/templates/{id}` — получить шаблон.
|
||||
- `PATCH /contests/daily/templates/{id}` — обновить поля: `name`, `description`, `prize_days`, `max_winners`, `attempts_per_user`, `times_per_day`, `schedule_times`, `cooldown_hours`, `payload` (dict), `is_enabled`.
|
||||
- `POST /contests/daily/templates/{id}/start-round` — запустить раунд вручную. Тело:
|
||||
```json
|
||||
{
|
||||
"starts_at": "2025-12-15T09:00:00+03:00",
|
||||
"ends_at": "2025-12-15T13:00:00+03:00",
|
||||
"cooldown_hours": 4,
|
||||
"payload": {"secret_idx": 3},
|
||||
"force": true
|
||||
}
|
||||
```
|
||||
Если `force=true`, активный раунд этого шаблона завершается перед созданием нового.
|
||||
- `GET /contests/daily/rounds?status_filter=active|finished|any&template_id&limit&offset` — список раундов.
|
||||
- `GET /contests/daily/rounds/{id}` — получить раунд.
|
||||
- `POST /contests/daily/rounds/{id}/finish` — завершить раунд.
|
||||
- `GET /contests/daily/rounds/{id}/attempts?winners_only=false&limit&offset` — попытки (с данными пользователя).
|
||||
|
||||
## Реферальные конкурсы (`/contests/referral`)
|
||||
|
||||
- `GET /contests/referral?contest_type&limit&offset` — список конкурсов.
|
||||
- `POST /contests/referral` — создать конкурс:
|
||||
```json
|
||||
{
|
||||
"title": "Рефералы декабрь",
|
||||
"contest_type": "referral_paid",
|
||||
"start_at": "2025-12-20T10:00:00+03:00",
|
||||
"end_at": "2025-12-27T10:00:00+03:00",
|
||||
"daily_summary_time": "12:00:00",
|
||||
"timezone": "Europe/Moscow",
|
||||
"prize_text": "🥇 5000 ₽, 🥈 3000 ₽",
|
||||
"is_active": true,
|
||||
"created_by": 1
|
||||
}
|
||||
```
|
||||
- `GET /contests/referral/{id}/detailed-stats` — детальная статистика конкурса с разбивкой по участникам (total_participants, total_invited, total_paid_amount, total_unpaid, participants).
|
||||
- `PATCH /contests/referral/{id}` — частичное обновление (те же поля + `final_summary_sent`, `is_active`, `daily_summary_times` с несколькими временами через запятую).
|
||||
- `POST /contests/referral/{id}/toggle?is_active=true|false` — быстро включить/остановить.
|
||||
- `GET /contests/referral/{id}/events?limit&offset` — события (referrer/referral, тип, суммы).
|
||||
- `DELETE /contests/referral/{id}` — удалить завершённый конкурс.
|
||||
|
||||
## Даты и часовые пояса
|
||||
|
||||
- Поля `datetime` можно передавать с TZ; сервер переводит в UTC (tzinfo убирается).
|
||||
- Если TZ не указан, используется `settings.TIMEZONE`.
|
||||
|
||||
## Тег в OpenAPI
|
||||
|
||||
Все методы сгруппированы под тегом `contests` в Swagger/Redoc после перезапуска web-api.
|
||||
@@ -0,0 +1,259 @@
|
||||
# Использование API статистики кнопок меню
|
||||
|
||||
## Обзор
|
||||
|
||||
Система статистики кликов по кнопкам меню позволяет отслеживать, какие кнопки чаще всего нажимают пользователи.
|
||||
|
||||
## API Эндпоинты
|
||||
|
||||
### 1. Логирование клика по кнопке
|
||||
|
||||
**POST** `/menu-layout/stats/log-click`
|
||||
|
||||
**Параметры:**
|
||||
- `button_id` (str) - ID кнопки
|
||||
- `user_id` (int, optional) - ID пользователя (telegram_id)
|
||||
- `callback_data` (str, optional) - callback_data кнопки
|
||||
- `button_type` (str, optional) - тип кнопки: `builtin`, `callback`, `url`, `mini_app`
|
||||
- `button_text` (str, optional) - текст кнопки на момент клика
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
await MenuLayoutService.log_button_click(
|
||||
db,
|
||||
button_id="menu_balance",
|
||||
user_id=123456789,
|
||||
callback_data="menu_balance",
|
||||
button_type="builtin",
|
||||
button_text="💰 Баланс"
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Получение статистики по конкретной кнопке
|
||||
|
||||
**GET** `/menu-layout/stats/buttons/{button_id}?days=30`
|
||||
|
||||
**Возвращает:**
|
||||
- `clicks_total` - общее количество кликов
|
||||
- `clicks_today` - клики сегодня
|
||||
- `clicks_week` - клики за неделю
|
||||
- `clicks_month` - клики за месяц
|
||||
- `unique_users` - уникальные пользователи
|
||||
- `last_click_at` - последний клик
|
||||
- `clicks_by_day` - клики по дням
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await MenuLayoutService.get_button_stats(db, "menu_balance", days=30)
|
||||
# Возвращает:
|
||||
# {
|
||||
# "button_id": "menu_balance",
|
||||
# "clicks_total": 150,
|
||||
# "clicks_today": 5,
|
||||
# "clicks_week": 25,
|
||||
# "clicks_month": 150,
|
||||
# "unique_users": 45,
|
||||
# "last_click_at": datetime(...)
|
||||
# }
|
||||
```
|
||||
|
||||
### 3. Получение общей статистики по всем кнопкам
|
||||
|
||||
**GET** `/menu-layout/stats?days=30`
|
||||
|
||||
**Возвращает:**
|
||||
- `items` - список статистики по каждой кнопке
|
||||
- `total_clicks` - общее количество кликов
|
||||
- `period_start` - начало периода
|
||||
- `period_end` - конец периода
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
all_stats = await MenuLayoutService.get_all_buttons_stats(db, days=30)
|
||||
total = await MenuLayoutService.get_total_clicks(db, days=30)
|
||||
```
|
||||
|
||||
## Автоматическое логирование
|
||||
|
||||
✅ **Логирование кликов происходит автоматически!**
|
||||
|
||||
Все клики по кнопкам автоматически логируются через `ButtonStatsMiddleware`. Middleware перехватывает все `CallbackQuery` события и логирует их в базу данных.
|
||||
|
||||
### Как это работает
|
||||
|
||||
1. При каждом клике по кнопке middleware автоматически:
|
||||
- Извлекает `callback_data` (используется как `button_id`)
|
||||
- Получает `user_id` из события
|
||||
- Определяет тип кнопки (`builtin`, `callback`, `url`)
|
||||
- Извлекает текст кнопки из клавиатуры (если доступен)
|
||||
- Логирует в базу данных асинхронно (не блокирует обработку)
|
||||
|
||||
2. Middleware активируется автоматически, если `MENU_LAYOUT_ENABLED=True`
|
||||
|
||||
3. Логирование происходит в фоновом режиме и не влияет на производительность
|
||||
|
||||
### Ручное логирование (опционально)
|
||||
|
||||
Если нужно логировать клики вручную (например, для внешних интеграций), можно использовать API:
|
||||
|
||||
```python
|
||||
# Через сервис
|
||||
await MenuLayoutService.log_button_click(
|
||||
db,
|
||||
button_id="custom_button",
|
||||
user_id=user_id,
|
||||
callback_data="custom_callback",
|
||||
button_type="callback",
|
||||
button_text="Кастомная кнопка"
|
||||
)
|
||||
|
||||
# Или через API эндпоинт
|
||||
POST /menu-layout/stats/log-click
|
||||
{
|
||||
"button_id": "custom_button",
|
||||
"user_id": 123456789,
|
||||
"callback_data": "custom_callback",
|
||||
"button_type": "callback",
|
||||
"button_text": "Кастомная кнопка"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Статистика по типам кнопок
|
||||
|
||||
**GET** `/menu-layout/stats/by-type?days=30`
|
||||
|
||||
**Возвращает:**
|
||||
- Статистику кликов по каждому типу кнопок (builtin, callback, url, mini_app)
|
||||
- Общее количество кликов по типам
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await MenuLayoutService.get_stats_by_button_type(db, days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"button_type": "builtin", "clicks_total": 500, "unique_users": 100},
|
||||
# {"button_type": "callback", "clicks_total": 200, "unique_users": 50},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 5. Статистика по часам дня
|
||||
|
||||
**GET** `/menu-layout/stats/by-hour?button_id=menu_balance&days=30`
|
||||
|
||||
**Параметры:**
|
||||
- `button_id` (optional) - ID кнопки для фильтрации
|
||||
- `days` (default: 30) - период в днях
|
||||
|
||||
**Возвращает:**
|
||||
- Распределение кликов по часам дня (0-23)
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await MenuLayoutService.get_clicks_by_hour(db, button_id="menu_balance", days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"hour": 9, "count": 50},
|
||||
# {"hour": 10, "count": 75},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 6. Статистика по дням недели
|
||||
|
||||
**GET** `/menu-layout/stats/by-weekday?button_id=menu_balance&days=30`
|
||||
|
||||
**Возвращает:**
|
||||
- Распределение кликов по дням недели (0=понедельник, 6=воскресенье)
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id="menu_balance", days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"weekday": 0, "weekday_name": "Понедельник", "count": 100},
|
||||
# {"weekday": 1, "weekday_name": "Вторник", "count": 120},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 7. Топ пользователей по кликам
|
||||
|
||||
**GET** `/menu-layout/stats/top-users?button_id=menu_balance&limit=10&days=30`
|
||||
|
||||
**Параметры:**
|
||||
- `button_id` (optional) - ID кнопки для фильтрации
|
||||
- `limit` (default: 10) - количество пользователей
|
||||
- `days` (default: 30) - период в днях
|
||||
|
||||
**Возвращает:**
|
||||
- Список пользователей с наибольшим количеством кликов
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
top_users = await MenuLayoutService.get_top_users(db, button_id="menu_balance", limit=10, days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"user_id": 123456789, "clicks_count": 50, "last_click_at": datetime(...)},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 8. Сравнение периодов
|
||||
|
||||
**GET** `/menu-layout/stats/compare?button_id=menu_balance¤t_days=7&previous_days=7`
|
||||
|
||||
**Параметры:**
|
||||
- `button_id` (optional) - ID кнопки для фильтрации
|
||||
- `current_days` (default: 7) - период текущего сравнения
|
||||
- `previous_days` (default: 7) - период предыдущего сравнения
|
||||
|
||||
**Возвращает:**
|
||||
- Сравнение текущего и предыдущего периода
|
||||
- Изменение в абсолютных числах и процентах
|
||||
- Тренд (up/down/stable)
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
comparison = await MenuLayoutService.get_period_comparison(
|
||||
db, button_id="menu_balance", current_days=7, previous_days=7
|
||||
)
|
||||
# Возвращает:
|
||||
# {
|
||||
# "current_period": {"clicks": 100, "days": 7, ...},
|
||||
# "previous_period": {"clicks": 80, "days": 7, ...},
|
||||
# "change": {"absolute": 20, "percent": 25.0, "trend": "up"}
|
||||
# }
|
||||
```
|
||||
|
||||
### 9. Последовательности кликов пользователя
|
||||
|
||||
**GET** `/menu-layout/stats/users/{user_id}/sequences?limit=50`
|
||||
|
||||
**Параметры:**
|
||||
- `user_id` (path) - ID пользователя
|
||||
- `limit` (default: 50) - максимальное количество записей
|
||||
|
||||
**Возвращает:**
|
||||
- Хронологическую последовательность кликов пользователя
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
sequences = await MenuLayoutService.get_user_click_sequences(db, user_id=123456789, limit=50)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"button_id": "menu_balance", "button_text": "💰 Баланс", "clicked_at": datetime(...)},
|
||||
# {"button_id": "menu_subscription", "button_text": "📊 Подписка", "clicked_at": datetime(...)},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
## Важные замечания
|
||||
|
||||
1. **Автоматическое логирование**: Все клики по кнопкам логируются автоматически через `ButtonStatsMiddleware`
|
||||
2. **Требуется авторизация**: API эндпоинты для получения статистики требуют токен авторизации (`require_api_token`)
|
||||
3. **button_id**: Используется `callback_data` кнопки как идентификатор
|
||||
4. **Производительность**: Логирование выполняется асинхронно в фоне и не блокирует обработку запросов
|
||||
5. **Активация**: Middleware работает только если `MENU_LAYOUT_ENABLED=True` в настройках
|
||||
6. **Временные зоны**: Все временные метрики используют локальное время сервера
|
||||
|
||||
@@ -32,6 +32,8 @@ from app.localization.loader import ensure_locale_templates
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.external_admin_service import ensure_external_admin_token
|
||||
from app.services.broadcast_service import broadcast_service
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
from app.services.contest_rotation_service import contest_rotation_service
|
||||
from app.utils.startup_timeline import StartupTimeline
|
||||
from app.utils.timezone import TimezoneAwareFormatter
|
||||
|
||||
@@ -174,6 +176,7 @@ async def main():
|
||||
admin_notification_service = AdminNotificationService(bot)
|
||||
version_service.bot = bot
|
||||
version_service.set_notification_service(admin_notification_service)
|
||||
referral_contest_service.set_bot(bot)
|
||||
stage.log(f"Репозиторий версий: {version_service.repo}")
|
||||
stage.log(f"Текущая версия: {version_service.current_version}")
|
||||
stage.success("Мониторинг, уведомления и рассылки подключены")
|
||||
@@ -211,6 +214,37 @@ async def main():
|
||||
stage.warning(f"Ошибка запуска сервиса отчетов: {e}")
|
||||
logger.error(f"❌ Ошибка запуска сервиса отчетов: {e}")
|
||||
|
||||
async with timeline.stage(
|
||||
"Реферальные конкурсы",
|
||||
"🏆",
|
||||
success_message="Сервис конкурсов готов",
|
||||
) as stage:
|
||||
try:
|
||||
await referral_contest_service.start()
|
||||
if referral_contest_service.is_running():
|
||||
stage.log("Автосводки по конкурсам запущены")
|
||||
else:
|
||||
stage.skip("Сервис конкурсов выключен настройками")
|
||||
except Exception as e:
|
||||
stage.warning(f"Ошибка запуска сервиса конкурсов: {e}")
|
||||
logger.error(f"❌ Ошибка запуска сервиса конкурсов: {e}")
|
||||
|
||||
async with timeline.stage(
|
||||
"Ротация игр",
|
||||
"🎲",
|
||||
success_message="Мини-игры готовы",
|
||||
) as stage:
|
||||
try:
|
||||
contest_rotation_service.set_bot(bot)
|
||||
await contest_rotation_service.start()
|
||||
if contest_rotation_service.is_running():
|
||||
stage.log("Ротационные игры запущены")
|
||||
else:
|
||||
stage.skip("Ротация игр выключена настройками")
|
||||
except Exception as e:
|
||||
stage.warning(f"Ошибка запуска ротации игр: {e}")
|
||||
logger.error(f"❌ Ошибка запуска ротации игр: {e}")
|
||||
|
||||
async with timeline.stage(
|
||||
"Автосинхронизация RemnaWave",
|
||||
"🔄",
|
||||
@@ -594,12 +628,24 @@ async def main():
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки сервиса отчетов: {e}")
|
||||
|
||||
logger.info("ℹ️ Остановка сервиса конкурсов...")
|
||||
try:
|
||||
await referral_contest_service.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки сервиса конкурсов: {e}")
|
||||
|
||||
logger.info("ℹ️ Остановка сервиса автосинхронизации RemnaWave...")
|
||||
try:
|
||||
await remnawave_sync_service.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки автосинхронизации RemnaWave: {e}")
|
||||
|
||||
logger.info("ℹ️ Остановка ротации игр...")
|
||||
try:
|
||||
await contest_rotation_service.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки ротации игр: {e}")
|
||||
|
||||
logger.info("ℹ️ Остановка сервиса бекапов...")
|
||||
try:
|
||||
await backup_service.stop_auto_backup()
|
||||
|
||||
+4
-1
@@ -17,7 +17,10 @@ uvicorn==0.32.1
|
||||
python-multipart==0.0.9
|
||||
|
||||
# YooKassa SDK
|
||||
yookassa==3.7.0
|
||||
yookassa==3.9.0
|
||||
|
||||
# NaloGO для чеков в налоговую
|
||||
nalogo
|
||||
|
||||
# Логирование и мониторинг
|
||||
structlog==23.2.0
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Тесты для MenuLayoutService."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
|
||||
from app.services.menu_layout.service import MenuLayoutService
|
||||
from app.services.menu_layout.context import MenuContext
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_build_button_connect_direct_mode_with_url():
|
||||
"""Тест: кнопка connect с open_mode=direct и валидным URL должна создавать WebAppInfo."""
|
||||
button_config = {
|
||||
"type": "builtin",
|
||||
"builtin_id": "connect",
|
||||
"text": {"ru": "🔗 Подключиться"},
|
||||
"action": "subscription_connect",
|
||||
"open_mode": "direct",
|
||||
"webapp_url": "https://example.com/miniapp",
|
||||
}
|
||||
|
||||
context = MenuContext(
|
||||
language="ru",
|
||||
has_active_subscription=True,
|
||||
subscription_is_active=True,
|
||||
)
|
||||
|
||||
texts = MagicMock()
|
||||
texts.t = lambda key, default: default
|
||||
|
||||
button = MenuLayoutService._build_button(button_config, context, texts)
|
||||
|
||||
assert button is not None
|
||||
assert isinstance(button, InlineKeyboardButton)
|
||||
assert button.web_app is not None
|
||||
assert button.web_app.url == "https://example.com/miniapp"
|
||||
assert button.callback_data is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_build_button_connect_direct_mode_with_subscription_url():
|
||||
"""Тест: кнопка connect с open_mode=direct должна получать URL из подписки."""
|
||||
button_config = {
|
||||
"type": "builtin",
|
||||
"builtin_id": "connect",
|
||||
"text": {"ru": "🔗 Подключиться"},
|
||||
"action": "subscription_connect",
|
||||
"open_mode": "direct",
|
||||
"webapp_url": None,
|
||||
}
|
||||
|
||||
# Мокаем подписку с URL
|
||||
mock_subscription = MagicMock()
|
||||
mock_subscription.subscription_url = "https://subscription.example.com/link"
|
||||
mock_subscription.subscription_crypto_link = None
|
||||
|
||||
context = MenuContext(
|
||||
language="ru",
|
||||
has_active_subscription=True,
|
||||
subscription_is_active=True,
|
||||
subscription=mock_subscription,
|
||||
)
|
||||
|
||||
texts = MagicMock()
|
||||
texts.t = lambda key, default: default
|
||||
|
||||
with patch('app.utils.subscription_utils.get_display_subscription_link') as mock_get_link:
|
||||
mock_get_link.return_value = "https://subscription.example.com/link"
|
||||
|
||||
button = MenuLayoutService._build_button(button_config, context, texts)
|
||||
|
||||
assert button is not None
|
||||
assert isinstance(button, InlineKeyboardButton)
|
||||
assert button.web_app is not None
|
||||
assert button.web_app.url == "https://subscription.example.com/link"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_build_button_connect_callback_mode():
|
||||
"""Тест: кнопка connect с open_mode=callback должна создавать callback кнопку."""
|
||||
button_config = {
|
||||
"type": "builtin",
|
||||
"builtin_id": "connect",
|
||||
"text": {"ru": "🔗 Подключиться"},
|
||||
"action": "subscription_connect",
|
||||
"open_mode": "callback",
|
||||
"webapp_url": None,
|
||||
}
|
||||
|
||||
context = MenuContext(
|
||||
language="ru",
|
||||
has_active_subscription=True,
|
||||
subscription_is_active=True,
|
||||
)
|
||||
|
||||
texts = MagicMock()
|
||||
texts.t = lambda key, default: default
|
||||
|
||||
button = MenuLayoutService._build_button(button_config, context, texts)
|
||||
|
||||
assert button is not None
|
||||
assert isinstance(button, InlineKeyboardButton)
|
||||
assert button.callback_data == "subscription_connect"
|
||||
assert button.web_app is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_build_button_connect_direct_mode_fallback_to_callback():
|
||||
"""Тест: кнопка connect с open_mode=direct без URL должна fallback на callback."""
|
||||
button_config = {
|
||||
"type": "builtin",
|
||||
"builtin_id": "connect",
|
||||
"text": {"ru": "🔗 Подключиться"},
|
||||
"action": "subscription_connect",
|
||||
"open_mode": "direct",
|
||||
"webapp_url": None,
|
||||
}
|
||||
|
||||
context = MenuContext(
|
||||
language="ru",
|
||||
has_active_subscription=True,
|
||||
subscription_is_active=True,
|
||||
subscription=None, # Нет подписки
|
||||
)
|
||||
|
||||
texts = MagicMock()
|
||||
texts.t = lambda key, default: default
|
||||
|
||||
with patch('app.services.menu_layout.service.settings') as mock_settings:
|
||||
mock_settings.MINIAPP_CUSTOM_URL = None
|
||||
|
||||
button = MenuLayoutService._build_button(button_config, context, texts)
|
||||
|
||||
assert button is not None
|
||||
assert isinstance(button, InlineKeyboardButton)
|
||||
# Должен fallback на callback_data, так как URL не найден
|
||||
assert button.callback_data == "subscription_connect"
|
||||
|
||||
Reference in New Issue
Block a user