Merge pull request #2177 from BEDOLAGA-DEV/dev5

Remnawave 2.4.0+ Api Update, Menu layout Api, Partners Stats Api, Yookassa 22%
This commit is contained in:
Egor
2025-12-21 08:13:51 +03:00
committed by GitHub
43 changed files with 7078 additions and 291 deletions
+1 -1
View File
@@ -226,7 +226,7 @@ YOOKASSA_VAT_CODE=1
# 1 - НДС не облагается
# 2 - НДС 0%
# 3 - НДС 10%
# 4 - НДС 20%
# 4 - НДС 20%/22%
# 5 - НДС 10/110
# 6 - НДС 20/120
+7
View File
@@ -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
@@ -127,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
+5
View File
@@ -175,6 +175,9 @@ class Settings(BaseSettings):
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 # Порог трафика в ГБ за сутки
@@ -183,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
+20 -2
View File
@@ -4,12 +4,14 @@ 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
from sqlalchemy.orm import selectinload, aliased
from app.database.models import (
ReferralContest,
ReferralContestEvent,
User,
Transaction,
TransactionType,
)
logger = logging.getLogger(__name__)
@@ -177,6 +179,10 @@ async def get_contest_leaderboard(
*,
limit: Optional[int] = None,
) -> Sequence[Tuple[User, int, int]]:
contest = await get_referral_contest(db, contest_id)
if not contest:
return []
query = (
select(
User,
@@ -191,7 +197,9 @@ async def get_contest_leaderboard(
if limit:
query = query.limit(limit)
result = await db.execute(query)
return result.all()
leaderboard = result.all()
return leaderboard
async def get_contest_participants(
@@ -248,6 +256,16 @@ async def get_contest_events_count(
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,
+45
View File
@@ -1782,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}>"
+141
View File
@@ -3715,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:
@@ -4316,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:
+254 -31
View File
@@ -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:
+104 -1
View File
@@ -436,7 +436,7 @@ async def show_leaderboard(
texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"),
]
for idx, (user, score, _) in enumerate(leaderboard, start=1):
lines.append(f"{idx}. {user.full_name}{score}")
lines.append(f"{idx}. {user.full_name} ({user.telegram_id}){score}")
await callback.message.edit_text(
"\n".join(lines),
@@ -659,6 +659,107 @@ async def finalize_contest_creation(message: types.Message, state: FSMContext, d
)
@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")
@@ -669,6 +770,8 @@ def register_handlers(dp: Dispatcher):
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"]))
+100
View File
@@ -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 []
+26 -42
View File
@@ -1,7 +1,6 @@
import logging
import random
from datetime import datetime
from typing import Optional
from datetime import datetime, timedelta
from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
@@ -11,8 +10,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.contest import (
get_active_rounds,
get_template_by_slug,
get_active_round_by_template,
get_attempt,
create_attempt,
increment_winner_count,
@@ -47,10 +44,6 @@ def _user_allowed(subscription) -> bool:
}
async def _with_session() -> AsyncSession:
return AsyncSessionLocal()
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)
@@ -64,12 +57,6 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=prize_days)
async def _ensure_round_for_template(template: ContestTemplate) -> Optional[ContestRound]:
async with AsyncSessionLocal() as db:
round_obj = await get_active_round_by_template(db, template.id)
return round_obj
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)
@@ -181,7 +168,6 @@ async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: Contest
texts = get_texts(db_user.language)
rows = round_obj.payload.get("rows", 3)
cols = round_obj.payload.get("cols", 3)
secret = random.randint(0, rows * cols - 1)
keyboard = []
for r in range(rows):
row_buttons = []
@@ -190,7 +176,7 @@ async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: Contest
row_buttons.append(
types.InlineKeyboardButton(
text="🎛",
callback_data=f"contest_pick_{round_obj.id}_{idx}_{secret}"
callback_data=f"contest_pick_{round_obj.id}_quest_{idx}"
)
)
keyboard.append(row_buttons)
@@ -205,11 +191,10 @@ async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: Contest
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)
secret = random.randint(0, total - 1)
keyboard = []
row = []
for i in range(total):
row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_{i}_{secret}"))
row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_locks_{i}"))
if len(row) == 5:
keyboard.append(row)
row = []
@@ -337,19 +322,31 @@ async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession):
is_winner = False
if tpl.slug == GAME_SERVER:
is_winner = pick == correct_flag
elif tpl.slug in {GAME_QUEST, GAME_LOCKS}:
elif tpl.slug == GAME_QUEST:
# Format: quest_{idx}
try:
idx_str, secret_str = pick.split("_", 1)
idx = int(idx_str)
secret = int(secret_str)
is_winner = idx == secret
except ValueError:
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:
@@ -393,6 +390,11 @@ async def handle_text_answer(message: types.Message, state: FSMContext, db_user,
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:
@@ -404,24 +406,6 @@ async def handle_text_answer(message: types.Message, state: FSMContext, db_user,
await state.clear()
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str:
from app.database.crud.subscription import get_subscription_by_user_id
logger = logging.getLogger(__name__)
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return "ошибка: подписка не найдена"
current_time = datetime.utcnow()
subscription.end_date = subscription.end_date + timedelta(days=prize_days)
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
logger.info(f"🎁 Продлена подписка пользователя {user_id} на {prize_days} дней за конкурс")
return f"подписка продлена на {prize_days} дней"
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_"))
+38 -29
View File
@@ -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,
)
@@ -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(),
)
@@ -1061,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(),
)
@@ -1216,10 +1225,10 @@ 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
@@ -1303,7 +1312,7 @@ async def handle_activate_button(
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
handle_back_to_menu,
F.data == "back_to_menu"
+185 -162
View File
@@ -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 ===")
+6
View File
@@ -616,6 +616,12 @@ def get_referral_contest_manage_keyboard(
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", "🕒 Итоги в день"),
+131 -1
View File
@@ -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 ""
+1
View File
@@ -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",
+1
View File
@@ -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": "🏠 В главное меню",
+2
View File
@@ -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": "🏠 В головне меню",
+1
View File
@@ -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":"🏠返回主菜单",
+208
View File
@@ -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}")
+22 -6
View File
@@ -3,6 +3,7 @@ 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
@@ -191,10 +192,15 @@ class ContestRotationService:
async def _tick(self) -> None:
async with AsyncSessionLocal() as db:
templates = await list_templates(db)
now_local = datetime.now().astimezone(timezone.utc)
# 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
)
@@ -207,18 +213,31 @@ class ContestRotationService:
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_local.replace(tzinfo=None),
ends_at=ends_at_local.replace(tzinfo=None),
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:
@@ -262,9 +281,6 @@ class ContestRotationService:
if not self.bot:
return
tz = settings.TIMEZONE or "UTC"
starts_txt = starts_at_local.strftime("%d.%m %H:%M")
ends_txt = ends_at_local.strftime("%d.%m %H:%M")
text = (
f"🎲 Стартует игра: <b>{tpl.name}</b>\n"
f"Приз: {tpl.prize_days} дн. подписки • Победителей: {tpl.max_winners}\n"
+36
View File
@@ -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",
]
+501
View File
@@ -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"},
]
+35
View File
@@ -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
+141
View File
@@ -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
+500
View File
@@ -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()
]
+40
View File
@@ -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",
]
+11 -6
View File
@@ -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)
@@ -937,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
@@ -1065,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>
+625
View File
@@ -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)
+51 -2
View File
@@ -277,7 +277,7 @@ class ReferralContestService:
f"Название: <b>{contest.title}</b>",
f"Статус: {'финал' if is_final else 'дневная сводка'}",
f"Временная зона: <code>{tz.key}</code>",
f"Всего участников: <b>{len(leaderboard)}</b>",
f"Всего рефералов: <b>{total_events}</b>",
"",
"Топ участников:",
]
@@ -285,7 +285,7 @@ class ReferralContestService:
if leaderboard:
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
name = user.full_name
lines.append(f"{idx}. {name}{score}")
lines.append(f"{idx}. {name} ({user.telegram_id}){score}")
else:
lines.append("Пока нет участников.")
@@ -390,6 +390,55 @@ class ReferralContestService:
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:
+2 -2
View File
@@ -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
)
+10
View File
@@ -15,6 +15,7 @@ from .routes import (
health,
main_menu_buttons,
media,
menu_layout,
miniapp,
partners,
polls,
@@ -56,6 +57,10 @@ OPENAPI_TAGS = [
"name": "main-menu",
"description": "Управление кнопками и сообщениями главного меню Telegram-бота.",
},
{
"name": "menu-layout",
"description": "API конструктор меню: управление расположением и настройками кнопок.",
},
{
"name": "welcome-texts",
"description": "Создание, редактирование и управление приветственными текстами.",
@@ -192,6 +197,11 @@ def create_web_api_app() -> FastAPI:
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",
+24 -3
View File
@@ -51,11 +51,13 @@ from app.webapi.schemas.contests import (
ContestTemplateUpdateRequest,
ReferralContestCreateRequest,
ReferralContestDetailResponse,
ReferralContestDetailedStatsResponse,
ReferralContestEventListResponse,
ReferralContestEventResponse,
ReferralContestEventUser,
ReferralContestLeaderboardItem,
ReferralContestListResponse,
ReferralContestParticipant,
ReferralContestResponse,
ReferralContestUpdateRequest,
StartRoundRequest,
@@ -262,7 +264,7 @@ async def update_daily_template(
if not tpl:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found")
update_fields = payload.dict(exclude_none=True)
update_fields = payload.model_dump(exclude_none=True)
if not update_fields:
return _serialize_template(tpl)
@@ -549,7 +551,7 @@ async def get_referral(
leaderboard = [_serialize_leaderboard_item(row) for row in leaderboard_rows]
return ReferralContestDetailResponse(
**_serialize_referral_contest(contest).dict(),
**_serialize_referral_contest(contest).model_dump(),
total_events=int(total_events),
leaderboard=leaderboard,
)
@@ -570,7 +572,7 @@ async def update_referral(
if not contest:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found")
fields = payload.dict(exclude_none=True)
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)
@@ -676,3 +678,22 @@ async def list_referral_events(
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)
+991
View File
@@ -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)}")
+178 -1
View File
@@ -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,
)
+17
View File
@@ -192,3 +192,20 @@ class ReferralContestEventListResponse(BaseModel):
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]
+691
View File
@@ -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
+156
View File
@@ -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
+13
View File
@@ -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")
+1 -1
View File
@@ -40,7 +40,7 @@
"created_by": 1
}
```
- `GET /contests/referral/{id}` — детали + `total_events`, `leaderboard`.
- `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, тип, суммы).
+259
View File
@@ -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&current_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. **Временные зоны**: Все временные метрики используют локальное время сервера
+1 -1
View File
@@ -17,7 +17,7 @@ uvicorn==0.32.1
python-multipart==0.0.9
# YooKassa SDK
yookassa==3.7.0
yookassa==3.9.0
# NaloGO для чеков в налоговую
nalogo
+139
View File
@@ -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"