Files
remnawave-bedolaga-telegram…/app/handlers/admin/pricing.py
2026-01-17 02:35:23 +03:00

1419 lines
50 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import logging
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Iterable, List, Tuple, Dict, Any
from aiogram import Bot, Dispatcher, F, types
from aiogram.fsm.context import FSMContext
from aiogram.exceptions import TelegramBadRequest
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.localization.texts import get_texts
from app.services.system_settings_service import bot_configuration_service
from app.states import PricingStates
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
PriceItem = Tuple[str, str, int]
TRAFFIC_PACKAGE_FIELDS: Tuple[Tuple[int, str], ...] = (
(5, "PRICE_TRAFFIC_5GB"),
(10, "PRICE_TRAFFIC_10GB"),
(25, "PRICE_TRAFFIC_25GB"),
(50, "PRICE_TRAFFIC_50GB"),
(100, "PRICE_TRAFFIC_100GB"),
(250, "PRICE_TRAFFIC_250GB"),
(500, "PRICE_TRAFFIC_500GB"),
(1000, "PRICE_TRAFFIC_1000GB"),
(0, "PRICE_TRAFFIC_UNLIMITED"),
)
TRAFFIC_PACKAGE_FIELD_MAP: Dict[int, str] = {gb: field for gb, field in TRAFFIC_PACKAGE_FIELDS}
TRAFFIC_PACKAGE_ORDER: Tuple[int, ...] = tuple(gb for gb, _ in TRAFFIC_PACKAGE_FIELDS)
TRAFFIC_PACKAGE_ORDER_INDEX: Dict[int, int] = {
gb: index for index, gb in enumerate(TRAFFIC_PACKAGE_ORDER)
}
@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_PAYMENT_ENABLED",
section="trial",
label_ru="💳 Платная активация",
label_en="💳 Paid activation",
action="toggle",
description_ru="Если включено — за активацию триала будет списываться указанная сумма.",
description_en="When enabled, the configured amount is charged during trial activation.",
),
SettingEntry(
key="TRIAL_ACTIVATION_PRICE",
section="trial",
label_ru="💰 Стоимость активации",
label_en="💰 Activation price",
action="price",
description_ru="Указывается в копейках. 0 — бесплатная активация.",
description_en="Amount in kopeks. 0 — free activation.",
),
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.",
),
)
CORE_PRICING_ENTRIES: Tuple[SettingEntry, ...] = (
SettingEntry(
key="BASE_SUBSCRIPTION_PRICE",
section="core",
label_ru="💳 Базовая стоимость подписки",
label_en="💳 Base subscription price",
action="price",
),
SettingEntry(
key="BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED",
section="core",
label_ru="🎟️ Базовые скидки для групп",
label_en="🎟️ Base group discounts",
action="toggle",
description_ru="Включает применение базовых скидок для групповых промо-периодов.",
description_en="Enables base discounts for promo group periods.",
),
SettingEntry(
key="BASE_PROMO_GROUP_PERIOD_DISCOUNTS",
section="core",
label_ru="🔖 Скидки по периодам",
label_en="🔖 Period discounts",
action="input",
description_ru="Формат: список пар дней и скидки через запятую (например 30:10,60:20).",
description_en="Format: comma-separated day/discount pairs (e.g. 30:10,60:20).",
),
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"),
ChoiceOption("fixed_with_topup", "Фикс. лимит + докупка", "Fixed + topup"),
),
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
}
SETTING_ENTRIES: Tuple[SettingEntry, ...] = tuple(
entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries
)
SETTING_KEY_TO_TOKEN: Dict[str, str] = {
entry.key: f"s{index}" for index, entry in enumerate(SETTING_ENTRIES)
}
SETTING_TOKEN_TO_KEY: Dict[str, str] = {
token: key for key, token in SETTING_KEY_TO_TOKEN.items()
}
def _encode_setting_callback_key(key: str) -> str:
return SETTING_KEY_TO_TOKEN.get(key, key)
def _decode_setting_callback_key(raw: str) -> str:
return SETTING_TOKEN_TO_KEY.get(raw, raw)
def _traffic_package_sort_key(package: Dict[str, Any]) -> Tuple[int, int]:
order_index = TRAFFIC_PACKAGE_ORDER_INDEX.get(package["gb"])
if order_index is not None:
return (0, order_index)
return (1, package["gb"])
def _collect_traffic_packages() -> List[Dict[str, Any]]:
raw_packages = settings.get_traffic_packages()
packages_map: Dict[int, Dict[str, Any]] = {}
for package in raw_packages:
gb = int(package.get("gb", 0))
packages_map[gb] = {
"gb": gb,
"price": int(package.get("price") or 0),
"enabled": bool(package.get("enabled", True)),
"field": TRAFFIC_PACKAGE_FIELD_MAP.get(gb),
}
for gb, field in TRAFFIC_PACKAGE_FIELDS:
if not hasattr(settings, field):
continue
price = getattr(settings, field)
existing = packages_map.get(gb)
enabled = existing["enabled"] if existing is not None else True
packages_map[gb] = {
"gb": gb,
"price": int(price),
"enabled": enabled,
"field": field,
}
packages = list(packages_map.values())
packages.sort(key=_traffic_package_sort_key)
return packages
def _serialize_traffic_packages(packages: Iterable[Dict[str, Any]]) -> str:
parts = []
for package in packages:
enabled_flag = "true" if package.get("enabled") else "false"
parts.append(f"{int(package['gb'])}:{int(package['price'])}:{enabled_flag}")
return ",".join(parts)
async def _save_traffic_packages(
db: AsyncSession,
packages: Iterable[Dict[str, Any]],
*,
skip_if_same: bool = False,
) -> bool:
new_value = _serialize_traffic_packages(packages)
current_value = bot_configuration_service.get_current_value("TRAFFIC_PACKAGES_CONFIG") or ""
if skip_if_same and current_value == new_value:
return False
await bot_configuration_service.set_value(db, "TRAFFIC_PACKAGES_CONFIG", new_value)
await db.commit()
return True
def _language_code(language: str | None) -> str:
return (language or "ru").split("-")[0].lower()
def _format_period_label(days: int, lang_code: str, short: bool = False) -> str:
if short:
suffix = "д" if lang_code == "ru" else "d"
return f"{days}{suffix}"
if lang_code == "ru":
return f"{days} дней"
if days == 1:
return "1 day"
return f"{days}-day plan"
def _format_traffic_label(gb: int, lang_code: str, short: bool = False) -> str:
if gb == 0:
return "" if short else ("Безлимит" if lang_code == "ru" else "Unlimited")
unit = "ГБ" if lang_code == "ru" else "GB"
if short:
return f"{gb}{unit}" if lang_code == "ru" else f"{gb}{unit}"
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
price_note = ""
if settings.is_trial_paid_activation_enabled():
price_note = f", 💳 {settings.format_price(settings.get_trial_activation_price())}"
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}{price_note}"
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
mode = settings.TRAFFIC_SELECTION_MODE.lower()
if mode == "fixed":
traffic_mode = "⚙️ fixed"
elif mode == "fixed_with_topup":
traffic_mode = "⚙️ fixed+topup"
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]:
from app.config import PERIOD_PRICES
items: List[PriceItem] = []
for days in settings.get_available_subscription_periods():
key = f"PRICE_{days}_DAYS"
price = PERIOD_PRICES.get(days, 0)
items.append((key, _format_period_label(days, lang_code), price))
return items
def _get_traffic_items(lang_code: str) -> List[PriceItem]:
packages = _collect_traffic_packages()
items: List[PriceItem] = []
for package in packages:
field = package.get("field")
if not field:
continue
label = _format_traffic_label(package["gb"], lang_code)
icon = "" if package["enabled"] else "⚪️"
items.append((field, f"{icon} {label}", int(package["price"])))
return items
def _get_extra_items(lang_code: str) -> List[PriceItem]:
items: List[PriceItem] = []
if hasattr(settings, "PRICE_PER_DEVICE"):
label = "Дополнительное устройство" if lang_code == "ru" else "Extra device"
items.append(("PRICE_PER_DEVICE", label, settings.PRICE_PER_DEVICE))
return items
def _build_period_summary(items: Iterable[PriceItem], lang_code: str, fallback: str) -> str:
parts: List[str] = []
for key, label, price in items:
try:
days = int(key.replace("PRICE_", "").replace("_DAYS", ""))
except ValueError:
days = None
if days is not None:
suffix = "д" if lang_code == "ru" else "d"
short_label = f"{days}{suffix}"
else:
short_label = label
parts.append(f"{short_label}: {settings.format_price(price)}")
return ", ".join(parts) if parts else fallback
def _build_traffic_summary(lang_code: str, fallback: str) -> str:
packages = _collect_traffic_packages()
enabled_packages = [package for package in packages if package["enabled"]]
if not enabled_packages:
return fallback
parts: List[str] = []
for package in enabled_packages:
short_label = _format_traffic_label(package["gb"], lang_code, short=True)
parts.append(f"{short_label}: {settings.format_price(int(package['price']))}")
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}:{_encode_setting_callback_key(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}:{_encode_setting_callback_key(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}:{_encode_setting_callback_key(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_traffic_options_section(language: str) -> Tuple[str, types.InlineKeyboardMarkup]:
texts = get_texts(language)
lang_code = _language_code(language)
packages = _collect_traffic_packages()
title = texts.t(
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE",
"🚦 Отображение пакетов трафика",
)
lines: List[str] = [title, ""]
enabled_labels = [
_format_traffic_label(package["gb"], lang_code, short=True)
for package in packages
if package["enabled"]
]
if enabled_labels:
lines.append(
texts.t(
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE",
"Активные пакеты: {items}",
).format(items=", ".join(enabled_labels))
)
else:
lines.append(
texts.t(
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE",
"Активных пакетов нет.",
)
)
lines.append("")
lines.append(
texts.t(
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT",
"Нажмите на пакет, чтобы включить или выключить его отображение.",
)
)
keyboard_rows: List[List[types.InlineKeyboardButton]] = []
buttons: List[types.InlineKeyboardButton] = []
for package in packages:
icon = "" if package["enabled"] else "⚪️"
label = _format_traffic_label(package["gb"], lang_code, short=True)
buttons.append(
types.InlineKeyboardButton(
text=f"{icon} {label}",
callback_data=f"admin_pricing_toggle_traffic:{package['gb']}",
)
)
for i in range(0, len(buttons), 3):
keyboard_rows.append(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_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_configured_subscription_periods())
available_renewal = set(settings.get_configured_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)
period_items = _get_period_items(lang_code)
traffic_items = _get_traffic_items(lang_code)
extra_items = _get_extra_items(lang_code)
fallback = texts.t("ADMIN_PRICING_SUMMARY_EMPTY", "")
summary_periods = _build_period_summary(period_items, lang_code, fallback)
summary_traffic = _build_traffic_summary(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)
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_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_TRAFFIC_OPTIONS",
"🚦 Отображение пакетов",
),
callback_data="admin_pricing_section:traffic_options",
),
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 "\n".join(lines), keyboard
def _build_section(
section: str,
language: str,
) -> Tuple[str, types.InlineKeyboardMarkup]:
texts = get_texts(language)
lang_code = _language_code(language)
if section == "periods":
items = _get_period_items(lang_code)
title = texts.t("ADMIN_PRICING_SECTION_PERIODS_TITLE", "🗓 Периоды подписки")
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 == "traffic_options":
return _build_traffic_options_section(language)
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", " Дополнительные опции")
lines = [title, ""]
if items:
for key, label, price in items:
lines.append(f"{label}{settings.format_price(price)}")
lines.append("")
lines.append(texts.t("ADMIN_PRICING_SECTION_PROMPT", "Выберите что изменить:"))
else:
lines.append(texts.t("ADMIN_PRICING_SECTION_EMPTY", "Нет доступных значений."))
keyboard_rows: List[List[types.InlineKeyboardButton]] = []
for key, label, price in items:
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=f"{label}{settings.format_price(price)}",
callback_data=f"admin_pricing_edit:{section}:{key}",
)
]
)
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_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,
keyboard: types.InlineKeyboardMarkup,
) -> None:
try:
await message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
except TelegramBadRequest as error: # message changed elsewhere
logger.debug("Failed to edit pricing message: %s", error)
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
async def _render_message_by_id(
bot: Bot,
chat_id: int,
message_id: int,
text: str,
keyboard: types.InlineKeyboardMarkup,
) -> None:
try:
await bot.edit_message_text(
text=text,
chat_id=chat_id,
message_id=message_id,
reply_markup=keyboard,
parse_mode="HTML",
)
except TelegramBadRequest as error:
logger.debug("Failed to edit pricing message by id: %s", error)
await bot.send_message(chat_id, text, reply_markup=keyboard, parse_mode="HTML")
def _parse_price_input(text: str) -> int:
normalized = text.replace("", "").replace("р", "").replace("RUB", "")
normalized = normalized.replace(" ", "").replace(",", ".").strip()
if not normalized:
raise ValueError("empty")
try:
value = Decimal(normalized)
except InvalidOperation as error:
raise ValueError("invalid") from error
if value < 0:
raise ValueError("negative")
kopeks = int((value * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
return kopeks
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", ""))
except ValueError:
days = None
if days is not None:
return _format_period_label(days, lang_code)
if section == "traffic" and key.startswith("PRICE_TRAFFIC_"):
if key.endswith("UNLIMITED"):
return _format_traffic_label(0, lang_code)
digits = ''.join(ch for ch in key if ch.isdigit())
try:
gb = int(digits)
except ValueError:
gb = None
if gb is not None:
return _format_traffic_label(gb, lang_code)
if key == "PRICE_PER_DEVICE":
return "Дополнительное устройство" if lang_code == "ru" else "Extra device"
return key
@admin_required
@error_handler
async def show_pricing_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
) -> None:
text, keyboard = _build_overview(db_user.language)
await _render_message(callback.message, text, keyboard)
await state.clear()
await callback.answer()
@admin_required
@error_handler
async def show_pricing_section(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
) -> None:
section = callback.data.split(":", 1)[1]
text, keyboard = _build_section(section, db_user.language)
await _render_message(callback.message, text, keyboard)
await state.clear()
await callback.answer()
@admin_required
@error_handler
async def start_price_edit(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
) -> None:
_, section, key = callback.data.split(":", 2)
texts = get_texts(db_user.language)
label = _resolve_label(section, key, db_user.language)
await state.update_data(
pricing_key=key,
pricing_section=section,
pricing_message_id=callback.message.message_id,
pricing_mode="price",
)
await state.set_state(PricingStates.waiting_for_value)
current_price = settings.format_price(getattr(settings, key, 0))
prompt = _build_price_prompt(texts, label, current_price)
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()
@admin_required
@error_handler
async def start_setting_edit(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
) -> None:
try:
_, section, raw_key = callback.data.split(":", 2)
except ValueError:
await callback.answer()
return
key = _decode_setting_callback_key(raw_key)
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,
db: AsyncSession,
) -> None:
data = await state.get_data()
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)
if not key:
await message.answer(texts.t("ADMIN_PRICING_EDIT_EXPIRED", "Сессия редактирования истекла."))
await state.clear()
return
raw_value = message.text or ""
if raw_value.strip().lower() in {"cancel", "отмена"}:
await state.clear()
section_text, section_keyboard = _build_section(section, db_user.language)
if message_id:
await _render_message_by_id(
message.bot,
message.chat.id,
message_id,
section_text,
section_keyboard,
)
await message.answer(texts.t("ADMIN_PRICING_EDIT_CANCELLED", "Изменения отменены."))
return
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
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, new_value)
await db.commit()
if key.startswith("PRICE_TRAFFIC_"):
packages = _collect_traffic_packages()
await _save_traffic_packages(db, packages, skip_if_same=True)
section_text, section_keyboard = _build_section(section, db_user.language)
if mode == "price":
if message_id:
await _render_message_by_id(
message.bot,
message.chat.id,
message_id,
section_text,
section_keyboard,
)
try:
await message.delete()
except TelegramBadRequest as error:
logger.debug("Failed to delete pricing input message: %s", error)
await state.clear()
return
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()
if message_id:
section_text, section_keyboard = _build_section(section, db_user.language)
await _render_message_by_id(
message.bot,
message.chat.id,
message_id,
section_text,
section_keyboard,
)
@admin_required
@error_handler
async def toggle_setting(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
) -> None:
try:
_, section, raw_key = callback.data.split(":", 2)
except ValueError:
await callback.answer()
return
key = _decode_setting_callback_key(raw_key)
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, raw_key, value_raw = callback.data.split(":", 3)
except ValueError:
await callback.answer()
return
key = _decode_setting_callback_key(raw_key)
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_traffic_package(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
) -> None:
try:
_, gb_raw = callback.data.split(":", 1)
gb_value = int(gb_raw)
except (ValueError, TypeError):
await callback.answer()
return
texts = get_texts(db_user.language)
packages = _collect_traffic_packages()
target_index = next((index for index, pkg in enumerate(packages) if pkg["gb"] == gb_value), None)
if target_index is None:
await callback.answer()
return
enabled_count = sum(1 for pkg in packages if pkg["enabled"])
target_package = packages[target_index]
if target_package["enabled"] and enabled_count <= 1:
await callback.answer(
texts.t(
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN",
"Должен оставаться хотя бы один пакет.",
),
show_alert=True,
)
return
target_package["enabled"] = not target_package["enabled"]
await _save_traffic_packages(db, packages)
status_text = (
texts.t("ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED", "Пакет включен.")
if target_package["enabled"]
else texts.t("ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED", "Пакет отключен.")
)
await callback.answer(status_text)
text, keyboard = _build_traffic_options_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_configured_subscription_periods())
options = {14, 30, 60, 90, 180, 360}
setting_key = "AVAILABLE_SUBSCRIPTION_PERIODS"
elif target == "renewal":
# Используем метод без фильтрации по ценам для админки
current = set(settings.get_configured_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,
F.data.in_({"admin_pricing", "admin_subs_pricing"}),
)
dp.callback_query.register(
show_pricing_section,
F.data.startswith("admin_pricing_section:"),
)
dp.callback_query.register(
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_traffic_package,
F.data.startswith("admin_pricing_toggle_traffic:"),
)
dp.callback_query.register(
toggle_period_option,
F.data.startswith("admin_pricing_toggle_period:"),
)
dp.message.register(
process_pricing_input,
PricingStates.waiting_for_value,
)