mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-19 19:32:10 +00:00
@@ -133,7 +133,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
if settings.MENU_LAYOUT_ENABLED:
|
||||
button_stats_middleware = ButtonStatsMiddleware()
|
||||
dp.callback_query.middleware(button_stats_middleware)
|
||||
logger.info("📊 ButtonStatsMiddleware активирован - автоматическое логирование кликов включено")
|
||||
logger.info("📊 ButtonStatsMiddleware активирован")
|
||||
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
|
||||
@@ -62,7 +62,7 @@ class ButtonStatsMiddleware(BaseMiddleware):
|
||||
)
|
||||
except Exception as e:
|
||||
# Не прерываем обработку при ошибке логирования
|
||||
logger.debug(f"Ошибка логирования клика по кнопке: {e}")
|
||||
logger.error(f"Ошибка логирования клика по кнопке: {e}", exc_info=True)
|
||||
|
||||
# Продолжаем обработку
|
||||
return await handler(event, data)
|
||||
@@ -114,7 +114,7 @@ class ButtonStatsMiddleware(BaseMiddleware):
|
||||
button_text=button_text
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка логирования клика по кнопке {button_id}: {e}")
|
||||
logger.debug(f"Ошибка записи клика в БД {button_id}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания сессии БД для логирования клика: {e}")
|
||||
logger.debug(f"Ошибка создания сессии БД для логирования клика: {e}")
|
||||
|
||||
|
||||
@@ -270,10 +270,44 @@ class MenuLayoutService:
|
||||
config = config.copy()
|
||||
buttons = config.get("buttons", {})
|
||||
|
||||
# Улучшенное определение кнопки connect для разных форматов ID
|
||||
actual_button_id = button_id
|
||||
if button_id not in buttons:
|
||||
# Пробуем найти кнопку connect по разным форматам
|
||||
if "connect" in button_id.lower():
|
||||
# Проверяем разные варианты: connect, callback:connect и т.д.
|
||||
for key in buttons.keys():
|
||||
if key == "connect" or buttons[key].get("builtin_id") == "connect":
|
||||
actual_button_id = key
|
||||
logger.info(
|
||||
f"🔗 Найдена кнопка connect по ID '{button_id}' -> '{actual_button_id}'"
|
||||
)
|
||||
break
|
||||
else:
|
||||
# Если не нашли, пробуем найти по builtin_id
|
||||
for key, button in buttons.items():
|
||||
if button.get("builtin_id") == "connect" or "connect" in str(button.get("builtin_id", "")).lower():
|
||||
actual_button_id = key
|
||||
logger.info(
|
||||
f"🔗 Найдена кнопка connect по builtin_id '{button_id}' -> '{actual_button_id}'"
|
||||
)
|
||||
break
|
||||
else:
|
||||
raise KeyError(f"Button '{button_id}' not found")
|
||||
|
||||
button = buttons[button_id].copy()
|
||||
if actual_button_id not in buttons:
|
||||
raise KeyError(f"Button '{actual_button_id}' not found")
|
||||
|
||||
button = buttons[actual_button_id].copy()
|
||||
|
||||
# Логирование для отладки
|
||||
if "connect" in actual_button_id.lower() or button.get("builtin_id") == "connect":
|
||||
logger.info(
|
||||
f"🔗 Обновление кнопки connect (ID: {actual_button_id}): "
|
||||
f"open_mode={updates.get('open_mode')}, "
|
||||
f"action={updates.get('action')}, "
|
||||
f"webapp_url={updates.get('webapp_url')}"
|
||||
)
|
||||
|
||||
# Применяем обновления
|
||||
if "text" in updates and updates["text"] is not None:
|
||||
@@ -296,12 +330,16 @@ class MenuLayoutService:
|
||||
# Для URL/MiniApp/callback кнопок можно менять action
|
||||
if button.get("type") in ("url", "mini_app", "callback"):
|
||||
button["action"] = updates["action"]
|
||||
# Для builtin кнопок можно менять action, если open_mode == "direct"
|
||||
# Это позволяет указать URL Mini App в поле action для кнопки connect
|
||||
elif button.get("type") == "builtin" and updates.get("open_mode") == "direct":
|
||||
button["action"] = updates["action"]
|
||||
if "open_mode" in updates and updates["open_mode"] is not None:
|
||||
button["open_mode"] = updates["open_mode"]
|
||||
if "webapp_url" in updates:
|
||||
button["webapp_url"] = updates["webapp_url"]
|
||||
|
||||
buttons[button_id] = button
|
||||
buttons[actual_button_id] = button
|
||||
config["buttons"] = buttons
|
||||
|
||||
await cls.save_config(db, config)
|
||||
@@ -905,12 +943,29 @@ class MenuLayoutService:
|
||||
) -> Optional[InlineKeyboardButton]:
|
||||
"""Построить кнопку из конфигурации."""
|
||||
button_type = button_config.get("type", "builtin")
|
||||
button_id = button_config.get("builtin_id") or button_config.get("id", "")
|
||||
text_config = button_config.get("text", {})
|
||||
action = button_config.get("action", "")
|
||||
open_mode = button_config.get("open_mode", "callback")
|
||||
webapp_url = button_config.get("webapp_url")
|
||||
icon = button_config.get("icon", "")
|
||||
|
||||
# Логирование для отладки кнопки connect
|
||||
is_connect_button = (
|
||||
button_id == "connect" or
|
||||
"connect" in str(button_id).lower() or
|
||||
action == "subscription_connect" or
|
||||
"connect" in str(action).lower()
|
||||
)
|
||||
|
||||
if is_connect_button:
|
||||
logger.info(
|
||||
f"🔗 Построение кнопки connect: "
|
||||
f"button_id={button_id}, type={button_type}, "
|
||||
f"open_mode={open_mode}, action={action}, "
|
||||
f"webapp_url={webapp_url}"
|
||||
)
|
||||
|
||||
# Получаем текст
|
||||
text = cls._get_localized_text(text_config, context.language)
|
||||
if not text:
|
||||
@@ -936,13 +991,51 @@ class MenuLayoutService:
|
||||
return InlineKeyboardButton(text=text, callback_data=action)
|
||||
else:
|
||||
# builtin - проверяем open_mode
|
||||
if open_mode == "direct" and webapp_url:
|
||||
if open_mode == "direct":
|
||||
# Прямое открытие Mini App через WebAppInfo
|
||||
# Используем webapp_url, если указан, иначе action (если это URL)
|
||||
url = webapp_url or action
|
||||
|
||||
# Для кнопки connect: если URL не указан или это callback_data,
|
||||
# пытаемся получить URL из подписки пользователя
|
||||
if is_connect_button and (not url or not (url.startswith("http://") or url.startswith("https://"))):
|
||||
if context.subscription:
|
||||
from app.utils.subscription_utils import get_display_subscription_link
|
||||
subscription_url = get_display_subscription_link(context.subscription)
|
||||
if subscription_url:
|
||||
url = subscription_url
|
||||
logger.info(
|
||||
f"🔗 Кнопка connect: получен URL из подписки: {url[:50]}..."
|
||||
)
|
||||
# Если все еще нет URL, пробуем использовать настройку MINIAPP_CUSTOM_URL
|
||||
if not url or not (url.startswith("http://") or url.startswith("https://")):
|
||||
if settings.MINIAPP_CUSTOM_URL:
|
||||
url = settings.MINIAPP_CUSTOM_URL
|
||||
logger.info(
|
||||
f"🔗 Кнопка connect: использован MINIAPP_CUSTOM_URL: {url[:50]}..."
|
||||
)
|
||||
|
||||
# Проверяем, что это действительно URL
|
||||
if url and (url.startswith("http://") or url.startswith("https://")):
|
||||
logger.info(
|
||||
f"🔗 Кнопка connect: open_mode=direct, используем URL: {url[:50]}..."
|
||||
)
|
||||
return InlineKeyboardButton(
|
||||
text=text, web_app=types.WebAppInfo(url=webapp_url)
|
||||
text=text, web_app=types.WebAppInfo(url=url)
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"🔗 Кнопка connect: open_mode=direct, но URL не найден. "
|
||||
f"webapp_url={webapp_url}, action={action}, "
|
||||
f"subscription_url={'есть' if context.subscription else 'нет'}"
|
||||
)
|
||||
# Fallback на callback_data
|
||||
return InlineKeyboardButton(text=text, callback_data=action)
|
||||
else:
|
||||
# Стандартный callback_data
|
||||
logger.debug(
|
||||
f"Кнопка connect: open_mode={open_mode}, используем callback_data: {action}"
|
||||
)
|
||||
return InlineKeyboardButton(text=text, callback_data=action)
|
||||
|
||||
# --- Построение клавиатуры ---
|
||||
@@ -1165,3 +1258,66 @@ class MenuLayoutService:
|
||||
) -> int:
|
||||
"""Получить общее количество кликов за период."""
|
||||
return await MenuLayoutStatsService.get_total_clicks(db, days)
|
||||
|
||||
@classmethod
|
||||
async def get_stats_by_button_type(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику кликов по типам кнопок."""
|
||||
return await MenuLayoutStatsService.get_stats_by_button_type(db, days)
|
||||
|
||||
@classmethod
|
||||
async def get_clicks_by_hour(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: Optional[str] = None,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику кликов по часам дня."""
|
||||
return await MenuLayoutStatsService.get_clicks_by_hour(db, button_id, days)
|
||||
|
||||
@classmethod
|
||||
async def get_clicks_by_weekday(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: Optional[str] = None,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику кликов по дням недели."""
|
||||
return await MenuLayoutStatsService.get_clicks_by_weekday(db, button_id, days)
|
||||
|
||||
@classmethod
|
||||
async def get_top_users(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
button_id: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить топ пользователей по количеству кликов."""
|
||||
return await MenuLayoutStatsService.get_top_users(db, button_id, limit, days)
|
||||
|
||||
@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]:
|
||||
"""Сравнить статистику текущего и предыдущего периода."""
|
||||
return await MenuLayoutStatsService.get_period_comparison(
|
||||
db, button_id, current_days, previous_days
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_user_click_sequences(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить последовательности кликов пользователя."""
|
||||
return await MenuLayoutStatsService.get_click_sequences(db, user_id, limit)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select, func, and_, desc
|
||||
from sqlalchemy import select, func, and_, desc, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import ButtonClickLog
|
||||
@@ -148,16 +148,34 @@ class MenuLayoutStatsService:
|
||||
days: int = 30,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Получить статистику по всем кнопкам."""
|
||||
start_date = datetime.now() - timedelta(days=days)
|
||||
now = datetime.now()
|
||||
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.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"),
|
||||
)
|
||||
.where(ButtonClickLog.clicked_at >= start_date)
|
||||
.group_by(ButtonClickLog.button_id)
|
||||
.order_by(desc(func.count(ButtonClickLog.id)))
|
||||
)
|
||||
@@ -166,6 +184,9 @@ class MenuLayoutStatsService:
|
||||
{
|
||||
"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,
|
||||
}
|
||||
@@ -186,3 +207,251 @@ class MenuLayoutStatsService:
|
||||
.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 = datetime.now() - 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 = datetime.now() - timedelta(days=days)
|
||||
|
||||
query = select(
|
||||
func.extract('hour', ButtonClickLog.clicked_at).label("hour"),
|
||||
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(func.extract('hour', ButtonClickLog.clicked_at))
|
||||
.order_by(func.extract('hour', ButtonClickLog.clicked_at))
|
||||
)
|
||||
|
||||
# Создаем словарь для быстрого доступа по часу
|
||||
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]]:
|
||||
"""Получить статистику кликов по дням недели.
|
||||
|
||||
PostgreSQL DOW возвращает 0=воскресенье, 1=понедельник, ..., 6=суббота
|
||||
Преобразуем в 0=понедельник, 6=воскресенье для удобства.
|
||||
"""
|
||||
start_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
# Используем CASE для преобразования: 0 (воскресенье) -> 6, остальные -1
|
||||
weekday_expr = case(
|
||||
(func.extract('dow', ButtonClickLog.clicked_at) == 0, 6),
|
||||
else_=func.extract('dow', ButtonClickLog.clicked_at) - 1
|
||||
).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 = datetime.now() - 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 = datetime.now()
|
||||
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()
|
||||
]
|
||||
592
app/services/partner_stats_service.py
Normal file
592
app/services/partner_stats_service.py
Normal file
@@ -0,0 +1,592 @@
|
||||
"""Сервис расширенной статистики партнёров (рефереров)."""
|
||||
|
||||
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
|
||||
|
||||
# Заработки по периодам
|
||||
earnings_all_time = await cls._get_earnings_for_period(db, user_id, None)
|
||||
earnings_today = await cls._get_earnings_for_period(db, user_id, today_start)
|
||||
earnings_week = await cls._get_earnings_for_period(db, user_id, week_ago)
|
||||
earnings_month = await cls._get_earnings_for_period(db, user_id, month_ago)
|
||||
earnings_year = await cls._get_earnings_for_period(db, user_id, year_ago)
|
||||
|
||||
# Рефералы по периодам
|
||||
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]]:
|
||||
"""Получить топ рефералов по доходу для реферера."""
|
||||
# Получаем рефералов с их доходами
|
||||
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)
|
||||
)
|
||||
|
||||
referrals = []
|
||||
now = datetime.utcnow()
|
||||
for row in result.all():
|
||||
# Проверяем активность подписки
|
||||
sub_result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.user_id == row.id,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
is_active = sub_result.scalar_one_or_none() is not None
|
||||
|
||||
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": is_active,
|
||||
"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 == True,
|
||||
)
|
||||
)
|
||||
)
|
||||
paid_referrals_count = paid_referrals.scalar() or 0
|
||||
|
||||
# Всего выплачено
|
||||
total_paid = await cls._get_total_earnings(db, None)
|
||||
today_paid = await cls._get_total_earnings(db, today_start)
|
||||
week_paid = await cls._get_total_earnings(db, week_ago)
|
||||
month_paid = await cls._get_total_earnings(db, month_ago)
|
||||
year_paid = await cls._get_total_earnings(db, year_ago)
|
||||
|
||||
# Новые рефералы по периодам
|
||||
new_referrals_today = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id.isnot(None),
|
||||
User.created_at >= today_start,
|
||||
)
|
||||
)
|
||||
)
|
||||
new_referrals_week = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id.isnot(None),
|
||||
User.created_at >= week_ago,
|
||||
)
|
||||
)
|
||||
)
|
||||
new_referrals_month = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id.isnot(None),
|
||||
User.created_at >= month_ago,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Конверсия
|
||||
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.scalar() or 0,
|
||||
"week": new_referrals_week.scalar() or 0,
|
||||
"month": new_referrals_month.scalar() or 0,
|
||||
},
|
||||
}
|
||||
|
||||
@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]
|
||||
|
||||
# Получаем данные пользователей
|
||||
result = []
|
||||
for data in top_referrers:
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id == data["user_id"])
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
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)
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, Security, status
|
||||
@@ -13,6 +14,8 @@ from app.services.menu_layout_service import (
|
||||
MenuLayoutService,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.menu_layout import (
|
||||
AddCustomButtonRequest,
|
||||
@@ -24,9 +27,13 @@ from ..schemas.menu_layout import (
|
||||
ButtonClickStats,
|
||||
ButtonClickStatsResponse,
|
||||
ButtonConditions,
|
||||
ButtonTypeStats,
|
||||
ButtonTypeStatsResponse,
|
||||
ButtonUpdateRequest,
|
||||
DynamicPlaceholder,
|
||||
DynamicPlaceholdersResponse,
|
||||
HourlyStats,
|
||||
HourlyStatsResponse,
|
||||
MenuButtonConfig,
|
||||
MenuClickStatsResponse,
|
||||
MenuLayoutConfig,
|
||||
@@ -47,7 +54,14 @@ from ..schemas.menu_layout import (
|
||||
MenuRowConfig,
|
||||
MoveButtonResponse,
|
||||
MoveButtonToRowRequest,
|
||||
PeriodComparisonResponse,
|
||||
ReorderButtonsInRowRequest,
|
||||
TopUserStats,
|
||||
TopUsersResponse,
|
||||
UserClickSequence,
|
||||
UserClickSequencesResponse,
|
||||
WeekdayStats,
|
||||
WeekdayStatsResponse,
|
||||
ReorderButtonsResponse,
|
||||
RowsReorderRequest,
|
||||
SwapButtonsRequest,
|
||||
@@ -128,9 +142,15 @@ async def update_menu_layout(
|
||||
config["rows"] = [row.model_dump() for row in payload.rows]
|
||||
|
||||
if payload.buttons is not None:
|
||||
config["buttons"] = {
|
||||
btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()
|
||||
}
|
||||
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)
|
||||
@@ -296,6 +316,12 @@ async def add_custom_button(
|
||||
) -> 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,
|
||||
@@ -305,7 +331,7 @@ async def add_custom_button(
|
||||
"conditions": payload.conditions.model_dump(exclude_none=True)
|
||||
if payload.conditions
|
||||
else None,
|
||||
"dynamic_text": payload.dynamic_text,
|
||||
"dynamic_text": dynamic_text,
|
||||
"description": payload.description,
|
||||
}
|
||||
button = await MenuLayoutService.add_custom_button(
|
||||
@@ -748,6 +774,9 @@ async def get_menu_click_stats(
|
||||
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"],
|
||||
)
|
||||
@@ -805,3 +834,158 @@ async def log_button_click(
|
||||
button_text=button_text,
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/stats/by-type", response_model=ButtonTypeStatsResponse)
|
||||
async def get_stats_by_button_type(
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ButtonTypeStatsResponse:
|
||||
"""Получить статистику кликов по типам кнопок (builtin, callback, url, mini_app)."""
|
||||
try:
|
||||
stats = await MenuLayoutService.get_stats_by_button_type(db, days)
|
||||
total_clicks = sum(s["clicks_total"] for s in stats)
|
||||
|
||||
return ButtonTypeStatsResponse(
|
||||
items=[
|
||||
ButtonTypeStats(
|
||||
button_type=s["button_type"],
|
||||
clicks_total=s["clicks_total"],
|
||||
unique_users=s["unique_users"],
|
||||
)
|
||||
for s in stats
|
||||
],
|
||||
total_clicks=total_clicks,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stats by type: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/stats/by-hour", response_model=HourlyStatsResponse)
|
||||
async def get_clicks_by_hour(
|
||||
button_id: Optional[str] = None,
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> HourlyStatsResponse:
|
||||
"""Получить статистику кликов по часам дня (0-23)."""
|
||||
stats = await MenuLayoutService.get_clicks_by_hour(db, button_id, days)
|
||||
|
||||
return HourlyStatsResponse(
|
||||
items=[
|
||||
HourlyStats(hour=s["hour"], count=s["count"])
|
||||
for s in stats
|
||||
],
|
||||
button_id=button_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/by-weekday", response_model=WeekdayStatsResponse)
|
||||
async def get_clicks_by_weekday(
|
||||
button_id: Optional[str] = None,
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> WeekdayStatsResponse:
|
||||
"""Получить статистику кликов по дням недели."""
|
||||
stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id, days)
|
||||
|
||||
return WeekdayStatsResponse(
|
||||
items=[
|
||||
WeekdayStats(
|
||||
weekday=s["weekday"],
|
||||
weekday_name=s["weekday_name"],
|
||||
count=s["count"]
|
||||
)
|
||||
for s in stats
|
||||
],
|
||||
button_id=button_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/top-users", response_model=TopUsersResponse)
|
||||
async def get_top_users(
|
||||
button_id: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
days: int = 30,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TopUsersResponse:
|
||||
"""Получить топ пользователей по количеству кликов."""
|
||||
try:
|
||||
stats = await MenuLayoutService.get_top_users(db, button_id, limit, days)
|
||||
|
||||
return TopUsersResponse(
|
||||
items=[
|
||||
TopUserStats(
|
||||
user_id=s["user_id"],
|
||||
clicks_count=s["clicks_count"],
|
||||
last_click_at=s["last_click_at"],
|
||||
)
|
||||
for s in stats
|
||||
],
|
||||
button_id=button_id,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting top users: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/stats/compare", response_model=PeriodComparisonResponse)
|
||||
async def get_period_comparison(
|
||||
button_id: Optional[str] = None,
|
||||
current_days: int = 7,
|
||||
previous_days: int = 7,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PeriodComparisonResponse:
|
||||
"""Сравнить статистику текущего и предыдущего периода."""
|
||||
try:
|
||||
comparison = await MenuLayoutService.get_period_comparison(
|
||||
db, button_id, current_days, previous_days
|
||||
)
|
||||
|
||||
logger.debug(f"Period comparison: button_id={button_id}, current_days={current_days}, previous_days={previous_days}, trend={comparison.get('change', {}).get('trend')}")
|
||||
|
||||
return PeriodComparisonResponse(
|
||||
current_period=comparison["current_period"],
|
||||
previous_period=comparison["previous_period"],
|
||||
change=comparison["change"],
|
||||
button_id=button_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting period comparison: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/stats/users/{user_id}/sequences", response_model=UserClickSequencesResponse)
|
||||
async def get_user_click_sequences(
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserClickSequencesResponse:
|
||||
"""Получить последовательности кликов пользователя."""
|
||||
try:
|
||||
sequences = await MenuLayoutService.get_user_click_sequences(db, user_id, limit)
|
||||
|
||||
logger.debug(f"User sequences: user_id={user_id}, limit={limit}, found={len(sequences)} sequences")
|
||||
|
||||
return UserClickSequencesResponse(
|
||||
user_id=user_id,
|
||||
items=[
|
||||
UserClickSequence(
|
||||
button_id=s["button_id"],
|
||||
button_text=s["button_text"],
|
||||
clicked_at=s["clicked_at"],
|
||||
)
|
||||
for s in sequences
|
||||
],
|
||||
total=len(sequences),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user sequences: user_id={user_id}, error={e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
@@ -14,6 +15,7 @@ from app.database.crud.user import (
|
||||
update_user,
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.services.partner_stats_service import PartnerStatsService
|
||||
from app.utils.user_utils import (
|
||||
get_detailed_referral_list,
|
||||
get_effective_referral_commission_percent,
|
||||
@@ -21,14 +23,34 @@ from app.utils.user_utils import (
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.partners import (
|
||||
ChangeData,
|
||||
DailyStats,
|
||||
DailyStatsResponse,
|
||||
EarningsByPeriod,
|
||||
GlobalPartnerStats,
|
||||
GlobalPartnerSummary,
|
||||
NewReferralsByPeriod,
|
||||
PartnerReferralCommissionUpdate,
|
||||
PartnerReferralItem,
|
||||
PartnerReferralList,
|
||||
PartnerReferralCommissionUpdate,
|
||||
PartnerReferrerDetail,
|
||||
PartnerReferrerItem,
|
||||
PartnerReferrerListResponse,
|
||||
PayoutsByPeriod,
|
||||
PeriodChange,
|
||||
PeriodComparisonResponse,
|
||||
PeriodData,
|
||||
ReferralsCountByPeriod,
|
||||
ReferrerDetailedStats,
|
||||
ReferrerSummary,
|
||||
TopReferralItem,
|
||||
TopReferralsResponse,
|
||||
TopReferrerItem,
|
||||
TopReferrersResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -197,3 +219,158 @@ async def update_referrer_commission(
|
||||
|
||||
stats = await get_user_referral_stats(db, user.id)
|
||||
return _serialize_referrer(user, stats)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# РАСШИРЕННАЯ СТАТИСТИКА ПАРТНЁРОВ
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/stats", response_model=GlobalPartnerStats)
|
||||
async def get_global_partner_stats(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> GlobalPartnerStats:
|
||||
"""Глобальная статистика партнёрской программы."""
|
||||
data = await PartnerStatsService.get_global_partner_stats(db, days)
|
||||
|
||||
return GlobalPartnerStats(
|
||||
summary=GlobalPartnerSummary(**data["summary"]),
|
||||
payouts=PayoutsByPeriod(**data["payouts"]),
|
||||
new_referrals=NewReferralsByPeriod(**data["new_referrals"]),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/daily", response_model=DailyStatsResponse)
|
||||
async def get_global_daily_stats(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> DailyStatsResponse:
|
||||
"""Глобальная статистика по дням."""
|
||||
data = await PartnerStatsService.get_global_daily_stats(db, days)
|
||||
|
||||
return DailyStatsResponse(
|
||||
items=[DailyStats(**item) for item in data],
|
||||
days=days,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/top-referrers", response_model=TopReferrersResponse)
|
||||
async def get_top_referrers(
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
days: Optional[int] = Query(None, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TopReferrersResponse:
|
||||
"""Топ рефереров по заработку."""
|
||||
data = await PartnerStatsService.get_top_referrers(db, limit, days)
|
||||
|
||||
return TopReferrersResponse(
|
||||
items=[TopReferrerItem(**item) for item in data],
|
||||
days=days,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/referrers/{user_id}/stats", response_model=ReferrerDetailedStats)
|
||||
async def get_referrer_detailed_stats(
|
||||
user_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ReferrerDetailedStats:
|
||||
"""Детальная статистика реферера."""
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
data = await PartnerStatsService.get_referrer_detailed_stats(db, user.id)
|
||||
|
||||
return ReferrerDetailedStats(
|
||||
user_id=data["user_id"],
|
||||
summary=ReferrerSummary(**data["summary"]),
|
||||
earnings=EarningsByPeriod(**data["earnings"]),
|
||||
referrals_count=ReferralsCountByPeriod(**data["referrals_count"]),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/referrers/{user_id}/stats/daily", response_model=DailyStatsResponse)
|
||||
async def get_referrer_daily_stats(
|
||||
user_id: int,
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> DailyStatsResponse:
|
||||
"""Статистика реферера по дням."""
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
data = await PartnerStatsService.get_referrer_daily_stats(db, user.id, days)
|
||||
|
||||
return DailyStatsResponse(
|
||||
items=[DailyStats(**item) for item in data],
|
||||
days=days,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/referrers/{user_id}/stats/top-referrals", response_model=TopReferralsResponse)
|
||||
async def get_referrer_top_referrals(
|
||||
user_id: int,
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TopReferralsResponse:
|
||||
"""Топ рефералов реферера по принесённому доходу."""
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
data = await PartnerStatsService.get_referrer_top_referrals(db, user.id, limit)
|
||||
|
||||
return TopReferralsResponse(
|
||||
items=[TopReferralItem(**item) for item in data],
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/referrers/{user_id}/stats/compare", response_model=PeriodComparisonResponse)
|
||||
async def get_referrer_period_comparison(
|
||||
user_id: int,
|
||||
current_days: int = Query(7, ge=1, le=365),
|
||||
previous_days: int = Query(7, ge=1, le=365),
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PeriodComparisonResponse:
|
||||
"""Сравнение периодов для реферера."""
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
data = await PartnerStatsService.get_referrer_period_comparison(
|
||||
db, user.id, current_days, previous_days
|
||||
)
|
||||
|
||||
return PeriodComparisonResponse(
|
||||
current_period=PeriodData(**data["current_period"]),
|
||||
previous_period=PeriodData(**data["previous_period"]),
|
||||
change=PeriodChange(
|
||||
referrals=ChangeData(**data["change"]["referrals"]),
|
||||
earnings=ChangeData(**data["change"]["earnings"]),
|
||||
),
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
@@ -587,6 +587,91 @@ class MenuClickStatsResponse(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
# --- Схемы для плейсхолдеров ---
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -118,6 +118,136 @@ POST /menu-layout/stats/log-click
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Статистика по типам кнопок
|
||||
|
||||
**GET** `/menu-layout/stats/by-type?days=30`
|
||||
|
||||
**Возвращает:**
|
||||
- Статистику кликов по каждому типу кнопок (builtin, callback, url, mini_app)
|
||||
- Общее количество кликов по типам
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await MenuLayoutService.get_stats_by_button_type(db, days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"button_type": "builtin", "clicks_total": 500, "unique_users": 100},
|
||||
# {"button_type": "callback", "clicks_total": 200, "unique_users": 50},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 5. Статистика по часам дня
|
||||
|
||||
**GET** `/menu-layout/stats/by-hour?button_id=menu_balance&days=30`
|
||||
|
||||
**Параметры:**
|
||||
- `button_id` (optional) - ID кнопки для фильтрации
|
||||
- `days` (default: 30) - период в днях
|
||||
|
||||
**Возвращает:**
|
||||
- Распределение кликов по часам дня (0-23)
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await MenuLayoutService.get_clicks_by_hour(db, button_id="menu_balance", days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"hour": 9, "count": 50},
|
||||
# {"hour": 10, "count": 75},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 6. Статистика по дням недели
|
||||
|
||||
**GET** `/menu-layout/stats/by-weekday?button_id=menu_balance&days=30`
|
||||
|
||||
**Возвращает:**
|
||||
- Распределение кликов по дням недели (0=понедельник, 6=воскресенье)
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id="menu_balance", days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"weekday": 0, "weekday_name": "Понедельник", "count": 100},
|
||||
# {"weekday": 1, "weekday_name": "Вторник", "count": 120},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 7. Топ пользователей по кликам
|
||||
|
||||
**GET** `/menu-layout/stats/top-users?button_id=menu_balance&limit=10&days=30`
|
||||
|
||||
**Параметры:**
|
||||
- `button_id` (optional) - ID кнопки для фильтрации
|
||||
- `limit` (default: 10) - количество пользователей
|
||||
- `days` (default: 30) - период в днях
|
||||
|
||||
**Возвращает:**
|
||||
- Список пользователей с наибольшим количеством кликов
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
top_users = await MenuLayoutService.get_top_users(db, button_id="menu_balance", limit=10, days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"user_id": 123456789, "clicks_count": 50, "last_click_at": datetime(...)},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 8. Сравнение периодов
|
||||
|
||||
**GET** `/menu-layout/stats/compare?button_id=menu_balance¤t_days=7&previous_days=7`
|
||||
|
||||
**Параметры:**
|
||||
- `button_id` (optional) - ID кнопки для фильтрации
|
||||
- `current_days` (default: 7) - период текущего сравнения
|
||||
- `previous_days` (default: 7) - период предыдущего сравнения
|
||||
|
||||
**Возвращает:**
|
||||
- Сравнение текущего и предыдущего периода
|
||||
- Изменение в абсолютных числах и процентах
|
||||
- Тренд (up/down/stable)
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
comparison = await MenuLayoutService.get_period_comparison(
|
||||
db, button_id="menu_balance", current_days=7, previous_days=7
|
||||
)
|
||||
# Возвращает:
|
||||
# {
|
||||
# "current_period": {"clicks": 100, "days": 7, ...},
|
||||
# "previous_period": {"clicks": 80, "days": 7, ...},
|
||||
# "change": {"absolute": 20, "percent": 25.0, "trend": "up"}
|
||||
# }
|
||||
```
|
||||
|
||||
### 9. Последовательности кликов пользователя
|
||||
|
||||
**GET** `/menu-layout/stats/users/{user_id}/sequences?limit=50`
|
||||
|
||||
**Параметры:**
|
||||
- `user_id` (path) - ID пользователя
|
||||
- `limit` (default: 50) - максимальное количество записей
|
||||
|
||||
**Возвращает:**
|
||||
- Хронологическую последовательность кликов пользователя
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
sequences = await MenuLayoutService.get_user_click_sequences(db, user_id=123456789, limit=50)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"button_id": "menu_balance", "button_text": "💰 Баланс", "clicked_at": datetime(...)},
|
||||
# {"button_id": "menu_subscription", "button_text": "📊 Подписка", "clicked_at": datetime(...)},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
## Важные замечания
|
||||
|
||||
1. **Автоматическое логирование**: Все клики по кнопкам логируются автоматически через `ButtonStatsMiddleware`
|
||||
@@ -125,4 +255,5 @@ POST /menu-layout/stats/log-click
|
||||
3. **button_id**: Используется `callback_data` кнопки как идентификатор
|
||||
4. **Производительность**: Логирование выполняется асинхронно в фоне и не блокирует обработку запросов
|
||||
5. **Активация**: Middleware работает только если `MENU_LAYOUT_ENABLED=True` в настройках
|
||||
6. **Временные зоны**: Все временные метрики используют локальное время сервера
|
||||
|
||||
|
||||
139
tests/services/test_menu_layout_service.py
Normal file
139
tests/services/test_menu_layout_service.py
Normal 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"
|
||||
|
||||
Reference in New Issue
Block a user