mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-24 21:30:52 +00:00
Merge pull request #723 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel-emq0t0
Refine admin pricing menu UX
This commit is contained in:
@@ -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} <b>{label}</b> — {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"• <b>{label}</b>: {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"• <b>{label}</b>: {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"<i>{description}</i>")
|
||||
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"💰 <b>{texts.t('ADMIN_PRICING_MENU_TITLE', 'Управление ценами')}</b>\n\n"
|
||||
f"{texts.t('ADMIN_PRICING_MENU_DESCRIPTION', 'Быстрый доступ к тарифам и пакетам.')}\n\n"
|
||||
f"<b>{texts.t('ADMIN_PRICING_MENU_SUMMARY', 'Краткая сводка:')}</b>\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"💰 <b>{texts.t('ADMIN_PRICING_MENU_TITLE', 'Управление ценами')}</b>",
|
||||
texts.t(
|
||||
'ADMIN_PRICING_MENU_DESCRIPTION',
|
||||
'Быстрый доступ к настройкам тарифов, периодов и пакетов.',
|
||||
),
|
||||
"",
|
||||
f"<b>{texts.t('ADMIN_PRICING_MENU_SUMMARY', 'Краткая сводка')}</b>",
|
||||
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"💰 <b>{texts.t('ADMIN_PRICING_EDIT_TITLE', 'Изменение цены')}</b>",
|
||||
"",
|
||||
f"{texts.t('ADMIN_PRICING_EDIT_TARGET', 'Текущий тариф')}: <b>{label}</b>",
|
||||
f"{texts.t('ADMIN_PRICING_EDIT_CURRENT', 'Текущее значение')}: <b>{current_price}</b>",
|
||||
"",
|
||||
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"💰 <b>{texts.t('ADMIN_PRICING_EDIT_TITLE', 'Изменение цены')}</b>\n\n"
|
||||
f"{texts.t('ADMIN_PRICING_EDIT_TARGET', 'Текущий тариф')}: <b>{label}</b>\n"
|
||||
f"{texts.t('ADMIN_PRICING_EDIT_CURRENT', 'Текущее значение')}: <b>{settings.format_price(getattr(settings, key, 0))}</b>\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"⚙️ <b>{texts.t('ADMIN_PRICING_SETTING_EDIT_TITLE', 'Настройка параметра')}</b>",
|
||||
"",
|
||||
f"{texts.t('ADMIN_PRICING_SETTING_PARAMETER', 'Параметр')}: <b>{label}</b>",
|
||||
f"{texts.t('ADMIN_PRICING_SETTING_CURRENT', 'Текущее значение')}: <b>{formatted_current}</b>",
|
||||
]
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user