mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
1419 lines
50 KiB
Python
1419 lines
50 KiB
Python
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,
|
||
)
|