mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-23 12:53:41 +00:00
- Add pyproject.toml with uv and ruff configuration - Pin Python version to 3.13 via .python-version - Add Makefile commands: lint, format, fix - Apply ruff formatting to entire codebase - Remove unused imports (base64 in yookassa/simple_subscription) - Update .gitignore for new config files
951 lines
35 KiB
Python
951 lines
35 KiB
Python
"""API эндпоинты для конструктора меню."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
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,
|
||
MenuLayoutExportResponse,
|
||
MenuLayoutHistoryEntry,
|
||
MenuLayoutHistoryResponse,
|
||
MenuLayoutImportRequest,
|
||
MenuLayoutImportResponse,
|
||
MenuLayoutResponse,
|
||
MenuLayoutUpdateRequest,
|
||
MenuLayoutValidateRequest,
|
||
MenuLayoutValidateResponse,
|
||
MenuPreviewButton,
|
||
MenuPreviewRequest,
|
||
MenuPreviewResponse,
|
||
MenuPreviewRow,
|
||
MenuRowConfig,
|
||
MoveButtonResponse,
|
||
MoveButtonToRowRequest,
|
||
PeriodComparisonResponse,
|
||
ReorderButtonsInRowRequest,
|
||
ReorderButtonsResponse,
|
||
RowsReorderRequest,
|
||
SwapButtonsRequest,
|
||
SwapButtonsResponse,
|
||
TopUsersResponse,
|
||
TopUserStats,
|
||
UserClickSequence,
|
||
UserClickSequencesResponse,
|
||
ValidationError,
|
||
WeekdayStats,
|
||
WeekdayStatsResponse,
|
||
)
|
||
|
||
|
||
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({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: int | None = None,
|
||
callback_data: str | None = None,
|
||
button_type: str | None = None,
|
||
button_text: str | None = 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: {e!s}')
|
||
|
||
|
||
@router.get('/stats/by-hour', response_model=HourlyStatsResponse)
|
||
async def get_clicks_by_hour(
|
||
button_id: str | None = 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: str | None = 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: str | None = 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: {e!s}')
|
||
|
||
|
||
@router.get('/stats/compare', response_model=PeriodComparisonResponse)
|
||
async def get_period_comparison(
|
||
button_id: str | None = 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: {e!s}')
|
||
|
||
|
||
@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: {e!s}')
|