feat: add cabinet menu layout editor with row arrangement, custom URL buttons, and drag-and-drop reordering
- Add menu_layout_cache.py for CABINET_MENU_LAYOUT in-process cache - Add admin_menu_layout.py routes (GET/PUT/POST reset) with merged view - Rewrite _build_cabinet_main_menu_keyboard to use cached row layout - Support custom URL buttons with style, emoji, labels, enabled toggle - Atomic dual-key DB writes for layout + button styles - Add language button to default layout and DEFAULT_BUTTON_STYLES - Pydantic validation with Literal types, max_length, duplicate ID checks - Register routes and cache loading in bot startup
This commit is contained in:
+128
-69
@@ -332,6 +332,32 @@ def get_language_selection_keyboard(
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def _get_balance_text(cached_styles: dict, language: str, texts, balance_kopeks: int) -> str:
|
||||
"""Build balance button text with formatting."""
|
||||
bal_cfg = cached_styles.get('balance', {})
|
||||
safe_balance = balance_kopeks or 0
|
||||
|
||||
# Custom label overrides the whole text including balance amount
|
||||
custom_bal = bal_cfg.get('labels', {}).get(language, '')
|
||||
if custom_bal:
|
||||
return custom_bal
|
||||
if hasattr(texts, 'BALANCE_BUTTON') and safe_balance > 0:
|
||||
return texts.BALANCE_BUTTON.format(balance=texts.format_price(safe_balance))
|
||||
return texts.t('BALANCE_BUTTON_DEFAULT', '💰 Баланс: {balance}').format(
|
||||
balance=texts.format_price(safe_balance),
|
||||
)
|
||||
|
||||
|
||||
def _is_support_enabled() -> bool:
|
||||
"""Check if support menu is enabled."""
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
return SupportSettingsService.is_support_menu_enabled()
|
||||
except Exception:
|
||||
return settings.SUPPORT_MENU_ENABLED
|
||||
|
||||
|
||||
def _build_cabinet_main_menu_keyboard(
|
||||
language: str,
|
||||
texts,
|
||||
@@ -342,10 +368,12 @@ def _build_cabinet_main_menu_keyboard(
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Build the main-menu keyboard for Cabinet mode.
|
||||
|
||||
Each button opens the corresponding section of the cabinet frontend
|
||||
via ``MINIAPP_CUSTOM_URL`` + path (e.g. ``/subscription``, ``/balance``).
|
||||
Row layout and button arrangement are driven by the cached menu layout
|
||||
(``get_cached_menu_layout``). Each row specifies which buttons it contains
|
||||
and how many fit per keyboard row (``max_per_row``).
|
||||
"""
|
||||
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
|
||||
from app.utils.menu_layout_cache import get_cached_menu_layout
|
||||
from app.utils.miniapp_buttons import (
|
||||
CALLBACK_TO_CABINET_STYLE,
|
||||
_resolve_style,
|
||||
@@ -354,6 +382,8 @@ def _build_cabinet_main_menu_keyboard(
|
||||
|
||||
global_style = _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip())
|
||||
cached_styles = get_cached_button_styles()
|
||||
layout = get_cached_menu_layout()
|
||||
custom_buttons_cfg: dict[str, dict] = layout.get('custom_buttons', {})
|
||||
|
||||
def _cabinet_button(
|
||||
text: str,
|
||||
@@ -385,84 +415,113 @@ def _build_cabinet_main_menu_keyboard(
|
||||
)
|
||||
return InlineKeyboardButton(text=text, callback_data=callback_fallback)
|
||||
|
||||
# -- Primary action row: Cabinet home --
|
||||
home_cfg = cached_styles.get('home', {})
|
||||
if home_cfg.get('enabled', True):
|
||||
profile_text = home_cfg.get('labels', {}).get(language, '') or texts.t('MENU_PROFILE', '👤 Личный кабинет')
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = [
|
||||
[_cabinet_button(profile_text, '/', 'menu_profile_unavailable')],
|
||||
]
|
||||
else:
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = []
|
||||
# -- Collect row definitions sorted by row_N key --
|
||||
row_keys = sorted(
|
||||
(k for k in layout if k.startswith('row_')),
|
||||
key=lambda k: int(k.split('_', 1)[1]) if k.split('_', 1)[1].isdigit() else 0,
|
||||
)
|
||||
|
||||
# -- Section buttons as paired rows --
|
||||
paired: list[InlineKeyboardButton] = []
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
# Subscription (green — main action)
|
||||
sub_cfg = cached_styles.get('subscription', {})
|
||||
if sub_cfg.get('enabled', True):
|
||||
sub_text = sub_cfg.get('labels', {}).get(language, '') or texts.MENU_SUBSCRIPTION
|
||||
paired.append(_cabinet_button(sub_text, '/subscription', 'menu_subscription'))
|
||||
for row_key in row_keys:
|
||||
row_def = layout[row_key]
|
||||
btn_ids: list[str] = row_def.get('buttons', [])
|
||||
max_per_row: int = row_def.get('max_per_row', 1)
|
||||
row_buttons: list[InlineKeyboardButton] = []
|
||||
|
||||
# Balance
|
||||
bal_cfg = cached_styles.get('balance', {})
|
||||
if bal_cfg.get('enabled', True):
|
||||
safe_balance = balance_kopeks or 0
|
||||
# Custom label overrides the whole text including balance amount
|
||||
custom_bal = bal_cfg.get('labels', {}).get(language, '')
|
||||
if custom_bal:
|
||||
balance_text = custom_bal
|
||||
elif hasattr(texts, 'BALANCE_BUTTON') and safe_balance > 0:
|
||||
balance_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(safe_balance))
|
||||
else:
|
||||
balance_text = texts.t('BALANCE_BUTTON_DEFAULT', '💰 Баланс: {balance}').format(
|
||||
balance=texts.format_price(safe_balance),
|
||||
)
|
||||
paired.append(_cabinet_button(balance_text, '/balance', 'menu_balance'))
|
||||
for btn_id in btn_ids:
|
||||
# --- Custom URL buttons ---
|
||||
if btn_id.startswith('custom_'):
|
||||
custom_cfg = custom_buttons_cfg.get(btn_id)
|
||||
if not custom_cfg or not custom_cfg.get('url') or not custom_cfg.get('enabled', True):
|
||||
continue
|
||||
custom_text = custom_cfg.get('labels', {}).get(language, '') or custom_cfg.get('labels', {}).get('ru', '') or 'Link'
|
||||
resolved_style = _resolve_style(custom_cfg.get('style'))
|
||||
resolved_emoji = custom_cfg.get('icon_custom_emoji_id') or None
|
||||
row_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=custom_text,
|
||||
url=custom_cfg['url'],
|
||||
style=resolved_style,
|
||||
icon_custom_emoji_id=resolved_emoji,
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
# Referrals (if enabled)
|
||||
ref_cfg = cached_styles.get('referral', {})
|
||||
if settings.is_referral_program_enabled() and ref_cfg.get('enabled', True):
|
||||
ref_text = ref_cfg.get('labels', {}).get(language, '') or texts.MENU_REFERRALS
|
||||
paired.append(_cabinet_button(ref_text, '/referral', 'menu_referrals'))
|
||||
# --- Built-in buttons ---
|
||||
section_cfg = cached_styles.get(btn_id, {})
|
||||
|
||||
# Support
|
||||
support_enabled = False
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
match btn_id:
|
||||
case 'home':
|
||||
if not section_cfg.get('enabled', True):
|
||||
continue
|
||||
home_text = section_cfg.get('labels', {}).get(language, '') or texts.t('MENU_PROFILE', '👤 Личный кабинет')
|
||||
row_buttons.append(_cabinet_button(home_text, '/', 'menu_profile_unavailable'))
|
||||
|
||||
support_enabled = SupportSettingsService.is_support_menu_enabled()
|
||||
except Exception:
|
||||
support_enabled = settings.SUPPORT_MENU_ENABLED
|
||||
case 'subscription':
|
||||
if not section_cfg.get('enabled', True):
|
||||
continue
|
||||
sub_text = section_cfg.get('labels', {}).get(language, '') or texts.MENU_SUBSCRIPTION
|
||||
row_buttons.append(_cabinet_button(sub_text, '/subscription', 'menu_subscription'))
|
||||
|
||||
sup_cfg = cached_styles.get('support', {})
|
||||
if support_enabled and sup_cfg.get('enabled', True):
|
||||
sup_text = sup_cfg.get('labels', {}).get(language, '') or texts.MENU_SUPPORT
|
||||
paired.append(_cabinet_button(sup_text, '/support', 'menu_support'))
|
||||
case 'balance':
|
||||
if not section_cfg.get('enabled', True):
|
||||
continue
|
||||
balance_text = _get_balance_text(cached_styles, language, texts, balance_kopeks)
|
||||
row_buttons.append(_cabinet_button(balance_text, '/balance', 'menu_balance'))
|
||||
|
||||
# Info
|
||||
info_cfg = cached_styles.get('info', {})
|
||||
if info_cfg.get('enabled', True):
|
||||
info_text = info_cfg.get('labels', {}).get(language, '') or texts.t('MENU_INFO', 'ℹ️ Инфо')
|
||||
paired.append(_cabinet_button(info_text, '/info', 'menu_info'))
|
||||
case 'referral':
|
||||
if not settings.is_referral_program_enabled():
|
||||
continue
|
||||
if not section_cfg.get('enabled', True):
|
||||
continue
|
||||
ref_text = section_cfg.get('labels', {}).get(language, '') or texts.MENU_REFERRALS
|
||||
row_buttons.append(_cabinet_button(ref_text, '/referral', 'menu_referrals'))
|
||||
|
||||
# Language selection (stays as callback — not a cabinet section)
|
||||
if settings.is_language_selection_enabled():
|
||||
paired.append(InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language'))
|
||||
case 'support':
|
||||
if not _is_support_enabled():
|
||||
continue
|
||||
if not section_cfg.get('enabled', True):
|
||||
continue
|
||||
sup_text = section_cfg.get('labels', {}).get(language, '') or texts.MENU_SUPPORT
|
||||
row_buttons.append(_cabinet_button(sup_text, '/support', 'menu_support'))
|
||||
|
||||
# Lay out in pairs
|
||||
for i in range(0, len(paired), 2):
|
||||
keyboard_rows.append(paired[i : i + 2])
|
||||
case 'info':
|
||||
if not section_cfg.get('enabled', True):
|
||||
continue
|
||||
info_text = section_cfg.get('labels', {}).get(language, '') or texts.t('MENU_INFO', 'ℹ️ Инфо')
|
||||
row_buttons.append(_cabinet_button(info_text, '/info', 'menu_info'))
|
||||
|
||||
# Admin / Moderator
|
||||
admin_cfg = cached_styles.get('admin', {})
|
||||
if is_admin:
|
||||
admin_buttons = [InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')]
|
||||
if admin_cfg.get('enabled', True):
|
||||
admin_web_text = admin_cfg.get('labels', {}).get(language, '') or '🖥 Веб-Админка'
|
||||
admin_buttons.append(_cabinet_button(admin_web_text, '/admin', 'admin_panel'))
|
||||
keyboard_rows.append(admin_buttons)
|
||||
elif is_moderator:
|
||||
case 'language':
|
||||
if not section_cfg.get('enabled', True):
|
||||
continue
|
||||
if not settings.is_language_selection_enabled():
|
||||
continue
|
||||
lang_text = section_cfg.get('labels', {}).get(language, '') or texts.MENU_LANGUAGE
|
||||
resolved_lang_emoji = section_cfg.get('icon_custom_emoji_id') or None
|
||||
row_buttons.append(InlineKeyboardButton(
|
||||
text=lang_text,
|
||||
callback_data='menu_language',
|
||||
icon_custom_emoji_id=resolved_lang_emoji,
|
||||
))
|
||||
|
||||
case 'admin':
|
||||
if not is_admin:
|
||||
continue
|
||||
admin_row = [InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')]
|
||||
if section_cfg.get('enabled', True):
|
||||
admin_web_text = section_cfg.get('labels', {}).get(language, '') or '🖥 Веб-Админка'
|
||||
admin_row.append(_cabinet_button(admin_web_text, '/admin', 'admin_panel'))
|
||||
keyboard_rows.append(admin_row)
|
||||
continue # bypass max_per_row chunking
|
||||
|
||||
# Split collected buttons into keyboard rows respecting max_per_row
|
||||
if row_buttons:
|
||||
for i in range(0, len(row_buttons), max_per_row):
|
||||
keyboard_rows.append(row_buttons[i : i + max_per_row])
|
||||
|
||||
# -- Moderator panel (only when not admin — admin row handled above) --
|
||||
if is_moderator and not is_admin:
|
||||
keyboard_rows.append([InlineKeyboardButton(text='🧑⚖️ Модерация', callback_data='moderator_panel')])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
Reference in New Issue
Block a user