mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-27 06:42:24 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
# --- Схемы для плейсхолдеров ---
|
||||
|
||||
|
||||
|
||||
@@ -118,6 +118,136 @@ POST /menu-layout/stats/log-click
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Статистика по типам кнопок
|
||||
|
||||
**GET** `/menu-layout/stats/by-type?days=30`
|
||||
|
||||
**Возвращает:**
|
||||
- Статистику кликов по каждому типу кнопок (builtin, callback, url, mini_app)
|
||||
- Общее количество кликов по типам
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await MenuLayoutService.get_stats_by_button_type(db, days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"button_type": "builtin", "clicks_total": 500, "unique_users": 100},
|
||||
# {"button_type": "callback", "clicks_total": 200, "unique_users": 50},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 5. Статистика по часам дня
|
||||
|
||||
**GET** `/menu-layout/stats/by-hour?button_id=menu_balance&days=30`
|
||||
|
||||
**Параметры:**
|
||||
- `button_id` (optional) - ID кнопки для фильтрации
|
||||
- `days` (default: 30) - период в днях
|
||||
|
||||
**Возвращает:**
|
||||
- Распределение кликов по часам дня (0-23)
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await MenuLayoutService.get_clicks_by_hour(db, button_id="menu_balance", days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"hour": 9, "count": 50},
|
||||
# {"hour": 10, "count": 75},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 6. Статистика по дням недели
|
||||
|
||||
**GET** `/menu-layout/stats/by-weekday?button_id=menu_balance&days=30`
|
||||
|
||||
**Возвращает:**
|
||||
- Распределение кликов по дням недели (0=понедельник, 6=воскресенье)
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id="menu_balance", days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"weekday": 0, "weekday_name": "Понедельник", "count": 100},
|
||||
# {"weekday": 1, "weekday_name": "Вторник", "count": 120},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 7. Топ пользователей по кликам
|
||||
|
||||
**GET** `/menu-layout/stats/top-users?button_id=menu_balance&limit=10&days=30`
|
||||
|
||||
**Параметры:**
|
||||
- `button_id` (optional) - ID кнопки для фильтрации
|
||||
- `limit` (default: 10) - количество пользователей
|
||||
- `days` (default: 30) - период в днях
|
||||
|
||||
**Возвращает:**
|
||||
- Список пользователей с наибольшим количеством кликов
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
top_users = await MenuLayoutService.get_top_users(db, button_id="menu_balance", limit=10, days=30)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"user_id": 123456789, "clicks_count": 50, "last_click_at": datetime(...)},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
### 8. Сравнение периодов
|
||||
|
||||
**GET** `/menu-layout/stats/compare?button_id=menu_balance¤t_days=7&previous_days=7`
|
||||
|
||||
**Параметры:**
|
||||
- `button_id` (optional) - ID кнопки для фильтрации
|
||||
- `current_days` (default: 7) - период текущего сравнения
|
||||
- `previous_days` (default: 7) - период предыдущего сравнения
|
||||
|
||||
**Возвращает:**
|
||||
- Сравнение текущего и предыдущего периода
|
||||
- Изменение в абсолютных числах и процентах
|
||||
- Тренд (up/down/stable)
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
comparison = await MenuLayoutService.get_period_comparison(
|
||||
db, button_id="menu_balance", current_days=7, previous_days=7
|
||||
)
|
||||
# Возвращает:
|
||||
# {
|
||||
# "current_period": {"clicks": 100, "days": 7, ...},
|
||||
# "previous_period": {"clicks": 80, "days": 7, ...},
|
||||
# "change": {"absolute": 20, "percent": 25.0, "trend": "up"}
|
||||
# }
|
||||
```
|
||||
|
||||
### 9. Последовательности кликов пользователя
|
||||
|
||||
**GET** `/menu-layout/stats/users/{user_id}/sequences?limit=50`
|
||||
|
||||
**Параметры:**
|
||||
- `user_id` (path) - ID пользователя
|
||||
- `limit` (default: 50) - максимальное количество записей
|
||||
|
||||
**Возвращает:**
|
||||
- Хронологическую последовательность кликов пользователя
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
sequences = await MenuLayoutService.get_user_click_sequences(db, user_id=123456789, limit=50)
|
||||
# Возвращает:
|
||||
# [
|
||||
# {"button_id": "menu_balance", "button_text": "💰 Баланс", "clicked_at": datetime(...)},
|
||||
# {"button_id": "menu_subscription", "button_text": "📊 Подписка", "clicked_at": datetime(...)},
|
||||
# ...
|
||||
# ]
|
||||
```
|
||||
|
||||
## Важные замечания
|
||||
|
||||
1. **Автоматическое логирование**: Все клики по кнопкам логируются автоматически через `ButtonStatsMiddleware`
|
||||
@@ -125,4 +255,5 @@ POST /menu-layout/stats/log-click
|
||||
3. **button_id**: Используется `callback_data` кнопки как идентификатор
|
||||
4. **Производительность**: Логирование выполняется асинхронно в фоне и не блокирует обработку запросов
|
||||
5. **Активация**: Middleware работает только если `MENU_LAYOUT_ENABLED=True` в настройках
|
||||
6. **Временные зоны**: Все временные метрики используют локальное время сервера
|
||||
|
||||
|
||||
Reference in New Issue
Block a user