Add statistics endpoints in MenuLayoutService for button clicks, including by type, hour, weekday, top users, period comparison, and user click sequences

This commit is contained in:
PEDZEO
2025-12-20 03:32:34 +03:00
parent 931b282f5b
commit d75fc0c60f
5 changed files with 675 additions and 5 deletions

View File

@@ -1258,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
@@ -186,3 +186,236 @@ 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))
)
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()
]

View File

@@ -24,9 +24,13 @@ from ..schemas.menu_layout import (
ButtonClickStats,
ButtonClickStatsResponse,
ButtonConditions,
ButtonTypeStats,
ButtonTypeStatsResponse,
ButtonUpdateRequest,
DynamicPlaceholder,
DynamicPlaceholdersResponse,
HourlyStats,
HourlyStatsResponse,
MenuButtonConfig,
MenuClickStatsResponse,
MenuLayoutConfig,
@@ -47,7 +51,14 @@ from ..schemas.menu_layout import (
MenuRowConfig,
MoveButtonResponse,
MoveButtonToRowRequest,
PeriodComparisonResponse,
ReorderButtonsInRowRequest,
TopUserStats,
TopUsersResponse,
UserClickSequence,
UserClickSequencesResponse,
WeekdayStats,
WeekdayStatsResponse,
ReorderButtonsResponse,
RowsReorderRequest,
SwapButtonsRequest,
@@ -128,9 +139,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 +313,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 +328,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(
@@ -805,3 +828,138 @@ 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)."""
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,
)
@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:
"""Получить топ пользователей по количеству кликов."""
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,
)
@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:
"""Сравнить статистику текущего и предыдущего периода."""
comparison = await MenuLayoutService.get_period_comparison(
db, button_id, current_days, previous_days
)
return PeriodComparisonResponse(
current_period=comparison["current_period"],
previous_period=comparison["previous_period"],
change=comparison["change"],
button_id=button_id,
)
@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:
"""Получить последовательности кликов пользователя."""
sequences = await MenuLayoutService.get_user_click_sequences(db, user_id, limit)
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),
)

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

@@ -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. **Временные зоны**: Все временные метрики используют локальное время сервера