Files
remnawave-bedolaga-telegram…/app/services/menu_layout/stats_service.py
2026-01-17 02:59:28 +03:00

517 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сервис статистики кликов по кнопкам меню."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import select, func, and_, desc, case, Integer
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import ButtonClickLog
def _utcnow() -> datetime:
"""Возвращает текущее UTC время как naive datetime (без timezone).
Замена deprecated datetime.utcnow().
"""
return datetime.now(timezone.utc).replace(tzinfo=None)
class MenuLayoutStatsService:
"""Сервис для сбора и анализа статистики кликов по кнопкам."""
@classmethod
def _is_sqlite(cls) -> bool:
"""Проверить, используется ли SQLite."""
return settings.is_sqlite()
@classmethod
def _get_hour_expr(cls, column):
"""Получить выражение для извлечения часа (совместимо с SQLite и PostgreSQL)."""
if cls._is_sqlite():
# SQLite: strftime('%H', column) возвращает строку
return func.cast(func.strftime('%H', column), Integer)
else:
# PostgreSQL: EXTRACT(hour FROM column)
return func.extract('hour', column)
@classmethod
def _get_weekday_expr(cls, column):
"""Получить выражение для дня недели (0=Пн, 6=Вс) совместимо с SQLite и PostgreSQL."""
if cls._is_sqlite():
# SQLite: strftime('%w', column) возвращает 0=воскресенье, 1-6=пн-сб
# Преобразуем: 0->6, 1->0, 2->1, ..., 6->5
dow = func.cast(func.strftime('%w', column), Integer)
return case(
(dow == 0, 6),
else_=dow - 1
)
else:
# PostgreSQL: EXTRACT(dow FROM column) возвращает 0=воскресенье, 1-6=пн-сб
dow = func.extract('dow', column)
return case(
(dow == 0, 6),
else_=dow - 1
)
@classmethod
async def log_button_click(
cls,
db: AsyncSession,
button_id: str,
user_id: Optional[int] = None,
callback_data: Optional[str] = None,
button_type: Optional[str] = None,
button_text: Optional[str] = None,
) -> Optional[ButtonClickLog]:
"""Записать клик по кнопке."""
try:
# Проверяем существование пользователя перед вставкой
# чтобы избежать ошибки foreign key в логах БД
actual_user_id = user_id
if user_id is not None:
from app.database.models import User
from sqlalchemy import select
result = await db.execute(
select(User.telegram_id).where(User.telegram_id == user_id).limit(1)
)
if result.scalar_one_or_none() is None:
actual_user_id = None # Пользователь не зарегистрирован
click_log = ButtonClickLog(
button_id=button_id,
user_id=actual_user_id,
callback_data=callback_data,
button_type=button_type,
button_text=button_text,
)
db.add(click_log)
await db.commit()
return click_log
except Exception:
await db.rollback()
return None
@classmethod
async def get_button_stats(
cls,
db: AsyncSession,
button_id: str,
days: int = 30,
) -> Dict[str, Any]:
"""Получить статистику кликов по конкретной кнопке."""
now = _utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=days)
# Общее количество кликов
total_result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(ButtonClickLog.button_id == button_id)
)
clicks_total = total_result.scalar() or 0
# Клики сегодня
today_result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(and_(
ButtonClickLog.button_id == button_id,
ButtonClickLog.clicked_at >= today_start
))
)
clicks_today = today_result.scalar() or 0
# Клики за неделю
week_result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(and_(
ButtonClickLog.button_id == button_id,
ButtonClickLog.clicked_at >= week_ago
))
)
clicks_week = week_result.scalar() or 0
# Клики за месяц
month_result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(and_(
ButtonClickLog.button_id == button_id,
ButtonClickLog.clicked_at >= month_ago
))
)
clicks_month = month_result.scalar() or 0
# Уникальные пользователи
unique_result = await db.execute(
select(func.count(func.distinct(ButtonClickLog.user_id)))
.where(ButtonClickLog.button_id == button_id)
)
unique_users = unique_result.scalar() or 0
# Последний клик
last_click_result = await db.execute(
select(ButtonClickLog.clicked_at)
.where(ButtonClickLog.button_id == button_id)
.order_by(desc(ButtonClickLog.clicked_at))
.limit(1)
)
last_click = last_click_result.scalar_one_or_none()
return {
"button_id": button_id,
"clicks_total": clicks_total,
"clicks_today": clicks_today,
"clicks_week": clicks_week,
"clicks_month": clicks_month,
"unique_users": unique_users,
"last_click_at": last_click,
}
@classmethod
async def get_button_clicks_by_day(
cls,
db: AsyncSession,
button_id: str,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить статистику кликов по дням."""
start_date = _utcnow() - timedelta(days=days)
# Группировка по дате
result = await db.execute(
select(
func.date(ButtonClickLog.clicked_at).label("date"),
func.count(ButtonClickLog.id).label("count")
)
.where(and_(
ButtonClickLog.button_id == button_id,
ButtonClickLog.clicked_at >= start_date
))
.group_by(func.date(ButtonClickLog.clicked_at))
.order_by(func.date(ButtonClickLog.clicked_at))
)
return [
{"date": str(row.date), "count": row.count}
for row in result.all()
]
@classmethod
async def get_all_buttons_stats(
cls,
db: AsyncSession,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить статистику по всем кнопкам."""
now = _utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=days)
# Для производительности используем один запрос с подзапросами через CASE
result = await db.execute(
select(
ButtonClickLog.button_id,
# Общее количество кликов (все клики без фильтра по датам)
func.count(ButtonClickLog.id).label("clicks_total"),
# Уникальные пользователи (все время)
func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"),
# Последний клик (все время)
func.max(ButtonClickLog.clicked_at).label("last_click_at"),
# Подсчет кликов за сегодня
func.sum(
case((ButtonClickLog.clicked_at >= today_start, 1), else_=0)
).label("clicks_today"),
# Подсчет кликов за неделю
func.sum(
case((ButtonClickLog.clicked_at >= week_ago, 1), else_=0)
).label("clicks_week"),
# Подсчет кликов за месяц
func.sum(
case((ButtonClickLog.clicked_at >= month_ago, 1), else_=0)
).label("clicks_month"),
)
.group_by(ButtonClickLog.button_id)
.order_by(desc(func.count(ButtonClickLog.id)))
)
return [
{
"button_id": row.button_id,
"clicks_total": row.clicks_total,
"clicks_today": row.clicks_today or 0,
"clicks_week": row.clicks_week or 0,
"clicks_month": row.clicks_month or 0,
"unique_users": row.unique_users,
"last_click_at": row.last_click_at,
}
for row in result.all()
]
@classmethod
async def get_total_clicks(
cls,
db: AsyncSession,
days: int = 30,
) -> int:
"""Получить общее количество кликов за период."""
start_date = _utcnow() - timedelta(days=days)
result = await db.execute(
select(func.count(ButtonClickLog.id))
.where(ButtonClickLog.clicked_at >= start_date)
)
return result.scalar() or 0
@classmethod
async def get_stats_by_button_type(
cls,
db: AsyncSession,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить статистику кликов по типам кнопок."""
start_date = _utcnow() - timedelta(days=days)
result = await db.execute(
select(
ButtonClickLog.button_type,
func.count(ButtonClickLog.id).label("clicks_total"),
func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"),
)
.where(and_(
ButtonClickLog.clicked_at >= start_date,
ButtonClickLog.button_type.isnot(None)
))
.group_by(ButtonClickLog.button_type)
.order_by(desc(func.count(ButtonClickLog.id)))
)
return [
{
"button_type": row.button_type or "unknown",
"clicks_total": row.clicks_total,
"unique_users": row.unique_users,
}
for row in result.all()
]
@classmethod
async def get_clicks_by_hour(
cls,
db: AsyncSession,
button_id: Optional[str] = None,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить статистику кликов по часам дня."""
start_date = _utcnow() - timedelta(days=days)
# Используем helper-метод для совместимости с SQLite и PostgreSQL
hour_expr = cls._get_hour_expr(ButtonClickLog.clicked_at).label("hour")
query = select(
hour_expr,
func.count(ButtonClickLog.id).label("count")
).where(ButtonClickLog.clicked_at >= start_date)
if button_id:
query = query.where(ButtonClickLog.button_id == button_id)
result = await db.execute(
query
.group_by(hour_expr)
.order_by(hour_expr)
)
# Создаем словарь для быстрого доступа по часу
stats_dict = {
int(row.hour): row.count
for row in result.all()
}
# Возвращаем все 24 часа, даже если count = 0
return [
{"hour": hour, "count": stats_dict.get(hour, 0)}
for hour in range(24)
]
@classmethod
async def get_clicks_by_weekday(
cls,
db: AsyncSession,
button_id: Optional[str] = None,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить статистику кликов по дням недели.
Возвращает 0=понедельник, 6=воскресенье.
Поддерживает как PostgreSQL, так и SQLite.
"""
start_date = _utcnow() - timedelta(days=days)
# Используем helper-метод для совместимости с SQLite и PostgreSQL
weekday_expr = cls._get_weekday_expr(ButtonClickLog.clicked_at).label("weekday")
query = select(
weekday_expr,
func.count(ButtonClickLog.id).label("count")
).where(ButtonClickLog.clicked_at >= start_date)
if button_id:
query = query.where(ButtonClickLog.button_id == button_id)
result = await db.execute(
query
.group_by(weekday_expr)
.order_by(weekday_expr)
)
weekday_names = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"]
# Создаем словарь для быстрого доступа по weekday
stats_dict = {
int(row.weekday): row.count
for row in result.all()
}
# Возвращаем все дни недели, даже если count = 0
return [
{
"weekday": weekday,
"weekday_name": weekday_names[weekday],
"count": stats_dict.get(weekday, 0)
}
for weekday in range(7)
]
@classmethod
async def get_top_users(
cls,
db: AsyncSession,
button_id: Optional[str] = None,
limit: int = 10,
days: int = 30,
) -> List[Dict[str, Any]]:
"""Получить топ пользователей по количеству кликов."""
start_date = _utcnow() - timedelta(days=days)
query = select(
ButtonClickLog.user_id,
func.count(ButtonClickLog.id).label("clicks_count"),
func.max(ButtonClickLog.clicked_at).label("last_click_at")
).where(and_(
ButtonClickLog.clicked_at >= start_date,
ButtonClickLog.user_id.isnot(None)
))
if button_id:
query = query.where(ButtonClickLog.button_id == button_id)
result = await db.execute(
query
.group_by(ButtonClickLog.user_id)
.order_by(desc(func.count(ButtonClickLog.id)))
.limit(limit)
)
return [
{
"user_id": row.user_id,
"clicks_count": row.clicks_count,
"last_click_at": row.last_click_at,
}
for row in result.all()
]
@classmethod
async def get_period_comparison(
cls,
db: AsyncSession,
button_id: Optional[str] = None,
current_days: int = 7,
previous_days: int = 7,
) -> Dict[str, Any]:
"""Сравнить статистику текущего и предыдущего периода."""
now = _utcnow()
current_start = now - timedelta(days=current_days)
previous_start = current_start - timedelta(days=previous_days)
previous_end = current_start
query_current = select(func.count(ButtonClickLog.id))
query_previous = select(func.count(ButtonClickLog.id))
if button_id:
query_current = query_current.where(ButtonClickLog.button_id == button_id)
query_previous = query_previous.where(ButtonClickLog.button_id == button_id)
query_current = query_current.where(
ButtonClickLog.clicked_at >= current_start
)
query_previous = query_previous.where(
and_(
ButtonClickLog.clicked_at >= previous_start,
ButtonClickLog.clicked_at < previous_end
)
)
current_result = await db.execute(query_current)
previous_result = await db.execute(query_previous)
current_count = current_result.scalar() or 0
previous_count = previous_result.scalar() or 0
change_percent = 0
if previous_count > 0:
change_percent = ((current_count - previous_count) / previous_count) * 100
return {
"current_period": {
"clicks": current_count,
"days": current_days,
"start": current_start,
"end": now,
},
"previous_period": {
"clicks": previous_count,
"days": previous_days,
"start": previous_start,
"end": previous_end,
},
"change": {
"absolute": current_count - previous_count,
"percent": round(change_percent, 2),
"trend": "up" if change_percent > 0 else "down" if change_percent < 0 else "stable",
},
}
@classmethod
async def get_click_sequences(
cls,
db: AsyncSession,
user_id: int,
limit: int = 50,
) -> List[Dict[str, Any]]:
"""Получить последовательности кликов пользователя."""
result = await db.execute(
select(
ButtonClickLog.button_id,
ButtonClickLog.button_text,
ButtonClickLog.clicked_at,
)
.where(ButtonClickLog.user_id == user_id)
.order_by(desc(ButtonClickLog.clicked_at))
.limit(limit)
)
return [
{
"button_id": row.button_id,
"button_text": row.button_text,
"clicked_at": row.clicked_at,
}
for row in result.all()
]