From 3964ed1f3ce21c56f4b13d907c8e284b4e212561 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 4 Oct 2025 06:07:35 +0300 Subject: [PATCH] Improve pricing admin panel UX --- app/handlers/admin/pricing.py | 764 ++++++++++++++++++++++++++++++++-- 1 file changed, 723 insertions(+), 41 deletions(-) diff --git a/app/handlers/admin/pricing.py b/app/handlers/admin/pricing.py index dbccaa83..e505b9fe 100644 --- a/app/handlers/admin/pricing.py +++ b/app/handlers/admin/pricing.py @@ -1,6 +1,7 @@ import logging +from dataclasses import dataclass from decimal import Decimal, InvalidOperation, ROUND_HALF_UP -from typing import Iterable, List, Tuple +from typing import Iterable, List, Tuple, Dict, Any from aiogram import Bot, Dispatcher, F, types from aiogram.fsm.context import FSMContext @@ -20,6 +21,163 @@ logger = logging.getLogger(__name__) PriceItem = Tuple[str, str, int] +@dataclass(slots=True) +class ChoiceOption: + value: Any + label_ru: str + label_en: str | None = None + + def label(self, lang_code: str) -> str: + if lang_code == "ru": + return self.label_ru + return self.label_en or self.label_ru + + +@dataclass(slots=True) +class SettingEntry: + key: str + section: str + label_ru: str + label_en: str + action: str # "input", "toggle", "price", "choice" + description_ru: str | None = None + description_en: str | None = None + choices: Tuple[ChoiceOption, ...] | None = None + + def label(self, lang_code: str) -> str: + if lang_code == "ru": + return self.label_ru + return self.label_en or self.label_ru + + def description(self, lang_code: str) -> str | None: + if lang_code == "ru": + return self.description_ru + return self.description_en or self.description_ru + + +TRIAL_ENTRIES: Tuple[SettingEntry, ...] = ( + SettingEntry( + key="TRIAL_DURATION_DAYS", + section="trial", + label_ru="⏳ Длительность (дни)", + label_en="⏳ Duration (days)", + action="input", + ), + SettingEntry( + key="TRIAL_TRAFFIC_LIMIT_GB", + section="trial", + label_ru="📦 Лимит трафика (ГБ)", + label_en="📦 Traffic limit (GB)", + action="input", + ), + SettingEntry( + key="TRIAL_DEVICE_LIMIT", + section="trial", + label_ru="📱 Лимит устройств", + label_en="📱 Device limit", + action="input", + ), + SettingEntry( + key="TRIAL_ADD_REMAINING_DAYS_TO_PAID", + section="trial", + label_ru="➕ Добавлять оставшиеся дни к платной подписке", + label_en="➕ Add remaining trial days to paid plan", + action="toggle", + description_ru="Если включено — при покупке платной подписки оставшиеся дни триала будут добавлены к сроку.", + description_en="When enabled, remaining trial days are added to paid subscription duration.", + ), + SettingEntry( + key="TRIAL_SQUAD_UUID", + section="trial", + label_ru="🆔 Squad UUID", + label_en="🆔 Squad UUID", + action="input", + description_ru="Можно оставить пустым, если не требуется назначать пробные подписки в конкретный Squad.", + description_en="Leave empty if trial subscriptions shouldn't be bound to a specific Squad.", + ), +) + + +CORE_PRICING_ENTRIES: Tuple[SettingEntry, ...] = ( + SettingEntry( + key="BASE_SUBSCRIPTION_PRICE", + section="core", + label_ru="💳 Базовая стоимость подписки", + label_en="💳 Base subscription price", + action="price", + ), + SettingEntry( + key="DEFAULT_DEVICE_LIMIT", + section="core", + label_ru="📱 Устройств по умолчанию", + label_en="📱 Default device limit", + action="input", + ), + SettingEntry( + key="DEFAULT_TRAFFIC_LIMIT_GB", + section="core", + label_ru="📦 Трафик по умолчанию (ГБ)", + label_en="📦 Default traffic (GB)", + action="input", + ), + SettingEntry( + key="MAX_DEVICES_LIMIT", + section="core", + label_ru="📈 Максимум устройств", + label_en="📈 Maximum devices", + action="input", + ), + SettingEntry( + key="RESET_TRAFFIC_ON_PAYMENT", + section="core", + label_ru="🔄 Сбрасывать трафик при оплате", + label_en="🔄 Reset traffic on payment", + action="toggle", + ), + SettingEntry( + key="DEFAULT_TRAFFIC_RESET_STRATEGY", + section="core", + label_ru="🗓 Стратегия сброса трафика", + label_en="🗓 Traffic reset strategy", + action="input", + description_ru="Доступные значения: DAY, WEEK, MONTH, NEVER.", + description_en="Available values: DAY, WEEK, MONTH, NEVER.", + ), + SettingEntry( + key="TRAFFIC_SELECTION_MODE", + section="core", + label_ru="⚙️ Режим выбора трафика", + label_en="⚙️ Traffic selection mode", + action="choice", + choices=( + ChoiceOption("selectable", "Выбор пакетов", "Selectable"), + ChoiceOption("fixed", "Фиксированный лимит", "Fixed limit"), + ), + description_ru="Определяет, выбирают ли пользователи пакеты или получают фиксированный лимит.", + description_en="Defines whether users pick packages or use a fixed limit.", + ), + SettingEntry( + key="FIXED_TRAFFIC_LIMIT_GB", + section="core", + label_ru="📏 Фиксированный лимит трафика (ГБ)", + label_en="📏 Fixed traffic limit (GB)", + action="input", + description_ru="Используется только в режиме фиксированного трафика. 0 = безлимит.", + description_en="Used only in fixed traffic mode. 0 = unlimited.", + ), +) + + +SETTING_ENTRIES_BY_SECTION: Dict[str, Tuple[SettingEntry, ...]] = { + "trial": TRIAL_ENTRIES, + "core": CORE_PRICING_ENTRIES, +} + +SETTING_ENTRY_BY_KEY: Dict[str, SettingEntry] = { + entry.key: entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries +} + + def _language_code(language: str | None) -> str: return (language or "ru").split("-")[0].lower() @@ -44,6 +202,29 @@ def _format_traffic_label(gb: int, lang_code: str, short: bool = False) -> str: return f"{gb} {unit}" +def _format_trial_summary(lang_code: str) -> str: + duration = settings.TRIAL_DURATION_DAYS + traffic = settings.TRIAL_TRAFFIC_LIMIT_GB + devices = settings.TRIAL_DEVICE_LIMIT + + traffic_label = _format_traffic_label(traffic, lang_code, short=True) + devices_label = f"{devices}📱" if lang_code == "ru" else f"{devices}📱" + days_suffix = "д" if lang_code == "ru" else "d" + return f"{duration}{days_suffix}, {traffic_label}, {devices_label}" + + +def _format_core_summary(lang_code: str) -> str: + base_price = settings.format_price(settings.BASE_SUBSCRIPTION_PRICE) + device_limit = settings.DEFAULT_DEVICE_LIMIT + traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB + if settings.TRAFFIC_SELECTION_MODE == "fixed": + traffic_mode = "⚙️ fixed" + else: + traffic_mode = "⚙️ selectable" + traffic_label = _format_traffic_label(traffic_limit, lang_code, short=True) + return f"{base_price}, {device_limit}📱, {traffic_label}, {traffic_mode}" + + def _get_period_items(lang_code: str) -> List[PriceItem]: items: List[PriceItem] = [] for days in settings.get_available_subscription_periods(): @@ -119,11 +300,181 @@ def _build_traffic_summary(items: Iterable[PriceItem], lang_code: str, fallback: return ", ".join(parts) if parts else fallback +def _build_period_options_summary(lang_code: str) -> str: + suffix = "д" if lang_code == "ru" else "d" + available = ", ".join(f"{days}{suffix}" for days in settings.get_available_subscription_periods()) + renewal = ", ".join(f"{days}{suffix}" for days in settings.get_available_renewal_periods()) + if lang_code == "ru": + return f"Подписки: {available or '—'} | Продления: {renewal or '—'}" + return f"Subscriptions: {available or '-'} | Renewals: {renewal or '-'}" + + def _build_extra_summary(items: Iterable[PriceItem], fallback: str) -> str: parts = [f"{label}: {settings.format_price(price)}" for key, label, price in items] return ", ".join(parts) if parts else fallback +def _build_settings_section( + section: str, + language: str, +) -> Tuple[str, types.InlineKeyboardMarkup]: + texts = get_texts(language) + lang_code = _language_code(language) + entries = SETTING_ENTRIES_BY_SECTION.get(section, ()) + + if section == "trial": + title = texts.t("ADMIN_PRICING_SECTION_TRIAL_TITLE", "🎁 Пробный период") + elif section == "core": + title = texts.t("ADMIN_PRICING_SECTION_CORE_TITLE", "⚙️ Настройки тарифов") + else: + title = texts.t("ADMIN_PRICING_SECTION_SETTINGS_GENERIC", "⚙️ Настройки") + + lines: List[str] = [title, ""] + keyboard_rows: List[List[types.InlineKeyboardButton]] = [] + + if entries: + lines.append( + texts.t( + "ADMIN_PRICING_SECTION_CURRENT", + "Текущие значения:", + ) + ) + lines.append("") + + for entry in entries: + label = entry.label(lang_code) + value = bot_configuration_service.get_current_value(entry.key) + formatted = bot_configuration_service.format_value_human(entry.key, value) + + if entry.action == "toggle": + state_icon = "✅" if bool(value) else "⚪️" + lines.append(f"{state_icon} {label} — {formatted}") + button_text = texts.t( + "ADMIN_PRICING_SETTING_TOGGLE_STATEFUL", + "{icon} {label}", + ).format(icon=state_icon, label=label) + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text=button_text, + callback_data=f"admin_pricing_toggle:{section}:{entry.key}", + ) + ] + ) + elif entry.action == "choice" and entry.choices: + lines.append(f"• {label}: {formatted}") + buttons: List[types.InlineKeyboardButton] = [] + for option in entry.choices: + is_active = value == option.value + icon = "✅" if is_active else "⚪️" + buttons.append( + types.InlineKeyboardButton( + text=f"{icon} {option.label(lang_code)}", + callback_data=( + f"admin_pricing_choice:{section}:{entry.key}:{option.value}" + ), + ) + ) + for i in range(0, len(buttons), 2): + keyboard_rows.append(buttons[i : i + 2]) + else: + lines.append(f"• {label}: {formatted}") + button_text = texts.t( + "ADMIN_PRICING_SETTING_EDIT_WITH_VALUE", + "✏️ {label} • {value}", + ).format(label=label, value=formatted) + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text=button_text, + callback_data=f"admin_pricing_setting:{section}:{entry.key}", + ) + ] + ) + + description = entry.description(lang_code) + if description: + lines.append(f"{description}") + lines.append("") + + if entries: + lines.append(texts.t("ADMIN_PRICING_SECTION_PROMPT", "Выберите что изменить:")) + else: + lines.append(texts.t("ADMIN_PRICING_SECTION_EMPTY", "Нет параметров для изменения.")) + + keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_pricing")]) + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + return "\n".join(lines).strip(), keyboard + + +def _build_period_options_section(language: str) -> Tuple[str, types.InlineKeyboardMarkup]: + texts = get_texts(language) + lang_code = _language_code(language) + suffix = "д" if lang_code == "ru" else "d" + + available_subscription = set(settings.get_available_subscription_periods()) + available_renewal = set(settings.get_available_renewal_periods()) + + subscription_options = (14, 30, 60, 90, 180, 360) + renewal_options = (30, 60, 90, 180, 360) + + title = texts.t("ADMIN_PRICING_SECTION_PERIOD_OPTIONS_TITLE", "🗓 Доступные периоды") + lines: List[str] = [title, ""] + + sub_list = ", ".join(f"{days}{suffix}" for days in sorted(available_subscription)) or "—" + renew_list = ", ".join(f"{days}{suffix}" for days in sorted(available_renewal)) or "—" + + lines.append( + texts.t( + "ADMIN_PRICING_SECTION_PERIOD_OPTIONS_SUB", + "Активные периоды подписки: {items}", + ).format(items=sub_list) + ) + lines.append( + texts.t( + "ADMIN_PRICING_SECTION_PERIOD_OPTIONS_RENEW", + "Активные периоды продления: {items}", + ).format(items=renew_list) + ) + lines.append("") + lines.append( + texts.t( + "ADMIN_PRICING_SECTION_PERIOD_OPTIONS_PROMPT", + "Нажмите на период, чтобы включить или выключить его отображение.", + ) + ) + + keyboard_rows: List[List[types.InlineKeyboardButton]] = [] + + sub_buttons = [] + for days in subscription_options: + icon = "✅" if days in available_subscription else "⚪️" + sub_buttons.append( + types.InlineKeyboardButton( + text=f"{icon} {days}{suffix}", + callback_data=f"admin_pricing_toggle_period:subscription:{days}", + ) + ) + for i in range(0, len(sub_buttons), 3): + keyboard_rows.append(sub_buttons[i : i + 3]) + + renew_buttons = [] + for days in renewal_options: + icon = "✅" if days in available_renewal else "⚪️" + renew_buttons.append( + types.InlineKeyboardButton( + text=f"{icon} {days}{suffix}", + callback_data=f"admin_pricing_toggle_period:renewal:{days}", + ) + ) + for i in range(0, len(renew_buttons), 3): + keyboard_rows.append(renew_buttons[i : i + 3]) + + keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_pricing")]) + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + return "\n".join(lines), keyboard + + def _build_overview(language: str) -> Tuple[str, types.InlineKeyboardMarkup]: texts = get_texts(language) lang_code = _language_code(language) @@ -136,42 +487,65 @@ def _build_overview(language: str) -> Tuple[str, types.InlineKeyboardMarkup]: summary_periods = _build_period_summary(period_items, lang_code, fallback) summary_traffic = _build_traffic_summary(traffic_items, lang_code, fallback) summary_extra = _build_extra_summary(extra_items, fallback) + summary_trial = _format_trial_summary(lang_code) + summary_core = _format_core_summary(lang_code) + summary_period_options = _build_period_options_summary(lang_code) - text = ( - f"💰 {texts.t('ADMIN_PRICING_MENU_TITLE', 'Управление ценами')}\n\n" - f"{texts.t('ADMIN_PRICING_MENU_DESCRIPTION', 'Быстрый доступ к тарифам и пакетам.')}\n\n" - f"{texts.t('ADMIN_PRICING_MENU_SUMMARY', 'Краткая сводка:')}\n" - f"{texts.t('ADMIN_PRICING_MENU_SUMMARY_PERIODS', '• Периоды: {summary}').format(summary=summary_periods)}\n" - f"{texts.t('ADMIN_PRICING_MENU_SUMMARY_TRAFFIC', '• Трафик: {summary}').format(summary=summary_traffic)}\n" - f"{texts.t('ADMIN_PRICING_MENU_SUMMARY_EXTRA', '• Дополнительно: {summary}').format(summary=summary_extra)}\n\n" - f"{texts.t('ADMIN_PRICING_MENU_PROMPT', 'Выберите раздел для редактирования:')}" - ) + lines = [ + f"💰 {texts.t('ADMIN_PRICING_MENU_TITLE', 'Управление ценами')}", + texts.t( + 'ADMIN_PRICING_MENU_DESCRIPTION', + 'Быстрый доступ к настройкам тарифов, периодов и пакетов.', + ), + "", + f"{texts.t('ADMIN_PRICING_MENU_SUMMARY', 'Краткая сводка')}", + f"🎁 {texts.t('ADMIN_PRICING_MENU_SUMMARY_TRIAL', 'Триал: {summary}').format(summary=summary_trial)}", + f"⚙️ {texts.t('ADMIN_PRICING_MENU_SUMMARY_CORE', 'Базовые лимиты: {summary}').format(summary=summary_core)}", + f"🗓 {texts.t('ADMIN_PRICING_MENU_SUMMARY_PERIOD_OPTIONS', 'Доступные периоды: {summary}').format(summary=summary_period_options)}", + f"💵 {texts.t('ADMIN_PRICING_MENU_SUMMARY_PERIODS', 'Стоимость периодов: {summary}').format(summary=summary_periods)}", + f"📦 {texts.t('ADMIN_PRICING_MENU_SUMMARY_TRAFFIC', 'Пакеты трафика: {summary}').format(summary=summary_traffic)}", + f"➕ {texts.t('ADMIN_PRICING_MENU_SUMMARY_EXTRA', 'Дополнительно: {summary}').format(summary=summary_extra)}", + "", + texts.t('ADMIN_PRICING_MENU_PROMPT', 'Выберите раздел для редактирования:'), + ] keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("ADMIN_PRICING_BUTTON_PERIODS", "🗓 Периоды подписки"), + text=texts.t("ADMIN_PRICING_BUTTON_TRIAL", "🎁 Пробный период"), + callback_data="admin_pricing_section:trial", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_PRICING_BUTTON_CORE", "⚙️ Настройки тарифов"), + callback_data="admin_pricing_section:core", + ), + ], + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_PRICING_BUTTON_PERIOD_OPTIONS", "🗓 Доступные периоды"), + callback_data="admin_pricing_section:period_options", + ), + types.InlineKeyboardButton( + text=texts.t("ADMIN_PRICING_BUTTON_PERIODS", "💵 Стоимость периодов"), callback_data="admin_pricing_section:periods", - ) + ), ], [ types.InlineKeyboardButton( text=texts.t("ADMIN_PRICING_BUTTON_TRAFFIC", "📦 Пакеты трафика"), callback_data="admin_pricing_section:traffic", - ) - ], - [ + ), types.InlineKeyboardButton( text=texts.t("ADMIN_PRICING_BUTTON_EXTRA", "➕ Дополнительно"), callback_data="admin_pricing_section:extra", - ) + ), ], [types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")], ] ) - return text, keyboard + return "\n".join(lines), keyboard def _build_section( @@ -187,6 +561,13 @@ def _build_section( elif section == "traffic": items = _get_traffic_items(lang_code) title = texts.t("ADMIN_PRICING_SECTION_TRAFFIC_TITLE", "📦 Пакеты трафика") + elif section == "extra": + items = _get_extra_items(lang_code) + title = texts.t("ADMIN_PRICING_SECTION_EXTRA_TITLE", "➕ Дополнительные опции") + elif section in SETTING_ENTRIES_BY_SECTION: + return _build_settings_section(section, language) + elif section == "period_options": + return _build_period_options_section(language) else: items = _get_extra_items(lang_code) title = texts.t("ADMIN_PRICING_SECTION_EXTRA_TITLE", "➕ Дополнительные опции") @@ -220,6 +601,25 @@ def _build_section( return "\n".join(lines), keyboard +def _build_price_prompt(texts: Any, label: str, current_price: str) -> str: + lines = [ + f"💰 {texts.t('ADMIN_PRICING_EDIT_TITLE', 'Изменение цены')}", + "", + f"{texts.t('ADMIN_PRICING_EDIT_TARGET', 'Текущий тариф')}: {label}", + f"{texts.t('ADMIN_PRICING_EDIT_CURRENT', 'Текущее значение')}: {current_price}", + "", + texts.t( + 'ADMIN_PRICING_EDIT_PROMPT', + 'Введите новую стоимость в рублях (например 990 или 990.50). Для бесплатного тарифа укажите 0.', + ), + texts.t( + 'ADMIN_PRICING_EDIT_CANCEL_HINT', + 'Напишите «Отмена», чтобы вернуться без изменений.', + ), + ] + return "\n".join(lines) + + async def _render_message( message: types.Message, text: str, @@ -273,6 +673,10 @@ def _parse_price_input(text: str) -> int: def _resolve_label(section: str, key: str, language: str) -> str: lang_code = _language_code(language) + entry = SETTING_ENTRY_BY_KEY.get(key) + if entry is not None: + return entry.label(lang_code) + if section == "periods" and key.startswith("PRICE_") and key.endswith("_DAYS"): try: days = int(key.replace("PRICE_", "").replace("_DAYS", "")) @@ -343,15 +747,12 @@ async def start_price_edit( pricing_key=key, pricing_section=section, pricing_message_id=callback.message.message_id, + pricing_mode="price", ) await state.set_state(PricingStates.waiting_for_value) - prompt = ( - f"💰 {texts.t('ADMIN_PRICING_EDIT_TITLE', 'Изменение цены')}\n\n" - f"{texts.t('ADMIN_PRICING_EDIT_TARGET', 'Текущий тариф')}: {label}\n" - f"{texts.t('ADMIN_PRICING_EDIT_CURRENT', 'Текущее значение')}: {settings.format_price(getattr(settings, key, 0))}\n\n" - f"{texts.t('ADMIN_PRICING_EDIT_PROMPT', 'Введите новую стоимость в рублях (например 990 или 990.50). Для бесплатного тарифа укажите 0.')}" - ) + current_price = settings.format_price(getattr(settings, key, 0)) + prompt = _build_price_prompt(texts, label, current_price) keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ @@ -368,7 +769,100 @@ async def start_price_edit( await callback.answer() -async def process_price_input( +@admin_required +@error_handler +async def start_setting_edit( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +) -> None: + try: + _, section, key = callback.data.split(":", 2) + except ValueError: + await callback.answer() + return + + entry = SETTING_ENTRY_BY_KEY.get(key) + texts = get_texts(db_user.language) + lang_code = _language_code(db_user.language) + label = entry.label(lang_code) if entry else key + current_value = bot_configuration_service.get_current_value(key) + formatted_current = bot_configuration_service.format_value_human(key, current_value) + guidance = bot_configuration_service.get_setting_guidance(key) + + mode = "price" if entry and entry.action == "price" else "setting" + + await state.update_data( + pricing_key=key, + pricing_section=section, + pricing_message_id=callback.message.message_id, + pricing_mode=mode, + pricing_label=label, + ) + await state.set_state(PricingStates.waiting_for_value) + + if mode == "price": + prompt = _build_price_prompt( + texts, + label, + settings.format_price(int(current_value or 0)), + ) + else: + description = guidance.get("description") or "" + format_hint = guidance.get("format") or "" + example = guidance.get("example") or "—" + warning = guidance.get("warning") or "" + prompt_parts = [ + f"⚙️ {texts.t('ADMIN_PRICING_SETTING_EDIT_TITLE', 'Настройка параметра')}", + "", + f"{texts.t('ADMIN_PRICING_SETTING_PARAMETER', 'Параметр')}: {label}", + f"{texts.t('ADMIN_PRICING_SETTING_CURRENT', 'Текущее значение')}: {formatted_current}", + ] + if description: + prompt_parts.extend(["", description]) + prompt_parts.extend( + [ + "", + f"ℹ️ {texts.t('ADMIN_PRICING_SETTING_FORMAT', 'Формат ввода')}: {format_hint}", + f"📌 {texts.t('ADMIN_PRICING_SETTING_EXAMPLE', 'Пример')}: {example}", + ] + ) + if warning: + prompt_parts.append( + f"⚠️ {texts.t('ADMIN_PRICING_SETTING_WARNING', 'Важно')}: {warning}" + ) + prompt_parts.extend( + [ + "", + texts.t( + 'ADMIN_PRICING_SETTING_PROMPT', + 'Отправьте новое значение или напишите «Отмена». Для очистки используйте none.', + ), + texts.t( + 'ADMIN_PRICING_SETTING_CANCEL_HINT', + 'Чтобы вернуться без изменений, ответьте «Отмена».', + ), + ] + ) + prompt = "\n".join(prompt_parts) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_PRICING_EDIT_CANCEL", "❌ Отмена"), + callback_data=f"admin_pricing_section:{section}", + ) + ] + ] + ) + + await _render_message(callback.message, prompt, keyboard) + await callback.answer() + + +async def process_pricing_input( message: types.Message, state: FSMContext, db_user: User, @@ -378,6 +872,8 @@ async def process_price_input( key = data.get("pricing_key") section = data.get("pricing_section", "periods") message_id = data.get("pricing_message_id") + mode = data.get("pricing_mode", "price") + stored_label = data.get("pricing_label") texts = get_texts(db_user.language) @@ -401,27 +897,52 @@ async def process_price_input( await message.answer(texts.t("ADMIN_PRICING_EDIT_CANCELLED", "Изменения отменены.")) return - try: - price_kopeks = _parse_price_input(raw_value) - except ValueError: - await message.answer( - texts.t( - "ADMIN_PRICING_EDIT_INVALID", - "Не удалось распознать цену. Укажите число в рублях (например 990 или 990.50).", + if mode == "price": + try: + new_value = _parse_price_input(raw_value) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PRICING_EDIT_INVALID", + "Не удалось распознать цену. Укажите число в рублях (например 990 или 990.50).", + ) ) - ) - return + return + else: + try: + new_value = bot_configuration_service.parse_user_value(key, raw_value) + except ValueError as error: + error_text = str(error) or texts.t( + "ADMIN_PRICING_SETTING_INVALID", + "Не удалось обновить параметр. Проверьте формат значения.", + ) + await message.answer(error_text) + return - await bot_configuration_service.set_value(db, key, price_kopeks) + await bot_configuration_service.set_value(db, key, new_value) await db.commit() - label = _resolve_label(section, key, db_user.language) - await message.answer( - texts.t("ADMIN_PRICING_EDIT_SUCCESS", "Цена для {item} обновлена: {price}").format( - item=label, - price=settings.format_price(price_kopeks), + if mode == "price": + label = _resolve_label(section, key, db_user.language) + await message.answer( + texts.t("ADMIN_PRICING_EDIT_SUCCESS", "Цена для {item} обновлена: {price}").format( + item=label, + price=settings.format_price(int(new_value)), + ) + ) + else: + entry = SETTING_ENTRY_BY_KEY.get(key) + lang_code = _language_code(db_user.language) + label = entry.label(lang_code) if entry else (stored_label or key) + formatted_value = bot_configuration_service.format_value_human( + key, bot_configuration_service.get_current_value(key) + ) + await message.answer( + texts.t( + "ADMIN_PRICING_SETTING_SUCCESS", + "Параметр {label} обновлен: {value}", + ).format(label=label, value=formatted_value) ) - ) await state.clear() @@ -436,6 +957,151 @@ async def process_price_input( ) +@admin_required +@error_handler +async def toggle_setting( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +) -> None: + try: + _, section, key = callback.data.split(":", 2) + except ValueError: + await callback.answer() + return + + entry = SETTING_ENTRY_BY_KEY.get(key) + if not entry or entry.action != "toggle": + await callback.answer() + return + + current = bool(bot_configuration_service.get_current_value(key)) + new_value = not current + await bot_configuration_service.set_value(db, key, new_value) + await db.commit() + + value_text = bot_configuration_service.format_value_human(key, new_value) + await callback.answer(value_text, show_alert=False) + + text, keyboard = _build_section(section, db_user.language) + await _render_message(callback.message, text, keyboard) + + +@admin_required +@error_handler +async def select_setting_choice( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +) -> None: + try: + _, section, key, value_raw = callback.data.split(":", 3) + except ValueError: + await callback.answer() + return + + entry = SETTING_ENTRY_BY_KEY.get(key) + if not entry or entry.action != "choice" or not entry.choices: + await callback.answer() + return + + target_option = None + for option in entry.choices: + if str(option.value) == value_raw: + target_option = option + break + + if target_option is None: + await callback.answer() + return + + texts = get_texts(db_user.language) + current_value = bot_configuration_service.get_current_value(key) + if current_value == target_option.value: + await callback.answer( + texts.t( + "ADMIN_PRICING_CHOICE_ALREADY", + "Это значение уже активно.", + ) + ) + return + + await bot_configuration_service.set_value(db, key, target_option.value) + await db.commit() + + lang_code = _language_code(db_user.language) + await callback.answer( + texts.t( + "ADMIN_PRICING_CHOICE_UPDATED", + "Выбрано: {label}", + ).format(label=target_option.label(lang_code)) + ) + + text, keyboard = _build_section(section, db_user.language) + await _render_message(callback.message, text, keyboard) + + +@admin_required +@error_handler +async def toggle_period_option( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +) -> None: + try: + _, target, value_raw = callback.data.split(":", 2) + days = int(value_raw) + except (ValueError, TypeError): + await callback.answer() + return + + texts = get_texts(db_user.language) + + if target == "subscription": + current = set(settings.get_available_subscription_periods()) + options = {14, 30, 60, 90, 180, 360} + setting_key = "AVAILABLE_SUBSCRIPTION_PERIODS" + elif target == "renewal": + current = set(settings.get_available_renewal_periods()) + options = {30, 60, 90, 180, 360} + setting_key = "AVAILABLE_RENEWAL_PERIODS" + else: + await callback.answer() + return + + if days not in options: + await callback.answer() + return + + if days in current: + if len(current) == 1: + await callback.answer( + texts.t( + "ADMIN_PRICING_PERIOD_MIN", + "Должен оставаться хотя бы один период.", + ), + show_alert=True, + ) + return + current.remove(days) + action_text = texts.t("ADMIN_PRICING_PERIOD_DISABLED", "Период отключен.") + else: + current.add(days) + action_text = texts.t("ADMIN_PRICING_PERIOD_ENABLED", "Период включен.") + + new_value = ",".join(str(item) for item in sorted(current)) + await bot_configuration_service.set_value(db, setting_key, new_value) + await db.commit() + + await callback.answer(action_text) + + text, keyboard = _build_period_options_section(db_user.language) + await _render_message(callback.message, text, keyboard) + + def register_handlers(dp: Dispatcher) -> None: dp.callback_query.register( show_pricing_menu, @@ -449,7 +1115,23 @@ def register_handlers(dp: Dispatcher) -> None: start_price_edit, F.data.startswith("admin_pricing_edit:"), ) + dp.callback_query.register( + start_setting_edit, + F.data.startswith("admin_pricing_setting:"), + ) + dp.callback_query.register( + toggle_setting, + F.data.startswith("admin_pricing_toggle:"), + ) + dp.callback_query.register( + select_setting_choice, + F.data.startswith("admin_pricing_choice:"), + ) + dp.callback_query.register( + toggle_period_option, + F.data.startswith("admin_pricing_toggle_period:"), + ) dp.message.register( - process_price_input, + process_pricing_input, PricingStates.waiting_for_value, )