Files
remnawave-bedolaga-telegram…/app/webapi/routes/menu_layout.py
PEDZEO 37dd5ede9f fix
2025-12-19 23:27:00 +03:00

808 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""API эндпоинты для конструктора меню."""
from __future__ import annotations
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,
)
from ..dependencies import get_db_session, require_api_token
from ..schemas.menu_layout import (
AddCustomButtonRequest,
AddRowRequest,
AvailableCallback,
AvailableCallbacksResponse,
BuiltinButtonInfo,
BuiltinButtonsListResponse,
ButtonClickStats,
ButtonClickStatsResponse,
ButtonConditions,
ButtonUpdateRequest,
DynamicPlaceholder,
DynamicPlaceholdersResponse,
MenuButtonConfig,
MenuClickStatsResponse,
MenuLayoutConfig,
MenuLayoutExportResponse,
MenuLayoutHistoryEntry,
MenuLayoutHistoryResponse,
MenuLayoutImportRequest,
MenuLayoutImportResponse,
MenuLayoutResponse,
MenuLayoutRollbackRequest,
MenuLayoutUpdateRequest,
MenuLayoutValidateRequest,
MenuLayoutValidateResponse,
MenuPreviewButton,
MenuPreviewRequest,
MenuPreviewResponse,
MenuPreviewRow,
MenuRowConfig,
MoveButtonResponse,
MoveButtonToRowRequest,
ReorderButtonsInRowRequest,
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:
config["buttons"] = {
btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()
}
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:
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": payload.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.now(),
)
@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.now()
period_start = now - timedelta(days=days)
return MenuClickStatsResponse(
items=[
ButtonClickStats(
button_id=s["button_id"],
clicks_total=s["clicks_total"],
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}