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:
Fringg
2026-03-09 23:07:32 +03:00
parent b9089e693f
commit dd8d7f6920
6 changed files with 703 additions and 70 deletions
+128 -69
View File
@@ -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)