"""Pydantic схемы для API конструктора меню.""" from __future__ import annotations from datetime import datetime from enum import Enum from typing import Any, Dict, List, Optional 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: Optional[bool] = Field( default=None, description="Требуется активная подписка" ) subscription_is_active: Optional[bool] = Field( default=None, description="Подписка должна быть активна (не приостановлена)" ) has_traffic_limit: Optional[bool] = Field( default=None, description="Подписка с лимитом трафика" ) is_admin: Optional[bool] = Field(default=None, description="Пользователь - админ") is_moderator: Optional[bool] = Field( default=None, description="Пользователь - модератор" ) referral_enabled: Optional[bool] = Field( default=None, description="Реферальная программа включена" ) contests_visible: Optional[bool] = Field( default=None, description="Конкурсы видимы" ) support_enabled: Optional[bool] = Field( default=None, description="Поддержка включена" ) language_selection_enabled: Optional[bool] = Field( default=None, description="Выбор языка включен" ) happ_enabled: Optional[bool] = Field( default=None, description="Кнопка Happ включена" ) simple_subscription_enabled: Optional[bool] = Field( default=None, description="Простая подписка включена" ) show_trial: Optional[bool] = Field( default=None, description="Показать пробный период" ) show_buy: Optional[bool] = Field( default=None, description="Показать кнопку покупки" ) has_saved_cart: Optional[bool] = Field( default=None, description="Есть сохраненная корзина" ) # Расширенные условия min_balance_kopeks: Optional[int] = Field( default=None, ge=0, description="Минимальный баланс в копейках" ) max_balance_kopeks: Optional[int] = Field( default=None, ge=0, description="Максимальный баланс в копейках" ) min_registration_days: Optional[int] = Field( default=None, ge=0, description="Минимум дней с регистрации" ) max_registration_days: Optional[int] = Field( default=None, ge=0, description="Максимум дней с регистрации" ) min_referrals: Optional[int] = Field( default=None, ge=0, description="Минимальное количество рефералов" ) has_referrals: Optional[bool] = Field( default=None, description="Есть рефералы" ) promo_group_ids: Optional[List[str]] = Field( default=None, description="Список ID промо-групп (пользователь должен быть в одной из них)" ) exclude_promo_group_ids: Optional[List[str]] = Field( default=None, description="Исключить пользователей из этих промо-групп" ) has_subscription_days_left: Optional[int] = Field( default=None, ge=0, description="Минимум дней до окончания подписки" ) max_subscription_days_left: Optional[int] = Field( default=None, ge=0, description="Максимум дней до окончания подписки" ) is_trial_user: Optional[bool] = Field( default=None, description="Пользователь на пробном периоде" ) has_autopay: Optional[bool] = Field( default=None, description="Автоплатёж включён" ) model_config = ConfigDict(extra="forbid") class MenuButtonConfig(BaseModel): """Конфигурация отдельной кнопки.""" type: ButtonType = Field(..., description="Тип кнопки") builtin_id: Optional[str] = Field( default=None, description="ID встроенной кнопки (для type=builtin)" ) text: Dict[str, str] = Field( ..., description="Локализованные тексты кнопки: {lang_code: text}" ) icon: Optional[str] = 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: Optional[ButtonConditions] = 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: Optional[str] = Field( default=None, description="URL для Mini App при open_mode=direct", ) description: Optional[str] = Field( default=None, max_length=200, description="Описание кнопки для админ-панели" ) sort_order: Optional[int] = 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: Optional[ButtonConditions] = 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: Optional[datetime] = None class BuiltinButtonInfo(BaseModel): """Информация о встроенной кнопке.""" id: str = Field(description="Идентификатор кнопки") default_text: Dict[str, str] = Field(description="Текст по умолчанию") callback_data: str = Field(description="callback_data кнопки") default_conditions: Optional[ButtonConditions] = 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: Optional[List[MenuRowConfig]] = Field( default=None, description="Новая конфигурация строк" ) buttons: Optional[Dict[str, MenuButtonConfig]] = Field( default=None, description="Новая конфигурация кнопок" ) model_config = ConfigDict(extra="forbid") class ButtonUpdateRequest(BaseModel): """Запрос на обновление отдельной кнопки.""" text: Optional[Dict[str, str]] = Field( default=None, description="Новые локализованные тексты" ) icon: Optional[str] = Field( default=None, max_length=10, description="Эмодзи/иконка кнопки" ) enabled: Optional[bool] = Field(default=None, description="Включить/выключить") visibility: Optional[ButtonVisibility] = Field( default=None, description="Новая видимость" ) conditions: Optional[ButtonConditions] = Field( default=None, description="Новые условия показа" ) action: Optional[str] = Field( default=None, description="Новый action (callback_data или URL)" ) dynamic_text: Optional[bool] = Field( default=None, description="Текст содержит плейсхолдеры" ) open_mode: Optional[ButtonOpenMode] = Field( default=None, description="Режим открытия: callback или direct" ) webapp_url: Optional[str] = Field( default=None, description="URL для Mini App при open_mode=direct" ) description: Optional[str] = Field( default=None, max_length=200, description="Описание кнопки" ) sort_order: Optional[int] = 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: Optional[ButtonConditions] = Field( default=None, description="Условия показа" ) max_per_row: int = Field(default=2, ge=1, le=4, description="Макс. кнопок в строке") position: Optional[int] = 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: Optional[str] = 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: Optional[ButtonConditions] = Field( default=None, description="Условия показа" ) dynamic_text: bool = Field( default=False, description="Текст содержит плейсхолдеры" ) row_id: Optional[str] = Field( default=None, description="ID строки для добавления кнопки" ) description: Optional[str] = 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: Optional[int] = 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: Optional[int] = None target_row_id: Optional[str] = None position: Optional[int] = 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: Optional[str] = Field(default=None, description="Описание действия") category: str = Field(description="Категория: menu, subscription, balance, referral, support, etc.") default_text: Optional[Dict[str, str]] = Field(default=None, description="Текст по умолчанию") default_icon: Optional[str] = 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: Optional[str] = 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: Optional[str] = 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: Optional[List[MenuRowConfig]] = None buttons: Optional[Dict[str, MenuButtonConfig]] = 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: Optional[datetime] = 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: Optional[str] = None class WeekdayStats(BaseModel): """Статистика по дням недели.""" weekday: int weekday_name: str count: int class WeekdayStatsResponse(BaseModel): """Статистика кликов по дням недели.""" items: List[WeekdayStats] button_id: Optional[str] = None class TopUserStats(BaseModel): """Статистика пользователя.""" user_id: int clicks_count: int last_click_at: Optional[datetime] = None class TopUsersResponse(BaseModel): """Топ пользователей по кликам.""" items: List[TopUserStats] button_id: Optional[str] = None limit: int class PeriodComparisonResponse(BaseModel): """Сравнение периодов.""" current_period: Dict[str, Any] previous_period: Dict[str, Any] change: Dict[str, Any] button_id: Optional[str] = None class UserClickSequence(BaseModel): """Последовательность кликов пользователя.""" button_id: str button_text: Optional[str] = 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