feat: add per-section button style and emoji customization via admin API

Add cabinet admin API for configuring button colors (primary/success/danger)
and custom emoji IDs per menu section (home, subscription, balance, referral,
support, info, admin). Styles are stored as JSON in system_settings and cached
in-process for fast resolution.

Style resolution chain: explicit param > per-section DB > global config > defaults.
This commit is contained in:
Fringg
2026-02-12 23:15:58 +03:00
parent 46c1a69456
commit a9687912df
6 changed files with 339 additions and 9 deletions

View File

@@ -238,6 +238,15 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
elif settings.is_cabinet_mode():
logger.info(f'🏠 Режим Cabinet активен, базовый URL: {settings.MINIAPP_CUSTOM_URL}')
# Load per-section button styles cache
if settings.is_cabinet_mode():
try:
from app.utils.button_styles_cache import load_button_styles_cache
await load_button_styles_cache()
except Exception as e:
logger.warning(f'Failed to load button styles cache: {e}')
logger.info('Бот успешно настроен')
return bot, dp

View File

@@ -5,6 +5,7 @@ from fastapi import APIRouter
from .admin_apps import router as admin_apps_router
from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router
from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_payment_methods import router as admin_payment_methods_router
@@ -91,6 +92,7 @@ router.include_router(admin_email_templates_router)
router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
router.include_router(admin_pinned_messages_router)
router.include_router(admin_button_styles_router)
# WebSocket route
router.include_router(websocket_router)

View File

