Files
remnawave-bedolaga-telegram…/app/services/menu_layout/stats_service.py
T

421 lines
14 KiB
Python

"""Сервис статистики кликов по кнопкам меню."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from sqlalchemy import select, func, and_, desc, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import ButtonClickLog
class MenuLayoutStatsService:
"""Сервис для сбора и анализа статистики кликов по кнопкам."""
@classmethod
async def log_button_click(
cls,
db: AsyncSession,
button_id: str,
user_id: Optional[int] = None,
callback_data: Optional[str] = None,
button_type: Optional[str] = None,
button_text: Optional[str] = None,
) -> ButtonClickLog:
"""Записать клик по кнопке."""
click_log = ButtonClickLog(
button_id=button_id,
user_id=user_id,
callback_data=callback_data,
button_type=button_type,
button_text=button_text,
)
db.add(click_log)
await db.commit()
return click_log
@classmethod
async def get_button_stats(
cls,
db: AsyncSession,
button_id: str,
days: int = 30,
) -> Dict[str, Any]:
"""Получить статистику кликов по конкретной кнопке."""
now = 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)
# Общее количество кликов
total_result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(ButtonClickLog.button_id == button_id)
)
clicks_total = total_result.scalar() or 0
# Клики сегодня
today_result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(and_(
ButtonClickLog.button_id == button_id,
ButtonClickLog.clicked_at >= today_start
))
)
clicks_today = today_result.scalar() or 0
# Клики за неделю
week_result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(and_(
ButtonClickLog.button_id == button_id,
ButtonClickLog.clicked_at >= week_ago
))
)
clicks_week = week_result.scalar() or 0
# Клики за месяц
month_result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(and_(
ButtonClickLog.button_id == button_id,
ButtonClickLog.clicked_at >= month_ago
))
)
clicks_month = month_result.scalar() or 0
# Уникальные пользователи
unique_result = await db.execute(
select(func.count(func.distinct(ButtonClickLog.user_id)))
.where(ButtonClickLog.button_id == button_id)
)
unique_users = unique_result.scalar() or 0
# Последний клик
last_click_result = await db.execute(
select(ButtonClickLog.clicked_at)
.where(ButtonClickLog.button_id == button_id)
.order_by(desc(ButtonClickLog.clicked_at))
.limit(1)
)
last_click = last_click_result.scalar_one_or_none()
return {
"button_id": button_id,
"clicks_total": clicks_total,
"clicks_today": clicks_today,
"clicks_week": clicks_week,
"clicks_month": clicks_month,
"unique_users": unique_users,
"last_click_at": last_click,
}
@classmethod
async def get_button_clicks_by_day(
cls,
db: AsyncSession,
button_id: str,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить статистику кликов по дням."""
start_date = datetime.now() - timedelta(days=days)
# Группировка по дате
result = await db.execute(
select(
func.date(ButtonClickLog.clicked_at).label("date"),
func.count(ButtonClickLog.id).label("count")
)
.where(and_(
ButtonClickLog.button_id == button_id,
ButtonClickLog.clicked_at >= start_date
))
.group_by(func.date(ButtonClickLog.clicked_at))
.order_by(func.date(ButtonClickLog.clicked_at))
)
return [
{"date": str(row.date), "count": row.count}
for row in result.all()
]
@classmethod
async def get_all_buttons_stats(
cls,
db: AsyncSession,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить статистику по всем кнопкам."""
start_date = datetime.now() - timedelta(days=days)
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")
)
.where(ButtonClickLog.clicked_at >= start_date)
.group_by(ButtonClickLog.button_id)
.order_by(desc(func.count(ButtonClickLog.id)))
)
return [
{
"button_id": row.button_id,
"clicks_total": row.clicks_total,
"unique_users": row.unique_users,
"last_click_at": row.last_click_at,
}
for row in result.all()
]
@classmethod
async def get_total_clicks(
cls,
db: AsyncSession,
days: int = 30,
) -> int:
"""Получить общее количество кликов за период."""
start_date = datetime.now() - timedelta(days=days)
result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(ButtonClickLog.clicked_at >= start_date)
)
return result.scalar() or 0
@classmethod
async def get_stats_by_button_type(
cls,
db: AsyncSession,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить статистику кликов по типам кнопок."""
start_date = 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))
)
return [
{"hour": int(row.hour), "count": row.count}
for row in result.all()
]
@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 = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"]
return [
{
"weekday": int(row.weekday),
"weekday_name": weekday_names[int(row.weekday)],
"count": row.count
}
for row in result.all()
]
@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(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()
]