"""Pydantic схемы для API конструктора меню.""" from __future__ import annotations from datetime import datetime from enum import Enum from typing import Any from pydantic import BaseModel, ConfigDict, Field class ButtonType(str, Enum): """Тип кнопки меню.""" BUILTIN = 'builtin' # Встроенная кнопка с callback_data URL = 'url' # Внешняя ссылка MINI_APP = 'mini_app' # Telegram Mini App CALLBACK = 'callback' # Кастомная кнопка с любым callback_data class ButtonVisibility(str, Enum): """Видимость кнопки.""" ALL = 'all' # Видна всем ADMINS = 'admins' # Только админам MODERATORS = 'moderators' # Только модераторам SUBSCRIBERS = 'subscribers' # Только подписчикам class ButtonOpenMode(str, Enum): """Режим открытия кнопки.""" CALLBACK = 'callback' # Отправляет callback_data боту (по умолчанию) DIRECT = 'direct' # Сразу открывает Mini App через WebAppInfo class ButtonConditions(BaseModel): """Условия показа кнопки.""" # Существующие условия has_active_subscription: bool | None = Field(default=None, description='Требуется активная подписка') subscription_is_active: bool | None = Field( default=None, description='Подписка должна быть активна (не приостановлена)' ) has_traffic_limit: bool | None = Field(default=None, description='Подписка с лимитом трафика') is_admin: bool | None = Field(default=None, description='Пользователь - админ') is_moderator: bool | None = Field(default=None, description='Пользователь - модератор') referral_enabled: bool | None = Field(default=None, description='Реферальная программа включена') contests_visible: bool | None = Field(default=None, description='Конкурсы видимы') support_enabled: bool | None = Field(default=None, description='Поддержка включена') language_selection_enabled: bool | None = Field(default=None, description='Выбор языка включен') happ_enabled: bool | None = Field(default=None, description='Кнопка Happ включена') simple_subscription_enabled: bool | None = Field(default=None, description='Простая подписка включена') show_trial: bool | None = Field(default=None, description='Показать пробный период') show_buy: bool | None = Field(default=None, description='Показать кнопку покупки') has_saved_cart: bool | None = Field(default=None, description='Есть сохраненная корзина') # Расширенные условия min_balance_kopeks: int | None = Field(default=None, ge=0, description='Минимальный баланс в копейках') max_balance_kopeks: int | None = Field(default=None, ge=0, description='Максимальный баланс в копейках') min_registration_days: int | None = Field(default=None, ge=0, description='Минимум дней с регистрации') max_registration_days: int | None = Field(default=None, ge=0, description='Максимум дней с регистрации') min_referrals: int | None = Field(default=None, ge=0, description='Минимальное количество рефералов') has_referrals: bool | None = Field(default=None, description='Есть рефералы') promo_group_ids: list[str] | None = Field( default=None, description='Список ID промо-групп (пользователь должен быть в одной из них)' ) exclude_promo_group_ids: list[str] | None = Field( default=None, description='Исключить пользователей из этих промо-групп' ) has_subscription_days_left: int | None = Field(default=None, ge=0, description='Минимум дней до окончания подписки') max_subscription_days_left: int | None = Field( default=None, ge=0, description='Максимум дней до окончания подписки' ) is_trial_user: bool | None = Field(default=None, description='Пользователь на пробном периоде') has_autopay: bool | None = Field(default=None, description='Автоплатёж включён') model_config = ConfigDict(extra='forbid') class MenuButtonConfig(BaseModel): """Конфигурация отдельной кнопки.""" type: ButtonType = Field(..., description='Тип кнопки') builtin_id: str | None = Field(default=None, description='ID встроенной кнопки (для type=builtin)') text: dict[str, str] = Field(..., description='Локализованные тексты кнопки: {lang_code: text}') icon: str | None = Field(default=None, max_length=10, description='Эмодзи/иконка кнопки (отдельно от текста)') action: str = Field(..., description='callback_data или URL в зависимости от типа') enabled: bool = Field(default=True, description='Кнопка активна') visibility: ButtonVisibility = Field(default=ButtonVisibility.ALL, description='Видимость кнопки') conditions: ButtonConditions | None = Field(default=None, description='Дополнительные условия показа') dynamic_text: bool = Field(default=False, description='Текст содержит плейсхолдеры ({balance}, {username} и т.д.)') open_mode: ButtonOpenMode = Field( default=ButtonOpenMode.CALLBACK, description='Режим открытия: callback (через бота) или direct (сразу Mini App)', ) webapp_url: str | None = Field( default=None, description='URL для Mini App при open_mode=direct', ) description: str | None = Field(default=None, max_length=200, description='Описание кнопки для админ-панели') sort_order: int | None = Field(default=None, description='Порядок сортировки (для отображения в админке)') model_config = ConfigDict(extra='forbid') class MenuRowConfig(BaseModel): """Конфигурация строки меню.""" id: str = Field(..., min_length=1, max_length=50, description='Уникальный ID строки') buttons: list[str] = Field(..., description='Список ID кнопок в строке') conditions: ButtonConditions | None = Field(default=None, description='Условия показа всей строки') max_per_row: int = Field(default=2, ge=1, le=4, description='Максимум кнопок в строке') model_config = ConfigDict(extra='forbid') class MenuLayoutConfig(BaseModel): """Полная конфигурация меню.""" version: int = Field(default=1, description='Версия формата конфигурации') rows: list[MenuRowConfig] = Field(default_factory=list, description='Строки меню') buttons: dict[str, MenuButtonConfig] = Field(default_factory=dict, description='Конфигурации кнопок') model_config = ConfigDict(extra='forbid') # --- Response schemas --- class MenuLayoutResponse(BaseModel): """Ответ с конфигурацией меню.""" version: int rows: list[MenuRowConfig] buttons: dict[str, MenuButtonConfig] is_enabled: bool = Field(description='Включен ли конструктор меню') updated_at: datetime | None = None class BuiltinButtonInfo(BaseModel): """Информация о встроенной кнопке.""" id: str = Field(description='Идентификатор кнопки') default_text: dict[str, str] = Field(description='Текст по умолчанию') callback_data: str = Field(description='callback_data кнопки') default_conditions: ButtonConditions | None = Field(default=None, description='Условия показа по умолчанию') supports_dynamic_text: bool = Field(default=False, description='Поддерживает ли динамический текст') supports_direct_open: bool = Field(default=False, description='Поддерживает ли прямое открытие Mini App') class BuiltinButtonsListResponse(BaseModel): """Список встроенных кнопок.""" items: list[BuiltinButtonInfo] total: int # --- Request schemas --- class MenuLayoutUpdateRequest(BaseModel): """Запрос на обновление конфигурации меню.""" rows: list[MenuRowConfig] | None = Field(default=None, description='Новая конфигурация строк') buttons: dict[str, MenuButtonConfig] | None = Field(default=None, description='Новая конфигурация кнопок') model_config = ConfigDict(extra='forbid') class ButtonUpdateRequest(BaseModel): """Запрос на обновление отдельной кнопки.""" text: dict[str, str] | None = Field(default=None, description='Новые локализованные тексты') icon: str | None = Field(default=None, max_length=10, description='Эмодзи/иконка кнопки') enabled: bool | None = Field(default=None, description='Включить/выключить') visibility: ButtonVisibility | None = Field(default=None, description='Новая видимость') conditions: ButtonConditions | None = Field(default=None, description='Новые условия показа') action: str | None = Field(default=None, description='Новый action (callback_data или URL)') dynamic_text: bool | None = Field(default=None, description='Текст содержит плейсхолдеры') open_mode: ButtonOpenMode | None = Field(default=None, description='Режим открытия: callback или direct') webapp_url: str | None = Field(default=None, description='URL для Mini App при open_mode=direct') description: str | None = Field(default=None, max_length=200, description='Описание кнопки') sort_order: int | None = Field(default=None, description='Порядок сортировки') model_config = ConfigDict(extra='forbid') class RowsReorderRequest(BaseModel): """Запрос на изменение порядка строк.""" ordered_ids: list[str] = Field(..., min_length=1, description='Список ID строк в новом порядке') model_config = ConfigDict(extra='forbid') class AddRowRequest(BaseModel): """Запрос на добавление новой строки.""" id: str = Field(..., min_length=1, max_length=50, description='ID новой строки') buttons: list[str] = Field(..., description='Список ID кнопок') conditions: ButtonConditions | None = Field(default=None, description='Условия показа') max_per_row: int = Field(default=2, ge=1, le=4, description='Макс. кнопок в строке') position: int | None = Field(default=None, ge=0, description='Позиция вставки (по умолчанию - в конец)') model_config = ConfigDict(extra='forbid') class AddCustomButtonRequest(BaseModel): """Запрос на добавление кастомной кнопки.""" id: str = Field(..., min_length=1, max_length=50, description='ID кнопки (уникальный)') type: ButtonType = Field(..., description='Тип кнопки (url, mini_app или callback)') text: dict[str, str] = Field(..., description='Локализованные тексты') icon: str | None = Field(default=None, max_length=10, description='Эмодзи/иконка кнопки') action: str = Field(..., min_length=1, description='URL или callback_data') visibility: ButtonVisibility = Field(default=ButtonVisibility.ALL, description='Видимость') conditions: ButtonConditions | None = Field(default=None, description='Условия показа') dynamic_text: bool = Field(default=False, description='Текст содержит плейсхолдеры') row_id: str | None = Field(default=None, description='ID строки для добавления кнопки') description: str | None = Field(default=None, max_length=200, description='Описание кнопки для админ-панели') model_config = ConfigDict(extra='forbid') class MenuPreviewRequest(BaseModel): """Запрос на предпросмотр меню.""" language: str = Field(default='ru', description='Язык для предпросмотра') is_admin: bool = Field(default=False, description='Режим админа') is_moderator: bool = Field(default=False, description='Режим модератора') has_active_subscription: bool = Field(default=False, description='Есть активная подписка') subscription_is_active: bool = Field(default=False, description='Подписка активна') balance_kopeks: int = Field(default=0, ge=0, description='Баланс в копейках') model_config = ConfigDict(extra='forbid') class MenuPreviewButton(BaseModel): """Кнопка в предпросмотре.""" text: str action: str type: ButtonType class MenuPreviewRow(BaseModel): """Строка в предпросмотре.""" buttons: list[MenuPreviewButton] class MenuPreviewResponse(BaseModel): """Ответ с предпросмотром меню.""" rows: list[MenuPreviewRow] total_buttons: int # --- Схемы для перемещения кнопок --- class MoveButtonToRowRequest(BaseModel): """Запрос на перемещение кнопки в другую строку.""" target_row_id: str = Field(..., description='ID целевой строки') position: int | None = Field(default=None, ge=0, description='Позиция в строке (по умолчанию - в конец)') model_config = ConfigDict(extra='forbid') class ReorderButtonsInRowRequest(BaseModel): """Запрос на изменение порядка кнопок в строке.""" ordered_button_ids: list[str] = Field(..., min_length=1, description='Список ID кнопок в новом порядке') model_config = ConfigDict(extra='forbid') class SwapButtonsRequest(BaseModel): """Запрос на обмен местами двух кнопок.""" button_id_1: str = Field(..., description='ID первой кнопки') button_id_2: str = Field(..., description='ID второй кнопки') model_config = ConfigDict(extra='forbid') class MoveButtonResponse(BaseModel): """Ответ на перемещение кнопки.""" button_id: str new_row_index: int | None = None target_row_id: str | None = None position: int | None = None class SwapButtonsResponse(BaseModel): """Ответ на обмен кнопок.""" button_1: dict[str, Any] button_2: dict[str, Any] class ReorderButtonsResponse(BaseModel): """Ответ на изменение порядка кнопок.""" row_id: str buttons: list[str] # --- Схемы для доступных callback_data --- class AvailableCallback(BaseModel): """Информация о доступном callback_data.""" callback_data: str = Field(description='callback_data для кнопки') name: str = Field(description='Человекочитаемое название') description: str | None = Field(default=None, description='Описание действия') category: str = Field(description='Категория: menu, subscription, balance, referral, support, etc.') default_text: dict[str, str] | None = Field(default=None, description='Текст по умолчанию') default_icon: str | None = Field(default=None, description='Иконка по умолчанию') requires_subscription: bool = Field(default=False, description='Требует активную подписку') is_in_menu: bool = Field(default=False, description='Уже добавлена в меню') class AvailableCallbacksResponse(BaseModel): """Список всех доступных callback_data.""" items: list[AvailableCallback] total: int categories: list[str] = Field(description='Список всех категорий') # --- Схемы для импорта/экспорта --- class MenuLayoutExportResponse(BaseModel): """Экспорт конфигурации меню.""" version: int rows: list[MenuRowConfig] buttons: dict[str, MenuButtonConfig] exported_at: datetime bot_version: str | None = None class MenuLayoutImportRequest(BaseModel): """Импорт конфигурации меню.""" version: int rows: list[MenuRowConfig] buttons: dict[str, MenuButtonConfig] merge_mode: str = Field(default='replace', description='Режим импорта: replace (заменить всё), merge (объединить)') model_config = ConfigDict(extra='forbid') class MenuLayoutImportResponse(BaseModel): """Результат импорта.""" success: bool imported_rows: int imported_buttons: int warnings: list[str] = Field(default_factory=list) # --- Схемы для истории изменений --- class MenuLayoutHistoryEntry(BaseModel): """Запись в истории изменений.""" id: int created_at: datetime action: str = Field(description='Тип действия: update, reset, import') changes_summary: str = Field(description='Краткое описание изменений') user_info: str | None = Field(default=None, description='Информация о пользователе') class MenuLayoutHistoryResponse(BaseModel): """История изменений.""" items: list[MenuLayoutHistoryEntry] total: int class MenuLayoutRollbackRequest(BaseModel): """Запрос на откат к предыдущей версии.""" history_id: int = Field(description='ID записи в истории для отката') model_config = ConfigDict(extra='forbid') # --- Схемы для валидации --- class ValidationError(BaseModel): """Ошибка валидации.""" field: str message: str severity: str = Field(description='error или warning') class MenuLayoutValidateRequest(BaseModel): """Запрос на валидацию конфигурации.""" rows: list[MenuRowConfig] | None = None buttons: dict[str, MenuButtonConfig] | None = None model_config = ConfigDict(extra='forbid') class MenuLayoutValidateResponse(BaseModel): """Результат валидации.""" is_valid: bool errors: list[ValidationError] = Field(default_factory=list) warnings: list[ValidationError] = Field(default_factory=list) # --- Схемы для статистики кликов --- class ButtonClickStats(BaseModel): """Статистика кликов по кнопке.""" button_id: str clicks_total: int = Field(default=0) clicks_today: int = Field(default=0) clicks_week: int = Field(default=0) clicks_month: int = Field(default=0) last_click_at: datetime | None = None unique_users: int = Field(default=0, description='Уникальные пользователи') class ButtonClickStatsResponse(BaseModel): """Статистика кликов для одной кнопки.""" button_id: str stats: ButtonClickStats clicks_by_day: list[dict[str, Any]] = Field(default_factory=list, description='Клики по дням [{date, count}]') class MenuClickStatsResponse(BaseModel): """Общая статистика кликов по всем кнопкам.""" items: list[ButtonClickStats] total_clicks: int period_start: datetime period_end: datetime class ButtonTypeStats(BaseModel): """Статистика по типу кнопки.""" button_type: str clicks_total: int unique_users: int class ButtonTypeStatsResponse(BaseModel): """Статистика кликов по типам кнопок.""" items: list[ButtonTypeStats] total_clicks: int class HourlyStats(BaseModel): """Статистика по часам.""" hour: int count: int class HourlyStatsResponse(BaseModel): """Статистика кликов по часам дня.""" items: list[HourlyStats] button_id: str | None = None class WeekdayStats(BaseModel): """Статистика по дням недели.""" weekday: int weekday_name: str count: int class WeekdayStatsResponse(BaseModel): """Статистика кликов по дням недели.""" items: list[WeekdayStats] button_id: str | None = None class TopUserStats(BaseModel): """Статистика пользователя.""" user_id: int clicks_count: int last_click_at: datetime | None = None class TopUsersResponse(BaseModel): """Топ пользователей по кликам.""" items: list[TopUserStats] button_id: str | None = None limit: int class PeriodComparisonResponse(BaseModel): """Сравнение периодов.""" current_period: dict[str, Any] previous_period: dict[str, Any] change: dict[str, Any] button_id: str | None = None class UserClickSequence(BaseModel): """Последовательность кликов пользователя.""" button_id: str button_text: str | None = None clicked_at: datetime class UserClickSequencesResponse(BaseModel): """Последовательности кликов пользователя.""" user_id: int items: list[UserClickSequence] total: int # --- Схемы для плейсхолдеров --- class DynamicPlaceholder(BaseModel): """Информация о динамическом плейсхолдере.""" placeholder: str = Field(description='Плейсхолдер, например {balance}') description: str = Field(description='Описание') example: str = Field(description='Пример значения') category: str = Field(description='Категория: user, subscription, referral, etc.') class DynamicPlaceholdersResponse(BaseModel): """Список доступных плейсхолдеров.""" items: list[DynamicPlaceholder] total: int