Files
Egor 830e64afe0 Dev (#2899)
* fix: устранить MissingGreenlet в автоплатежах и починить traceback в логах

- subtract_user_balance: пишем promo_offer_log в отдельной сессии вместо rollback после commit, который экспайрил объекты основной сессии и ломал последующие обращения к subscription/user attrs
- monitoring_service._process_autopayments: перезагружаем subscription с eager-load user/tariff после списания, оборачиваем каждую итерацию в try/except + rollback, чтобы одна ошибка не валила весь батч
- logging_config: новый processor _auto_capture_exc_info автоматически подтягивает traceback из sys.exc_info() или error-kwarg → полный traceback в файле, консоли и Telegram без exc_info=True на каждом вызове
- logging_handler: дублирующая логика захвата exc_info в TelegramNotifierProcessor как резерв

* fix: устранить root cause MissingGreenlet в автоплатежах через refetch по id

Трейс показал: subscription.user падает на lazy-load → pool._checkout →
do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не
поддерживает sync-lazy-load для relationships. Причина рассинхрона:
lock_user_for_pricing делает populate_existing=True + selectinload(
User.subscriptions).selectinload(Subscription.tariff), что разгружает
Subscription.user backref для сестринских подписок того же user.
Последующее обращение sub.user у другой подписки падает.

Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию
делаем fresh refetch через async select с eager load user+tariff+
promo_group. Никаких lazy access в горячем пути. В except используем
локально захваченные id вместо getattr(subscription, ...), чтобы
логирование не падало каскадом на expired объекте.

* fix: grant all available squads for unrestricted trials (#2897)

* feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook (#2894)

* feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook

* style: ruff format main.py

---------

Co-authored-by: Dmitry Lunin <br@slack.ru>

* fix: do not update first_name/last_name from OIDC claims (#2892)

Co-authored-by: Dmitry Lunin <br@slack.ru>

* fix: do not reset subscription_crypto_link when cryptoLink absent in webhook (#2891)

Co-authored-by: Dmitry Lunin <br@slack.ru>

* fix: FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal

- balance/platega: re-set FSM state after min/max validation errors,
  set state before pending_amount path, use balance_topup callback for back button
- balance/main: set FSM state and payment_method in handle_topup_amount_callback
  for all providers before routing, use balance_topup callback in validation errors
- payment/paypear: fix confirmation_url key (was 'url'), add fallback,
  store charged amount with commission for correct webhook amount comparison
- tariff_purchase: redirect to active tariff list when current tariff is
  inactive (hidden trial after promo code activation)
- cabinet/renewal: check tariff.is_active in both GET and POST endpoints
  to prevent hidden trial tariff periods from appearing

* fix: tariff switch pricing showing free for upgrades, admin duplicate subscription guard

- pricing_engine: use shortest period for daily rate comparison instead
  of period closest to remaining_days — fixes incorrect free/zero cost
  for upgrades when tariffs have different period sets
- pricing_engine: remove unused target_days parameter from
  get_tariff_daily_rate_fraction
- admin_users: add duplicate subscription check before create,
  change_tariff and activate actions to prevent UniqueViolationError
  on uq_subscriptions_user_tariff_active constraint
- admin_users: add IntegrityError fallback on create as TOCTOU safety net

* feat: tariff switch direction control, fix device pricing within tariff limit

Tariff switch direction:
- Add TARIFF_SWITCH_UPGRADE_ENABLED and TARIFF_SWITCH_DOWNGRADE_ENABLED
  settings to control allowed switch directions
- Guard all 10 entry points: instant switch (list, preview, confirm),
  legacy switch (list, select, confirm, daily confirm), cabinet (preview,
  execute), purchase-options API
- Filter tariff lists by allowed direction, show "unavailable" when
  both directions disabled
- Expose settings in cabinet purchase-options response for frontend

Device pricing fix:
- Devices within tariff.device_limit are now free when restoring
  (was charging for all devices regardless of tariff inclusion)
- Fix max(100, price) minimum enforcing 1 RUB even when
  chargeable_devices is 0
- Apply fix across all endpoints: bot handlers (confirm_change,
  execute_change, confirm_add), cabinet API (legacy purchase,
  modern purchase, get-price, save-cart), inline keyboard display

* fix: classic mode renewal resets device_limit to 1 via cart key mismatch

- Fix cart key mismatch: extend cart saved 'device_limit' but
  confirm_purchase read 'devices' key, falling back to DEFAULT=1.
  Now both keys are saved in both cart-save paths
- Fix confirm_purchase device resolution: use explicit is None checks
  instead of or-chain to avoid falsy-zero trap
- Fix return_to_saved_cart display: fall back to 'device_limit' and
  'traffic_limit_gb' keys when 'devices'/'traffic_gb' are absent
- Fix second cart-save path in _extend_existing_subscription with
  same dual-key pattern
- Fix RemnaWaveService import path in renewal service
- Add RESET_DEVICES_ON_RENEWAL setting: resets all connected devices
  (hwid) via RemnaWave API on each subscription renewal

* fix: menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports

- Increase icon max_length from 10 to 100 in all three schemas
  (MenuButtonConfig, ButtonUpdateRequest, AddCustomButtonRequest)
  to support Telegram Custom Emoji IDs
- Add traffic_topup_enabled condition to ButtonConditions schema
- Remove shadowing local imports of MenuLayoutService in
  routes/menu_layout.py (top-level import already provides access)

* feat(tickets): multi-media message gallery (media_items JSONB)

- Add media_items JSONB column to TicketMessage model for multi-media
  gallery support (photos/videos/documents in one bubble)
- Add TicketMediaItem schema with type validation and shared
  _validate_media_bundle helper (max 10 items, legacy field compat)
- Update admin and user ticket handlers to store media_items and
  back-fill legacy media_type/media_file_id/media_caption from first
  item for backward compatibility
- Update _message_to_response in both admin and user routes to include
  media_items in API responses
- Allow empty message text when media is attached (message field now
  defaults to empty string with model validator ensuring text or media)
- Add migration 0061 with idempotent column check

Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)

* fix: ticket media_items review fixes

- Add if has_media else None guards in user-side ticket handlers
  (create_ticket, add_message) matching admin handler pattern
- Fix Telegram notification using resolved primary_file_id/primary_type
  instead of raw request fields for gallery messages
- Narrow except Exception to (TypeError, KeyError, ValueError) in
  _message_to_response with warning log for debugging
- Add media_items parameter to TicketCRUD.create_ticket and
  TicketCRUD.add_message for CRUD layer parity
- Add TicketMediaItemResponse and media_items field to webapi
  TicketMessageResponse to prevent data loss on read

* feat: landing page analytics goals and sticky pay button

- Add sticky_pay_button, analytics_view_enabled, analytics_view_goal,
  analytics_click_enabled, analytics_click_goal columns to LandingPage
- Add fields to CRUD updatable fields, admin create/update/detail
  schemas, create_landing() kwargs, _landing_to_detail() response
- Expose sticky_pay_button and analytics fields in public landing
  config response for frontend Yandex Metrika integration
- Add migration 0062 with idempotent column checks

Based on PR #2852 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)

* fix: validate analytics goal is set when analytics is enabled on landing

Prevent enabling analytics_view/click without providing the
corresponding goal identifier, which would result in empty
Yandex Metrika calls on the frontend.

* feat: Yandex Metrika offline conversions + S2S postbacks

- Add YandexClientIdMap model for user → yandex_cid mapping with
  upsert-safe CRUD (ON CONFLICT DO UPDATE)
- Add yandex_cid, subid, referrer columns to GuestPurchase
- Add yandex_offline_conv_service: Measurement Protocol integration
  with mc.yandex.ru/collect (registration, trial, purchase events),
  background task management, CID parsing from /start params
- Add s2s_postback_service: server-to-server affiliate postbacks
  with URL template placeholders and URL-safe encoding
- Add analytics offline conversion info to branding API (masked secret)
- Add POST /analytics/yandex-cid endpoint for cabinet CID capture
- Add 11 config settings (YANDEX_OFFLINE_CONV_*, S2S_POSTBACK_*)
- Add migration 0063 (yandex_client_id_map table + guest_purchases cols)
- Fix: mask measurement secret aggressively (show only last 4 chars)
- Fix: always replace {user_id} placeholder in S2S postback URLs
- Fix: use structlog kwargs instead of f-strings with LOG_PREFIX

Based on PR #2851 by @smediainfo — CI/CD workflow changes excluded

---------

Co-authored-by: c0mrade <killmy666@gmail.com>
Co-authored-by: Danila Yudin <danyayudin2012@gmail.com>
Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com>
Co-authored-by: Dmitry Lunin <br@slack.ru>
2026-04-22 06:08:26 +03:00

955 lines
35 KiB
Python

"""API эндпоинты для конструктора меню."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
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 = structlog.get_logger(__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):
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:
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:
"""Экспортировать конфигурацию меню."""
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(UTC),
)
@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:
"""Получить общую статистику кликов по всем кнопкам."""
stats = await MenuLayoutService.get_all_buttons_stats(db, days)
total_clicks = await MenuLayoutService.get_total_clicks(db, days)
now = datetime.now(UTC)
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('Error getting stats by type', error=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('Error getting top users', error=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(
'Period comparison: button_id=, current_days=, previous_days=, trend',
button_id=button_id,
current_days=current_days,
previous_days=previous_days,
get=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('Error getting period comparison', error=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(
'User sequences: user_id=, limit=, found= sequences',
user_id=user_id,
limit=limit,
sequences_count=len(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('Error getting user sequences: user_id=, error', user_id=user_id, error=e, exc_info=True)
raise HTTPException(status_code=500, detail=f'Internal server error: {e!s}')