Merge pull request #894 from Fr1ngg/j4qq3i-bedolaga/add-admin-panel-for-menu-button-management

Add admin tools for configurable main menu buttons
This commit is contained in:
Egor
2025-10-08 06:00:00 +03:00
committed by GitHub
11 changed files with 675 additions and 1 deletions

View File

@@ -0,0 +1,145 @@
from __future__ import annotations
from typing import Optional, Sequence
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
MainMenuButton,
MainMenuButtonActionType,
MainMenuButtonVisibility,
)
async def count_main_menu_buttons(db: AsyncSession) -> int:
result = await db.execute(select(func.count()).select_from(MainMenuButton))
return int(result.scalar() or 0)
async def get_main_menu_buttons(
db: AsyncSession,
*,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[MainMenuButton]:
stmt = select(MainMenuButton).order_by(
MainMenuButton.display_order.asc(),
MainMenuButton.id.asc(),
)
if offset is not None:
stmt = stmt.offset(offset)
if limit is not None:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_main_menu_button_by_id(
db: AsyncSession, button_id: int
) -> MainMenuButton | None:
result = await db.execute(
select(MainMenuButton).where(MainMenuButton.id == button_id)
)
return result.scalar_one_or_none()
async def get_next_display_order(db: AsyncSession) -> int:
result = await db.execute(select(func.max(MainMenuButton.display_order)))
current_max = result.scalar()
return (int(current_max) if current_max is not None else 0) + 1
def _enum_value(value, enum_cls):
if value is None:
return None
if isinstance(value, enum_cls):
return value.value
return str(value)
async def create_main_menu_button(
db: AsyncSession,
*,
text: str,
action_type: MainMenuButtonActionType | str,
action_value: str,
visibility: MainMenuButtonVisibility | str = MainMenuButtonVisibility.ALL,
is_active: bool = True,
display_order: Optional[int] = None,
) -> MainMenuButton:
if display_order is None:
display_order = await get_next_display_order(db)
button = MainMenuButton(
text=text,
action_type=_enum_value(action_type, MainMenuButtonActionType),
action_value=action_value,
visibility=_enum_value(visibility, MainMenuButtonVisibility)
or MainMenuButtonVisibility.ALL.value,
is_active=bool(is_active),
display_order=int(display_order),
)
db.add(button)
await db.commit()
await db.refresh(button)
return button
async def update_main_menu_button(
db: AsyncSession,
button: MainMenuButton,
*,
text: Optional[str] = None,
action_type: MainMenuButtonActionType | str | None = None,
action_value: Optional[str] = None,
visibility: MainMenuButtonVisibility | str | None = None,
is_active: Optional[bool] = None,
display_order: Optional[int] = None,
) -> MainMenuButton:
if text is not None:
button.text = text
if action_type is not None:
button.action_type = _enum_value(action_type, MainMenuButtonActionType)
if action_value is not None:
button.action_value = action_value
if visibility is not None:
button.visibility = _enum_value(visibility, MainMenuButtonVisibility)
if is_active is not None:
button.is_active = bool(is_active)
if display_order is not None:
button.display_order = int(display_order)
await db.commit()
await db.refresh(button)
return button
async def delete_main_menu_button(db: AsyncSession, button: MainMenuButton) -> None:
await db.delete(button)
await db.commit()
async def reorder_main_menu_buttons(
db: AsyncSession,
ordered_ids: Sequence[int],
) -> None:
if not ordered_ids:
return
order_map = {int(button_id): index for index, button_id in enumerate(ordered_ids)}
result = await db.execute(
select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys()))
)
buttons = result.scalars().all()
for button in buttons:
desired_order = order_map.get(button.id)
if desired_order is not None:
button.display_order = desired_order
await db.commit()

View File

