"""API эндпоинты для конструктора меню.""" from __future__ import annotations import logging from typing import Any, List, Optional from fastapi import APIRouter, Depends, HTTPException, Response, Security, status from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.services.menu_layout_service import ( MenuContext, MenuLayoutService, ) logger = logging.getLogger(__name__) from ..dependencies import get_db_session, require_api_token from ..schemas.menu_layout import ( AddCustomButtonRequest, AddRowRequest, AvailableCallback, AvailableCallbacksResponse, BuiltinButtonInfo, BuiltinButtonsListResponse, ButtonClickStats, ButtonClickStatsResponse, ButtonConditions, ButtonTypeStats, ButtonTypeStatsResponse, ButtonUpdateRequest, DynamicPlaceholder, DynamicPlaceholdersResponse, HourlyStats, HourlyStatsResponse, MenuButtonConfig, MenuClickStatsResponse, MenuLayoutConfig, MenuLayoutExportResponse, MenuLayoutHistoryEntry, MenuLayoutHistoryResponse, MenuLayoutImportRequest, MenuLayoutImportResponse, MenuLayoutResponse, MenuLayoutRollbackRequest, MenuLayoutUpdateRequest, MenuLayoutValidateRequest, MenuLayoutValidateResponse, MenuPreviewButton, MenuPreviewRequest, MenuPreviewResponse, MenuPreviewRow, MenuRowConfig, MoveButtonResponse, MoveButtonToRowRequest, PeriodComparisonResponse, ReorderButtonsInRowRequest, TopUserStats, TopUsersResponse, UserClickSequence, UserClickSequencesResponse, WeekdayStats, WeekdayStatsResponse, ReorderButtonsResponse, RowsReorderRequest, SwapButtonsRequest, SwapButtonsResponse, ValidationError, ) router = APIRouter() def _serialize_config(config: dict, is_enabled: bool, updated_at) -> MenuLayoutResponse: """Сериализовать конфигурацию в response.""" rows = [] for row_data in config.get("rows", []): rows.append( MenuRowConfig( id=row_data["id"], buttons=row_data.get("buttons", []), conditions=ButtonConditions(**row_data["conditions"]) if row_data.get("conditions") else None, max_per_row=row_data.get("max_per_row", 2), ) ) buttons = {} for btn_id, btn_data in config.get("buttons", {}).items(): buttons[btn_id] = MenuButtonConfig( type=btn_data["type"], builtin_id=btn_data.get("builtin_id"), text=btn_data.get("text", {}), icon=btn_data.get("icon"), action=btn_data.get("action", ""), enabled=btn_data.get("enabled", True), visibility=btn_data.get("visibility", "all"), conditions=ButtonConditions(**btn_data["conditions"]) if btn_data.get("conditions") else None, dynamic_text=btn_data.get("dynamic_text", False), open_mode=btn_data.get("open_mode", "callback"), webapp_url=btn_data.get("webapp_url"), description=btn_data.get("description"), sort_order=btn_data.get("sort_order"), ) return MenuLayoutResponse( version=config.get("version", 1), rows=rows, buttons=buttons, is_enabled=is_enabled, updated_at=updated_at, ) @router.get("", response_model=MenuLayoutResponse) async def get_menu_layout( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MenuLayoutResponse: """Получить текущую конфигурацию меню.""" config = await MenuLayoutService.get_config(db) updated_at = await MenuLayoutService.get_config_updated_at(db) return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) @router.put("", response_model=MenuLayoutResponse) async def update_menu_layout( payload: MenuLayoutUpdateRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MenuLayoutResponse: """Обновить конфигурацию меню полностью.""" config = await MenuLayoutService.get_config(db) config = config.copy() if payload.rows is not None: config["rows"] = [row.model_dump() for row in payload.rows] if payload.buttons is not None: 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) return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) @router.post("/reset", response_model=MenuLayoutResponse) async def reset_menu_layout( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MenuLayoutResponse: """Сбросить конфигурацию к дефолтной.""" config = await MenuLayoutService.reset_to_default(db) updated_at = await MenuLayoutService.get_config_updated_at(db) return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) @router.get("/builtin-buttons", response_model=BuiltinButtonsListResponse) async def list_builtin_buttons( _: Any = Security(require_api_token), ) -> BuiltinButtonsListResponse: """Получить список встроенных кнопок.""" items = [] for btn_info in MenuLayoutService.get_builtin_buttons_info(): items.append( BuiltinButtonInfo( id=btn_info["id"], default_text=btn_info["default_text"], callback_data=btn_info["callback_data"], default_conditions=ButtonConditions(**btn_info["default_conditions"]) if btn_info.get("default_conditions") else None, supports_dynamic_text=btn_info.get("supports_dynamic_text", False), supports_direct_open=btn_info.get("supports_direct_open", False), ) ) return BuiltinButtonsListResponse(items=items, total=len(items)) @router.patch("/buttons/{button_id}") async def update_button( button_id: str, payload: ButtonUpdateRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MenuButtonConfig: """Обновить конфигурацию отдельной кнопки.""" try: updates = payload.model_dump(exclude_unset=True) # Конвертируем visibility в строку если есть if "visibility" in updates and updates["visibility"] is not None: if hasattr(updates["visibility"], "value"): updates["visibility"] = updates["visibility"].value # Конвертируем open_mode в строку если есть if "open_mode" in updates and updates["open_mode"] is not None: if hasattr(updates["open_mode"], "value"): updates["open_mode"] = updates["open_mode"].value # Конвертируем conditions - убираем None значения если это dict if "conditions" in updates and updates["conditions"] is not None: if isinstance(updates["conditions"], dict): updates["conditions"] = {k: v for k, v in updates["conditions"].items() if v is not None} elif hasattr(updates["conditions"], "model_dump"): updates["conditions"] = updates["conditions"].model_dump(exclude_none=True) button = await MenuLayoutService.update_button(db, button_id, updates) return MenuButtonConfig( type=button["type"], builtin_id=button.get("builtin_id"), text=button.get("text", {}), icon=button.get("icon"), action=button.get("action", ""), enabled=button.get("enabled", True), visibility=button.get("visibility", "all"), conditions=ButtonConditions(**button["conditions"]) if button.get("conditions") else None, dynamic_text=button.get("dynamic_text", False), open_mode=button.get("open_mode", "callback"), webapp_url=button.get("webapp_url"), description=button.get("description"), ) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e @router.post("/rows/reorder", response_model=List[MenuRowConfig]) async def reorder_rows( payload: RowsReorderRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> List[MenuRowConfig]: """Изменить порядок строк.""" try: rows = await MenuLayoutService.reorder_rows(db, payload.ordered_ids) return [ MenuRowConfig( id=row["id"], buttons=row.get("buttons", []), conditions=ButtonConditions(**row["conditions"]) if row.get("conditions") else None, max_per_row=row.get("max_per_row", 2), ) for row in rows ] except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e @router.post("/rows", response_model=MenuRowConfig, status_code=status.HTTP_201_CREATED) async def add_row( payload: AddRowRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MenuRowConfig: """Добавить новую строку.""" try: row_config = { "id": payload.id, "buttons": payload.buttons, "conditions": payload.conditions.model_dump(exclude_none=True) if payload.conditions else None, "max_per_row": payload.max_per_row, } row = await MenuLayoutService.add_row(db, row_config, payload.position) return MenuRowConfig( id=row["id"], buttons=row.get("buttons", []), conditions=ButtonConditions(**row["conditions"]) if row.get("conditions") else None, max_per_row=row.get("max_per_row", 2), ) except ValueError as e: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e @router.delete("/rows/{row_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) async def delete_row( row_id: str, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> Response: """Удалить строку.""" try: await MenuLayoutService.delete_row(db, row_id) return Response(status_code=status.HTTP_204_NO_CONTENT) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e @router.post( "/buttons", response_model=MenuButtonConfig, status_code=status.HTTP_201_CREATED ) async def add_custom_button( payload: AddCustomButtonRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> 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, "icon": payload.icon, "action": payload.action, "visibility": payload.visibility.value, "conditions": payload.conditions.model_dump(exclude_none=True) if payload.conditions else None, "dynamic_text": dynamic_text, "description": payload.description, } button = await MenuLayoutService.add_custom_button( db, payload.id, button_config, payload.row_id ) return MenuButtonConfig( type=button["type"], builtin_id=button.get("builtin_id"), text=button.get("text", {}), icon=button.get("icon"), action=button.get("action", ""), enabled=button.get("enabled", True), visibility=button.get("visibility", "all"), conditions=ButtonConditions(**button["conditions"]) if button.get("conditions") else None, dynamic_text=button.get("dynamic_text", False), open_mode=button.get("open_mode", "callback"), webapp_url=button.get("webapp_url"), description=button.get("description"), ) except ValueError as e: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e @router.delete("/buttons/{button_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) async def delete_custom_button( button_id: str, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> Response: """Удалить кастомную кнопку.""" try: await MenuLayoutService.delete_custom_button(db, button_id) return Response(status_code=status.HTTP_204_NO_CONTENT) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e except ValueError as e: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e @router.post("/preview", response_model=MenuPreviewResponse) async def preview_menu( payload: MenuPreviewRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MenuPreviewResponse: """Предпросмотр меню для указанного контекста пользователя.""" context = MenuContext( language=payload.language, is_admin=payload.is_admin, is_moderator=payload.is_moderator, has_active_subscription=payload.has_active_subscription, subscription_is_active=payload.subscription_is_active, balance_kopeks=payload.balance_kopeks, ) preview_rows = await MenuLayoutService.preview_keyboard(db, context) rows = [] total_buttons = 0 for row_data in preview_rows: buttons = [ MenuPreviewButton( text=btn["text"], action=btn["action"], type=btn["type"], ) for btn in row_data["buttons"] ] total_buttons += len(buttons) rows.append(MenuPreviewRow(buttons=buttons)) return MenuPreviewResponse(rows=rows, total_buttons=total_buttons) # --- Эндпоинты для перемещения кнопок --- @router.post("/buttons/{button_id}/move-up", response_model=MoveButtonResponse) async def move_button_up( button_id: str, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MoveButtonResponse: """Переместить кнопку вверх (в предыдущую строку или на позицию выше в текущей строке).""" try: result = await MenuLayoutService.move_button_up(db, button_id) return MoveButtonResponse( button_id=button_id, new_row_index=result.get("new_row_index"), position=result.get("new_position"), ) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e except ValueError as e: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e @router.post("/buttons/{button_id}/move-down", response_model=MoveButtonResponse) async def move_button_down( button_id: str, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MoveButtonResponse: """Переместить кнопку вниз (в следующую строку или на позицию ниже в текущей строке).""" try: result = await MenuLayoutService.move_button_down(db, button_id) return MoveButtonResponse( button_id=button_id, new_row_index=result.get("new_row_index"), position=result.get("new_position"), ) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e except ValueError as e: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e @router.post("/buttons/{button_id}/move-to-row", response_model=MoveButtonResponse) async def move_button_to_row( button_id: str, payload: MoveButtonToRowRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MoveButtonResponse: """Переместить кнопку в указанную строку.""" try: result = await MenuLayoutService.move_button_to_row( db, button_id, payload.target_row_id, payload.position ) return MoveButtonResponse( button_id=button_id, target_row_id=payload.target_row_id, position=result.get("new_position"), ) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e except ValueError as e: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e @router.post("/rows/{row_id}/reorder-buttons", response_model=ReorderButtonsResponse) async def reorder_buttons_in_row( row_id: str, payload: ReorderButtonsInRowRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> ReorderButtonsResponse: """Изменить порядок кнопок в строке.""" try: result = await MenuLayoutService.reorder_buttons_in_row( db, row_id, payload.ordered_button_ids ) return ReorderButtonsResponse( row_id=row_id, buttons=result["buttons"], ) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e except ValueError as e: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e @router.post("/buttons/swap", response_model=SwapButtonsResponse) async def swap_buttons( payload: SwapButtonsRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> SwapButtonsResponse: """Обменять местами две кнопки (даже из разных строк).""" try: result = await MenuLayoutService.swap_buttons( db, payload.button_id_1, payload.button_id_2 ) return SwapButtonsResponse( button_1=result["button_1"], button_2=result["button_2"], ) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e except ValueError as e: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e # --- Новые эндпоинты --- @router.get("/available-callbacks", response_model=AvailableCallbacksResponse) async def list_available_callbacks( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> AvailableCallbacksResponse: """Получить список всех доступных callback_data для создания кнопок.""" callbacks = await MenuLayoutService.get_available_callbacks(db) items = [ AvailableCallback( callback_data=cb["callback_data"], name=cb["name"], description=cb.get("description"), category=cb["category"], default_text=cb.get("default_text"), default_icon=cb.get("default_icon"), requires_subscription=cb.get("requires_subscription", False), is_in_menu=cb.get("is_in_menu", False), ) for cb in callbacks ] categories = list(set(cb["category"] for cb in callbacks)) return AvailableCallbacksResponse( items=items, total=len(items), categories=sorted(categories), ) @router.get("/placeholders", response_model=DynamicPlaceholdersResponse) async def list_dynamic_placeholders( _: Any = Security(require_api_token), ) -> DynamicPlaceholdersResponse: """Получить список доступных динамических плейсхолдеров для текста кнопок.""" placeholders = MenuLayoutService.get_dynamic_placeholders() items = [ DynamicPlaceholder( placeholder=p["placeholder"], description=p["description"], example=p["example"], category=p["category"], ) for p in placeholders ] return DynamicPlaceholdersResponse(items=items, total=len(items)) @router.get("/export", response_model=MenuLayoutExportResponse) async def export_menu_layout( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MenuLayoutExportResponse: """Экспортировать конфигурацию меню.""" from datetime import datetime export_data = await MenuLayoutService.export_config(db) rows = [] for row_data in export_data.get("rows", []): rows.append( MenuRowConfig( id=row_data["id"], buttons=row_data.get("buttons", []), conditions=ButtonConditions(**row_data["conditions"]) if row_data.get("conditions") else None, max_per_row=row_data.get("max_per_row", 2), ) ) buttons = {} for btn_id, btn_data in export_data.get("buttons", {}).items(): buttons[btn_id] = MenuButtonConfig( type=btn_data["type"], builtin_id=btn_data.get("builtin_id"), text=btn_data.get("text", {}), icon=btn_data.get("icon"), action=btn_data.get("action", ""), enabled=btn_data.get("enabled", True), visibility=btn_data.get("visibility", "all"), conditions=ButtonConditions(**btn_data["conditions"]) if btn_data.get("conditions") else None, dynamic_text=btn_data.get("dynamic_text", False), open_mode=btn_data.get("open_mode", "callback"), webapp_url=btn_data.get("webapp_url"), description=btn_data.get("description"), ) return MenuLayoutExportResponse( version=export_data.get("version", 1), rows=rows, buttons=buttons, exported_at=datetime.utcnow(), ) @router.post("/import", response_model=MenuLayoutImportResponse) async def import_menu_layout( payload: MenuLayoutImportRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MenuLayoutImportResponse: """Импортировать конфигурацию меню.""" import_data = { "version": payload.version, "rows": [row.model_dump() for row in payload.rows], "buttons": {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()}, } result = await MenuLayoutService.import_config(db, import_data, payload.merge_mode) return MenuLayoutImportResponse( success=result["success"], imported_rows=result["imported_rows"], imported_buttons=result["imported_buttons"], warnings=result["warnings"], ) @router.post("/validate", response_model=MenuLayoutValidateResponse) async def validate_menu_layout( payload: MenuLayoutValidateRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MenuLayoutValidateResponse: """Валидировать конфигурацию меню без сохранения.""" # Если данные не переданы, валидируем текущую конфигурацию if payload.rows is None and payload.buttons is None: config = await MenuLayoutService.get_config(db) else: config = { "rows": [row.model_dump() for row in payload.rows] if payload.rows else [], "buttons": {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()} if payload.buttons else {}, } result = MenuLayoutService.validate_config(config) return MenuLayoutValidateResponse( is_valid=result["is_valid"], errors=[ ValidationError( field=e["field"], message=e["message"], severity=e["severity"], ) for e in result["errors"] ], warnings=[ ValidationError( field=w["field"], message=w["message"], severity=w["severity"], ) for w in result["warnings"] ], ) # --- Эндпоинты истории изменений --- @router.get("/history", response_model=MenuLayoutHistoryResponse) async def get_menu_layout_history( limit: int = 50, offset: int = 0, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MenuLayoutHistoryResponse: """Получить историю изменений меню.""" entries = await MenuLayoutService.get_history(db, limit, offset) total = await MenuLayoutService.get_history_count(db) return MenuLayoutHistoryResponse( items=[ MenuLayoutHistoryEntry( id=entry["id"], created_at=entry["created_at"], action=entry["action"], changes_summary=entry["changes_summary"] or "", user_info=entry["user_info"], ) for entry in entries ], total=total, ) @router.get("/history/{history_id}") async def get_history_entry( history_id: int, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> dict: """Получить конкретную запись истории с полной конфигурацией.""" entry = await MenuLayoutService.get_history_entry(db, history_id) if not entry: raise HTTPException(status.HTTP_404_NOT_FOUND, f"History entry {history_id} not found") return { "id": entry["id"], "action": entry["action"], "changes_summary": entry["changes_summary"], "user_info": entry["user_info"], "created_at": entry["created_at"].isoformat() if entry["created_at"] else None, "config": entry["config"], } @router.post("/history/{history_id}/rollback", response_model=MenuLayoutResponse) async def rollback_to_history( history_id: int, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MenuLayoutResponse: """Откатить конфигурацию к записи из истории.""" try: config = await MenuLayoutService.rollback_to_history(db, history_id) updated_at = await MenuLayoutService.get_config_updated_at(db) return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e # --- Эндпоинты статистики кликов --- @router.get("/stats", response_model=MenuClickStatsResponse) async def get_menu_click_stats( days: int = 30, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MenuClickStatsResponse: """Получить общую статистику кликов по всем кнопкам.""" from datetime import datetime, timedelta stats = await MenuLayoutService.get_all_buttons_stats(db, days) total_clicks = await MenuLayoutService.get_total_clicks(db, days) now = datetime.utcnow() period_start = now - timedelta(days=days) return MenuClickStatsResponse( items=[ ButtonClickStats( button_id=s["button_id"], clicks_total=s["clicks_total"], clicks_today=s.get("clicks_today", 0), clicks_week=s.get("clicks_week", 0), clicks_month=s.get("clicks_month", 0), unique_users=s["unique_users"], last_click_at=s["last_click_at"], ) for s in stats ], total_clicks=total_clicks, period_start=period_start, period_end=now, ) @router.get("/stats/buttons/{button_id}", response_model=ButtonClickStatsResponse) async def get_button_click_stats( button_id: str, days: int = 30, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> ButtonClickStatsResponse: """Получить статистику кликов по конкретной кнопке.""" stats = await MenuLayoutService.get_button_stats(db, button_id, days) clicks_by_day = await MenuLayoutService.get_button_clicks_by_day(db, button_id, days) return ButtonClickStatsResponse( button_id=button_id, stats=ButtonClickStats( button_id=stats["button_id"], clicks_total=stats["clicks_total"], clicks_today=stats["clicks_today"], clicks_week=stats["clicks_week"], clicks_month=stats["clicks_month"], unique_users=stats["unique_users"], last_click_at=stats["last_click_at"], ), clicks_by_day=clicks_by_day, ) @router.post("/stats/log-click") async def log_button_click( button_id: str, user_id: Optional[int] = None, callback_data: Optional[str] = None, button_type: Optional[str] = None, button_text: Optional[str] = None, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> dict: """Записать клик по кнопке (для внешней интеграции).""" await MenuLayoutService.log_button_click( db, button_id=button_id, user_id=user_id, callback_data=callback_data, button_type=button_type, 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).""" try: 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, ) except Exception as e: logger.error(f"Error getting stats by type: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @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: """Получить топ пользователей по количеству кликов.""" try: 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, ) except Exception as e: logger.error(f"Error getting top users: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @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: """Сравнить статистику текущего и предыдущего периода.""" try: comparison = await MenuLayoutService.get_period_comparison( db, button_id, current_days, previous_days ) logger.debug(f"Period comparison: button_id={button_id}, current_days={current_days}, previous_days={previous_days}, trend={comparison.get('change', {}).get('trend')}") return PeriodComparisonResponse( current_period=comparison["current_period"], previous_period=comparison["previous_period"], change=comparison["change"], button_id=button_id, ) except Exception as e: logger.error(f"Error getting period comparison: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @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: """Получить последовательности кликов пользователя.""" try: sequences = await MenuLayoutService.get_user_click_sequences(db, user_id, limit) logger.debug(f"User sequences: user_id={user_id}, limit={limit}, found={len(sequences)} sequences") 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), ) except Exception as e: logger.error(f"Error getting user sequences: user_id={user_id}, error={e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")