@@ -0,0 +1,195 @@
"""Admin routes for per-section cabinet button style configuration."""
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.utils.button_styles_cache import (
BUTTON_STYLES_KEY,
DEFAULT_BUTTON_STYLES,
SECTIONS,
VALID_STYLES,
load_button_styles_cache,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles'])
# ---- Schemas ---------------------------------------------------------------
class ButtonSectionConfig(BaseModel):
"""Configuration for a single button section."""
style: str = 'primary'
icon_custom_emoji_id: str = ''
class ButtonStylesResponse(BaseModel):
"""Full button styles configuration (all 7 sections)."""
home: ButtonSectionConfig = ButtonSectionConfig()
subscription: ButtonSectionConfig = ButtonSectionConfig()
balance: ButtonSectionConfig = ButtonSectionConfig()
referral: ButtonSectionConfig = ButtonSectionConfig()
support: ButtonSectionConfig = ButtonSectionConfig()
info: ButtonSectionConfig = ButtonSectionConfig()
admin: ButtonSectionConfig = ButtonSectionConfig()
class ButtonSectionUpdate(BaseModel):
"""Partial update for a single section (None = keep current)."""
style: str | None = None
icon_custom_emoji_id: str | None = None
class ButtonStylesUpdate(BaseModel):
"""Partial update — only include sections you want to change."""
home: ButtonSectionUpdate | None = None
subscription: ButtonSectionUpdate | None = None
balance: ButtonSectionUpdate | None = None
referral: ButtonSectionUpdate | None = None
support: ButtonSectionUpdate | None = None
info: ButtonSectionUpdate | None = None
admin: ButtonSectionUpdate | None = None
# ---- Helpers ---------------------------------------------------------------
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def _set_setting_value(db: AsyncSession, key: str, value: str) -> None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = SystemSetting(key=key, value=value)
db.add(setting)
await db.commit()
def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
return ButtonStylesResponse(
**{section: ButtonSectionConfig(**cfg) for section, cfg in styles.items() if section in SECTIONS},
)
# ---- Routes ----------------------------------------------------------------
@router.get('', response_model=ButtonStylesResponse)
async def get_button_styles(
_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return current per-section button styles. Admin only."""
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
merged = {section: {**cfg} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
if raw:
try:
db_data = json.loads(raw)
for section, overrides in db_data.items():
if section in merged and isinstance(overrides, dict):
if overrides.get('style') in VALID_STYLES:
merged[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
except (json.JSONDecodeError, TypeError):
pass
return _build_response(merged)
@router.patch('', response_model=ButtonStylesResponse)
async def update_button_styles(
payload: ButtonStylesUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Partially update per-section button styles. Admin only."""
# Load current state
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
current: dict[str, dict] = {section: {**cfg} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
if raw:
try:
db_data = json.loads(raw)
for section, overrides in db_data.items():
if section in current and isinstance(overrides, dict):
current[section].update(overrides)
except (json.JSONDecodeError, TypeError):
pass
# Apply updates
update_data = payload.model_dump(exclude_none=True)
changed_sections: list[str] = []
for section, updates in update_data.items():
if section not in current or not isinstance(updates, dict):
continue
if 'style' in updates:
style_val = updates['style']
if style_val not in VALID_STYLES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid style "{style_val}" for section "{section}". '
f'Allowed: {", ".join(sorted(VALID_STYLES))}',
)
current[section]['style'] = style_val
if 'icon_custom_emoji_id' in updates:
emoji_val = (updates['icon_custom_emoji_id'] or '').strip()
current[section]['icon_custom_emoji_id'] = emoji_val
changed_sections.append(section)
# Persist
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(current))
# Refresh in-process cache
await load_button_styles_cache()
logger.info('Admin %s updated button styles for sections: %s', admin.telegram_id, changed_sections)
return _build_response(current)
@router.post('/reset', response_model=ButtonStylesResponse)
async def reset_button_styles(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset all button styles to defaults. Admin only."""
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
await load_button_styles_cache()
logger.info('Admin %s reset button styles to defaults', admin.telegram_id)
return _build_response(DEFAULT_BUTTON_STYLES)

View File

@@ -375,6 +375,7 @@ def _build_cabinet_main_menu_keyboard(
Each button opens the corresponding section of the cabinet frontend
via ``MINIAPP_CUSTOM_URL`` + path (e.g. ``/subscription``, ``/balance``).
"""
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
from app.utils.miniapp_buttons import (
CALLBACK_TO_CABINET_STYLE,
_resolve_style,
@@ -382,6 +383,7 @@ def _build_cabinet_main_menu_keyboard(
)
global_style = _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip())
cached_styles = get_cached_button_styles()
def _cabinet_button(
text: str,
@@ -393,26 +395,36 @@ def _build_cabinet_main_menu_keyboard(
) -> InlineKeyboardButton:
url = build_cabinet_url(path)
if url:
resolved = _resolve_style(style or global_style or CALLBACK_TO_CABINET_STYLE.get(callback_fallback))
section = CALLBACK_TO_SECTION.get(callback_fallback)
section_cfg = cached_styles.get(section or '', {}) if section else {}
resolved = _resolve_style(
style
or _resolve_style(section_cfg.get('style'))
or global_style
or CALLBACK_TO_CABINET_STYLE.get(callback_fallback)
)
resolved_emoji = icon_custom_emoji_id or section_cfg.get('icon_custom_emoji_id') or None
return InlineKeyboardButton(
text=text,
web_app=types.WebAppInfo(url=url),
style=resolved,
icon_custom_emoji_id=icon_custom_emoji_id or None,
icon_custom_emoji_id=resolved_emoji or None,
)
return InlineKeyboardButton(text=text, callback_data=callback_fallback)
# -- Primary action row: Cabinet home --
profile_text = texts.t('MENU_PROFILE', '👤 Личный кабинет')
keyboard_rows: list[list[InlineKeyboardButton]] = [
[_cabinet_button(profile_text, '/', 'menu_profile_unavailable', style='primary')],
[_cabinet_button(profile_text, '/', 'menu_profile_unavailable')],
]
# -- Section buttons as paired rows --
paired: list[InlineKeyboardButton] = []
# Subscription (green — main action)
paired.append(_cabinet_button(texts.MENU_SUBSCRIPTION, '/subscription', 'menu_subscription', style='success'))
paired.append(_cabinet_button(texts.MENU_SUBSCRIPTION, '/subscription', 'menu_subscription'))
# Balance
safe_balance = balance_kopeks or 0
@@ -422,11 +434,11 @@ def _build_cabinet_main_menu_keyboard(
balance_text = texts.t('BALANCE_BUTTON_DEFAULT', '💰 Баланс: {balance}').format(
balance=texts.format_price(safe_balance),
)
paired.append(_cabinet_button(balance_text, '/balance', 'menu_balance', style='primary'))
paired.append(_cabinet_button(balance_text, '/balance', 'menu_balance'))
# Referrals (if enabled)
if settings.is_referral_program_enabled():
paired.append(_cabinet_button(texts.MENU_REFERRALS, '/referral', 'menu_referrals', style='success'))
paired.append(_cabinet_button(texts.MENU_REFERRALS, '/referral', 'menu_referrals'))
# Support
support_enabled = False
@@ -462,7 +474,7 @@ def _build_cabinet_main_menu_keyboard(
keyboard_rows.append(
[
InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel'),
_cabinet_button('🖥 Веб-Админка', '/admin', 'admin_panel', style='danger'),
_cabinet_button('🖥 Веб-Админка', '/admin', 'admin_panel'),
]
)
elif is_moderator:

View File

@@ -0,0 +1,102 @@
"""Lightweight in-process cache for per-section cabinet button styles.
Avoids circular imports between ``cabinet.routes`` and ``app.utils.miniapp_buttons``
by keeping the cache and its helpers in a dedicated module.
"""
import json
import logging
from app.database.database import AsyncSessionLocal
logger = logging.getLogger(__name__)
# ---- Defaults per section ------------------------------------------------
DEFAULT_BUTTON_STYLES: dict[str, dict] = {
'home': {'style': 'primary', 'icon_custom_emoji_id': ''},
'subscription': {'style': 'success', 'icon_custom_emoji_id': ''},
'balance': {'style': 'primary', 'icon_custom_emoji_id': ''},
'referral': {'style': 'success', 'icon_custom_emoji_id': ''},
'support': {'style': 'primary', 'icon_custom_emoji_id': ''},
'info': {'style': 'primary', 'icon_custom_emoji_id': ''},
'admin': {'style': 'danger', 'icon_custom_emoji_id': ''},
}
SECTIONS = list(DEFAULT_BUTTON_STYLES.keys())
# Map callback_data values to their logical section name.
CALLBACK_TO_SECTION: dict[str, str] = {
'menu_profile_unavailable': 'home',
'back_to_menu': 'home',
'menu_subscription': 'subscription',
'subscription': 'subscription',
'subscription_extend': 'subscription',
'subscription_upgrade': 'subscription',
'subscription_connect': 'subscription',
'subscription_resume_checkout': 'subscription',
'return_to_saved_cart': 'subscription',
'menu_buy': 'subscription',
'buy_traffic': 'subscription',
'menu_balance': 'balance',
'balance_topup': 'balance',
'menu_referrals': 'referral',
'menu_referral': 'referral',
'menu_support': 'support',
'menu_info': 'info',
'admin_panel': 'admin',
}
# DB key used for storage.
BUTTON_STYLES_KEY = 'CABINET_BUTTON_STYLES'
# Valid Telegram Bot API style values.
VALID_STYLES = frozenset({'primary', 'success', 'danger'})
# ---- Module-level cache ---------------------------------------------------
_cached_styles: dict[str, dict] | None = None
def get_cached_button_styles() -> dict[str, dict]:
"""Return the current merged config (DB overrides + defaults).
If the cache has not been loaded yet, returns defaults.
"""
if _cached_styles is not None:
return _cached_styles
return {section: {**cfg} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
async def load_button_styles_cache() -> dict[str, dict]:
"""Load button styles from DB and refresh the module cache.
Called at bot startup and after admin updates via the cabinet API.
"""
global _cached_styles
merged = {section: {**cfg} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
try:
from sqlalchemy import select
from app.database.models import SystemSetting
async with AsyncSessionLocal() as session:
result = await session.execute(select(SystemSetting).where(SystemSetting.key == BUTTON_STYLES_KEY))
setting = result.scalar_one_or_none()
if setting and setting.value:
db_data: dict = json.loads(setting.value)
for section, overrides in db_data.items():
if section in merged and isinstance(overrides, dict):
if overrides.get('style') in VALID_STYLES:
merged[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
except Exception:
logger.exception('Failed to load button styles from DB, using defaults')
_cached_styles = merged
logger.info('Button styles cache loaded: %s', list(merged.keys()))
return merged

View File

@@ -2,6 +2,7 @@ from aiogram import types
from aiogram.types import InlineKeyboardButton
from app.config import settings
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
# Mapping from callback_data to cabinet frontend paths.
@@ -129,17 +130,26 @@ def build_miniapp_or_callback_button(
if path:
url = build_cabinet_url(path)
if url:
# Resolve style: explicit > global config > per-section default
# Resolve per-section config from cache
section = CALLBACK_TO_SECTION.get(callback_data)
section_cfg = get_cached_button_styles().get(section or '', {}) if section else {}
# Style chain: explicit param > per-section DB > global config > hardcoded default
resolved_style = _resolve_style(
style
or _resolve_style(section_cfg.get('style'))
or _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip())
or CALLBACK_TO_CABINET_STYLE.get(callback_data)
)
# Emoji chain: explicit param > per-section DB
resolved_emoji = icon_custom_emoji_id or section_cfg.get('icon_custom_emoji_id') or None
return InlineKeyboardButton(
text=text,
web_app=types.WebAppInfo(url=url),
style=resolved_style,
icon_custom_emoji_id=icon_custom_emoji_id or None,
icon_custom_emoji_id=resolved_emoji or None,
)
return InlineKeyboardButton(text=text, callback_data=callback_data)