From d75fc0c60f46e2e5d3481a9fe76119ebbb7fb512 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Sat, 20 Dec 2025 03:32:34 +0300 Subject: [PATCH] Add statistics endpoints in MenuLayoutService for button clicks, including by type, hour, weekday, top users, period comparison, and user click sequences --- app/services/menu_layout/service.py | 63 ++++++ app/services/menu_layout/stats_service.py | 235 +++++++++++++++++++++- app/webapi/routes/menu_layout.py | 166 ++++++++++++++- app/webapi/schemas/menu_layout.py | 85 ++++++++ docs/menu_stats_api_usage.md | 131 ++++++++++++ 5 files changed, 675 insertions(+), 5 deletions(-) diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py index 601443c8..053dd5df 100644 --- a/app/services/menu_layout/service.py +++ b/app/services/menu_layout/service.py @@ -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) diff --git a/app/services/menu_layout/stats_service.py b/app/services/menu_layout/stats_service.py index dd0f0159..af26f314 100644 --- a/app/services/menu_layout/stats_service.py +++ b/app/services/menu_layout/stats_service.py @@ -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() + ] \ No newline at end of file diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index fb8d6513..f025d825 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -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), + ) \ No newline at end of file diff --git a/app/webapi/schemas/menu_layout.py b/app/webapi/schemas/menu_layout.py index 86e3823b..e8c89c3c 100644 --- a/app/webapi/schemas/menu_layout.py +++ b/app/webapi/schemas/menu_layout.py @@ -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 + + # --- Схемы для плейсхолдеров --- diff --git a/docs/menu_stats_api_usage.md b/docs/menu_stats_api_usage.md index 022f5fec..cc5125c6 100644 --- a/docs/menu_stats_api_usage.md +++ b/docs/menu_stats_api_usage.md @@ -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. **Временные зоны**: Все временные метрики используют локальное время сервера