@@ -79,6 +79,17 @@ class PaymentMethod(Enum):
PAL24 = "pal24"
MANUAL = "manual"
class MainMenuButtonActionType(Enum):
URL = "url"
MINI_APP = "mini_app"
class MainMenuButtonVisibility(Enum):
ALL = "all"
ADMINS = "admins"
SUBSCRIBERS = "subscribers"
class YooKassaPayment(Base):
__tablename__ = "yookassa_payments"
@@ -1252,4 +1263,42 @@ class WebApiToken(Base):
def __repr__(self) -> str:
status = "active" if self.is_active else "revoked"
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
class MainMenuButton(Base):
__tablename__ = "main_menu_buttons"
id = Column(Integer, primary_key=True, index=True)
text = Column(String(64), nullable=False)
action_type = Column(String(20), nullable=False)
action_value = Column(Text, nullable=False)
visibility = Column(String(20), nullable=False, default=MainMenuButtonVisibility.ALL.value)
is_active = Column(Boolean, nullable=False, default=True)
display_order = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
__table_args__ = (
Index("ix_main_menu_buttons_order", "display_order", "id"),
)
@property
def action_type_enum(self) -> MainMenuButtonActionType:
try:
return MainMenuButtonActionType(self.action_type)
except ValueError:
return MainMenuButtonActionType.URL
@property
def visibility_enum(self) -> MainMenuButtonVisibility:
try:
return MainMenuButtonVisibility(self.visibility)
except ValueError:
return MainMenuButtonVisibility.ALL
def __repr__(self) -> str:
return (
f"<MainMenuButton id={self.id} text='{self.text}' "
f"action={self.action_type} visibility={self.visibility} active={self.is_active}>"
)

View File

