From a9687912dfe756e7d772d96cc253f78f2e97185c Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 12 Feb 2026 23:15:58 +0300 Subject: [PATCH] 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. --- app/bot.py | 9 + app/cabinet/routes/__init__.py | 2 + app/cabinet/routes/admin_button_styles.py | 195 ++++++++++++++++++++++ app/keyboards/inline.py | 26 ++- app/utils/button_styles_cache.py | 102 +++++++++++ app/utils/miniapp_buttons.py | 14 +- 6 files changed, 339 insertions(+), 9 deletions(-) create mode 100644 app/cabinet/routes/admin_button_styles.py create mode 100644 app/utils/button_styles_cache.py diff --git a/app/bot.py b/app/bot.py index 392b0ffd..369fccc8 100644 --- a/app/bot.py +++ b/app/bot.py @@ -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 diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index c879d128..4a1f142b 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -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) diff --git a/app/cabinet/routes/admin_button_styles.py b/app/cabinet/routes/admin_button_styles.py new file mode 100644 index 00000000..b46f97de --- /dev/null +++ b/app/cabinet/routes/admin_button_styles.py @@ -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) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index e63d4f64..b65a6c29 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -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: diff --git a/app/utils/button_styles_cache.py b/app/utils/button_styles_cache.py new file mode 100644 index 00000000..1ae367b7 --- /dev/null +++ b/app/utils/button_styles_cache.py @@ -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 diff --git a/app/utils/miniapp_buttons.py b/app/utils/miniapp_buttons.py index 1b83ba89..7adadd7f 100644 --- a/app/utils/miniapp_buttons.py +++ b/app/utils/miniapp_buttons.py @@ -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)