Merge pull request #2175 from BEDOLAGA-DEV/buttons

Buttons
This commit is contained in:
PEDZEO
2025-12-21 05:02:45 +03:00
committed by GitHub
11 changed files with 1917 additions and 28 deletions

View File

@@ -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

View File

@@ -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}")

View File

@@ -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)

View File

@@ -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()
]

View 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)

View File

@@ -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)}")

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,
)

View File

@@ -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
# --- Схемы для плейсхолдеров ---

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

View File

@@ -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&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`
@@ -125,4 +255,5 @@ POST /menu-layout/stats/log-click
3. **button_id**: Используется `callback_data` кнопки как идентификатор
4. **Производительность**: Логирование выполняется асинхронно в фоне и не блокирует обработку запросов
5. **Активация**: Middleware работает только если `MENU_LAYOUT_ENABLED=True` в настройках
6. **Временные зоны**: Все временные метрики используют локальное время сервера

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"