@@ -1062,6 +1062,78 @@ async def create_promo_offer_templates_table():
return False
async def create_main_menu_buttons_table() -> bool:
table_exists = await check_table_exists('main_menu_buttons')
if table_exists:
logger.info("Таблица main_menu_buttons уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE main_menu_buttons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text VARCHAR(64) NOT NULL,
action_type VARCHAR(20) NOT NULL,
action_value TEXT NOT NULL,
visibility VARCHAR(20) NOT NULL DEFAULT 'all',
is_active BOOLEAN NOT NULL DEFAULT 1,
display_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS ix_main_menu_buttons_order ON main_menu_buttons(display_order, id);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE IF NOT EXISTS main_menu_buttons (
id SERIAL PRIMARY KEY,
text VARCHAR(64) NOT NULL,
action_type VARCHAR(20) NOT NULL,
action_value TEXT NOT NULL,
visibility VARCHAR(20) NOT NULL DEFAULT 'all',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS ix_main_menu_buttons_order ON main_menu_buttons(display_order, id);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE IF NOT EXISTS main_menu_buttons (
id INT AUTO_INCREMENT PRIMARY KEY,
text VARCHAR(64) NOT NULL,
action_type VARCHAR(20) NOT NULL,
action_value TEXT NOT NULL,
visibility VARCHAR(20) NOT NULL DEFAULT 'all',
is_active BOOLEAN NOT NULL DEFAULT 1,
display_order INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE INDEX ix_main_menu_buttons_order ON main_menu_buttons(display_order, id);
"""
else:
logger.error(f"Неподдерживаемый тип БД для таблицы main_menu_buttons: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("✅ Таблица main_menu_buttons успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы main_menu_buttons: {e}")
return False
async def create_promo_offer_logs_table() -> bool:
table_exists = await check_table_exists('promo_offer_logs')
if table_exists:
@@ -2902,6 +2974,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей promo_offer_templates")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MAIN_MENU_BUTTONS ===")
main_menu_buttons_created = await create_main_menu_buttons_table()
if main_menu_buttons_created:
logger.info("✅ Таблица main_menu_buttons готова")
else:
logger.warning("⚠️ Проблемы с таблицей main_menu_buttons")
template_columns_ready = await ensure_promo_offer_template_active_duration_column()
if template_columns_ready:
logger.info("✅ Колонка active_discount_hours промо-предложений готова")

View File

@@ -29,6 +29,7 @@ from app.services.subscription_checkout_service import (
)
from app.utils.photo_message import edit_or_answer_photo
from app.services.support_settings_service import SupportSettingsService
from app.services.main_menu_button_service import MainMenuButtonService
from app.utils.promo_offer import (
build_promo_offer_hint,
build_test_access_hint,
@@ -167,6 +168,13 @@ async def show_main_menu(
db_user.telegram_id
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
@@ -180,6 +188,7 @@ async def show_main_menu(
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
custom_buttons=custom_buttons,
),
parse_mode="HTML",
)
@@ -871,6 +880,13 @@ async def handle_back_to_menu(
db_user.telegram_id
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
@@ -884,6 +900,7 @@ async def handle_back_to_menu(
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
custom_buttons=custom_buttons,
),
parse_mode="HTML",
)

View File

@@ -31,6 +31,7 @@ from app.services.campaign_service import AdvertisingCampaignService
from app.services.admin_notification_service import AdminNotificationService
from app.services.subscription_service import SubscriptionService
from app.services.support_settings_service import SupportSettingsService
from app.services.main_menu_button_service import MainMenuButtonService
from app.utils.user_utils import generate_unique_referral_code
from app.utils.promo_offer import (
build_promo_offer_hint,
@@ -333,6 +334,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
user.telegram_id
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
await message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
@@ -344,6 +352,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -746,6 +755,13 @@ async def complete_registration_from_callback(
and SupportSettingsService.is_moderator(existing_user.telegram_id)
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
try:
await callback.message.answer(
menu_text,
@@ -758,6 +774,7 @@ async def complete_registration_from_callback(
balance_kopeks=existing_user.balance_kopeks,
subscription=existing_user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -918,6 +935,13 @@ async def complete_registration_from_callback(
and SupportSettingsService.is_moderator(user.telegram_id)
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
try:
await callback.message.answer(
menu_text,
@@ -930,6 +954,7 @@ async def complete_registration_from_callback(
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -984,6 +1009,13 @@ async def complete_registration(
and SupportSettingsService.is_moderator(existing_user.telegram_id)
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
try:
await message.answer(
menu_text,
@@ -996,6 +1028,7 @@ async def complete_registration(
balance_kopeks=existing_user.balance_kopeks,
subscription=existing_user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -1156,6 +1189,13 @@ async def complete_registration(
and SupportSettingsService.is_moderator(user.telegram_id)
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
try:
await message.answer(
menu_text,
@@ -1168,6 +1208,7 @@ async def complete_registration(
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -1431,6 +1472,13 @@ async def required_sub_channel_check(
and SupportSettingsService.is_moderator(user.telegram_id)
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
keyboard = get_main_menu_keyboard(
language=user.language,
is_admin=is_admin,
@@ -1440,6 +1488,7 @@ async def required_sub_channel_check(
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
if settings.ENABLE_LOGO_MODE:

View File

@@ -121,6 +121,7 @@ def get_main_menu_keyboard(
show_resume_checkout: bool = False,
*,
is_moderator: bool = False,
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -230,6 +231,11 @@ def get_main_menu_keyboard(
)
])
if custom_buttons:
for button in custom_buttons:
if isinstance(button, InlineKeyboardButton):
keyboard.append([button])
keyboard.extend([
[
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"),

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import List
from aiogram import types
from aiogram.types import InlineKeyboardButton
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
MainMenuButton,
MainMenuButtonActionType,
MainMenuButtonVisibility,
)
@dataclass(frozen=True)
class _MainMenuButtonData:
text: str
action_type: MainMenuButtonActionType
action_value: str
visibility: MainMenuButtonVisibility
is_active: bool
display_order: int
class MainMenuButtonService:
_cache: List[_MainMenuButtonData] | None = None
_lock: asyncio.Lock = asyncio.Lock()
@classmethod
def invalidate_cache(cls) -> None:
cls._cache = None
@classmethod
async def _load_cache(cls, db: AsyncSession) -> List[_MainMenuButtonData]:
if cls._cache is not None:
return cls._cache
async with cls._lock:
if cls._cache is not None:
return cls._cache
result = await db.execute(
select(MainMenuButton).order_by(
MainMenuButton.display_order.asc(),
MainMenuButton.id.asc(),
)
)
items: List[_MainMenuButtonData] = []
for record in result.scalars().all():
text = (record.text or "").strip()
action_value = (record.action_value or "").strip()
if not text or not action_value:
continue
try:
action_type = MainMenuButtonActionType(record.action_type)
except ValueError:
continue
try:
visibility = MainMenuButtonVisibility(record.visibility)
except ValueError:
visibility = MainMenuButtonVisibility.ALL
items.append(
_MainMenuButtonData(
text=text,
action_type=action_type,
action_value=action_value,
visibility=visibility,
is_active=bool(record.is_active),
display_order=int(record.display_order or 0),
)
)
cls._cache = items
return items
@classmethod
async def get_buttons_for_user(
cls,
db: AsyncSession,
*,
is_admin: bool,
has_active_subscription: bool,
subscription_is_active: bool,
) -> list[InlineKeyboardButton]:
data = await cls._load_cache(db)
has_subscription = bool(has_active_subscription and subscription_is_active)
buttons: list[InlineKeyboardButton] = []
for item in data:
if not item.is_active:
continue
if item.visibility == MainMenuButtonVisibility.ADMINS and not is_admin:
continue
if item.visibility == MainMenuButtonVisibility.SUBSCRIBERS and not has_subscription:
continue
button = cls._build_button(item)
if button:
buttons.append(button)
return buttons
@staticmethod
def _build_button(item: _MainMenuButtonData) -> InlineKeyboardButton | None:
if item.action_type == MainMenuButtonActionType.URL:
return InlineKeyboardButton(text=item.text, url=item.action_value)
if item.action_type == MainMenuButtonActionType.MINI_APP:
return InlineKeyboardButton(
text=item.text,
web_app=types.WebAppInfo(url=item.action_value),
)
return None

View File

@@ -12,6 +12,7 @@ from .routes import (
campaigns,
config,
health,
main_menu_buttons,
promocodes,
miniapp,
promo_groups,
@@ -40,6 +41,10 @@ OPENAPI_TAGS = [
"name": "settings",
"description": "Получение и изменение конфигурации бота из административной панели.",
},
{
"name": "main-menu",
"description": "Управление кнопками главного меню Telegram-бота.",
},
{
"name": "users",
"description": "Управление пользователями, балансом и статусами подписок.",
@@ -120,6 +125,11 @@ def create_web_api_app() -> FastAPI:
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
app.include_router(promo_offers.router, prefix="/promo-offers", tags=["promo-offers"])
app.include_router(
main_menu_buttons.router,
prefix="/main-menu/buttons",
tags=["main-menu"],
)
app.include_router(pages.router, prefix="/pages", tags=["pages"])
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])

View File

@@ -1,6 +1,7 @@
from . import (
config,
health,
main_menu_buttons,
miniapp,
promo_offers,
pages,
@@ -17,6 +18,7 @@ from . import (
__all__ = [
"config",
"health",
"main_menu_buttons",
"miniapp",
"promo_offers",
"pages",

View File

@@ -0,0 +1,113 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.main_menu_button import (
count_main_menu_buttons,
create_main_menu_button,
delete_main_menu_button,
get_main_menu_button_by_id,
get_main_menu_buttons,
update_main_menu_button,
)
from app.database.models import MainMenuButton
from app.services.main_menu_button_service import MainMenuButtonService
from ..dependencies import get_db_session, require_api_token
from ..schemas.main_menu_buttons import (
MainMenuButtonCreateRequest,
MainMenuButtonListResponse,
MainMenuButtonResponse,
MainMenuButtonUpdateRequest,
)
router = APIRouter()
def _serialize(button: MainMenuButton) -> MainMenuButtonResponse:
return MainMenuButtonResponse(
id=button.id,
text=button.text,
action_type=button.action_type_enum,
action_value=button.action_value,
visibility=button.visibility_enum,
is_active=button.is_active,
display_order=button.display_order,
created_at=button.created_at,
updated_at=button.updated_at,
)
@router.get("", response_model=MainMenuButtonListResponse)
async def list_main_menu_buttons(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> MainMenuButtonListResponse:
total = await count_main_menu_buttons(db)
buttons = await get_main_menu_buttons(db, limit=limit, offset=offset)
return MainMenuButtonListResponse(
items=[_serialize(button) for button in buttons],
total=total,
limit=limit,
offset=offset,
)
@router.post("", response_model=MainMenuButtonResponse, status_code=status.HTTP_201_CREATED)
async def create_main_menu_button_endpoint(
payload: MainMenuButtonCreateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MainMenuButtonResponse:
button = await create_main_menu_button(
db,
text=payload.text,
action_type=payload.action_type,
action_value=payload.action_value,
visibility=payload.visibility,
is_active=payload.is_active,
display_order=payload.display_order,
)
MainMenuButtonService.invalidate_cache()
return _serialize(button)
@router.patch("/{button_id}", response_model=MainMenuButtonResponse)
async def update_main_menu_button_endpoint(
button_id: int,
payload: MainMenuButtonUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MainMenuButtonResponse:
button = await get_main_menu_button_by_id(db, button_id)
if not button:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Main menu button not found")
update_payload = payload.dict(exclude_unset=True)
button = await update_main_menu_button(db, button, **update_payload)
MainMenuButtonService.invalidate_cache()
return _serialize(button)
@router.delete("/{button_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_main_menu_button_endpoint(
button_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Response:
button = await get_main_menu_button_by_id(db, button_id)
if not button:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Main menu button not found")
await delete_main_menu_button(db, button)
MainMenuButtonService.invalidate_cache()
return Response(status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, validator
from app.database.models import MainMenuButtonActionType, MainMenuButtonVisibility
def _clean_text(value: str) -> str:
cleaned = (value or "").strip()
if not cleaned:
raise ValueError("Text cannot be empty")
return cleaned
def _validate_action_value(value: str) -> str:
cleaned = (value or "").strip()
if not cleaned:
raise ValueError("Action value cannot be empty")
if not cleaned.lower().startswith(("http://", "https://")):
raise ValueError("Action value must start with http:// or https://")
return cleaned
class MainMenuButtonResponse(BaseModel):
id: int
text: str
action_type: MainMenuButtonActionType
action_value: str
visibility: MainMenuButtonVisibility
is_active: bool
display_order: int
created_at: datetime
updated_at: datetime
class MainMenuButtonCreateRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=64)
action_type: MainMenuButtonActionType
action_value: str = Field(..., min_length=1, max_length=1024)
visibility: MainMenuButtonVisibility = MainMenuButtonVisibility.ALL
is_active: bool = True
display_order: Optional[int] = Field(None, ge=0)
_normalize_text = validator("text", allow_reuse=True)(_clean_text)
_normalize_action_value = validator("action_value", allow_reuse=True)(_validate_action_value)
class MainMenuButtonUpdateRequest(BaseModel):
text: Optional[str] = Field(None, min_length=1, max_length=64)
action_type: Optional[MainMenuButtonActionType] = None
action_value: Optional[str] = Field(None, min_length=1, max_length=1024)
visibility: Optional[MainMenuButtonVisibility] = None
is_active: Optional[bool] = None
display_order: Optional[int] = Field(None, ge=0)
@validator("text")
def validate_text(cls, value): # noqa: D401,B902
if value is None:
return value
return _clean_text(value)
@validator("action_value")
def validate_action_value(cls, value): # noqa: D401,B902
if value is None:
return value
return _validate_action_value(value)
class MainMenuButtonListResponse(BaseModel):
items: list[MainMenuButtonResponse]
total: int
limit: int
offset: int