830e64afe0
* 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>
560 lines
23 KiB
Python
560 lines
23 KiB
Python
"""Pydantic схемы для API конструктора меню."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from enum import StrEnum
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
|
|
class ButtonType(StrEnum):
|
|
"""Тип кнопки меню."""
|
|
|
|
BUILTIN = 'builtin' # Встроенная кнопка с callback_data
|
|
URL = 'url' # Внешняя ссылка
|
|
MINI_APP = 'mini_app' # Telegram Mini App
|
|
CALLBACK = 'callback' # Кастомная кнопка с любым callback_data
|
|
|
|
|
|
class ButtonVisibility(StrEnum):
|
|
"""Видимость кнопки."""
|
|
|
|
ALL = 'all' # Видна всем
|
|
ADMINS = 'admins' # Только админам
|
|
MODERATORS = 'moderators' # Только модераторам
|
|
SUBSCRIBERS = 'subscribers' # Только подписчикам
|
|
|
|
|
|
class ButtonOpenMode(StrEnum):
|
|
"""Режим открытия кнопки."""
|
|
|
|
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='Есть сохраненная корзина')
|
|
traffic_topup_enabled: 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=100, 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=100, 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=100, 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
|