diff --git a/handlers/admin/users/__init__.py b/handlers/admin/users/__init__.py
index 52c16106..c38e8ea5 100644
--- a/handlers/admin/users/__init__.py
+++ b/handlers/admin/users/__init__.py
@@ -1,6 +1,6 @@
from aiogram import Router
-from . import users_balance, users_bans, users_hwid, users_keys, users_manage
+from . import users_balance, users_bans, users_hwid, users_keys, users_manage, users_tariffs
router = Router()
@@ -9,3 +9,4 @@ router.include_router(users_balance.router)
router.include_router(users_hwid.router)
router.include_router(users_keys.router)
router.include_router(users_bans.router)
+router.include_router(users_tariffs.router)
diff --git a/handlers/admin/users/users_keys.py b/handlers/admin/users/users_keys.py
index 5729d7bd..68440910 100644
--- a/handlers/admin/users/users_keys.py
+++ b/handlers/admin/users/users_keys.py
@@ -3,7 +3,7 @@ import time
import uuid
from datetime import datetime, timedelta, timezone
-from typing import Any
+from core.settings.tariffs_config import normalize_tariff_config
import pytz
@@ -178,140 +178,6 @@ async def handle_key_edit(
)
-@router.callback_query(F.data == "back:renew", IsAdminFilter())
-async def handle_back_to_key_menu(
- callback_query: CallbackQuery,
- session: AsyncSession,
- state: FSMContext,
-):
- data = await state.get_data()
- email = data["email"]
- tg_id = data["tg_id"]
- await state.clear()
-
- callback_data = AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id)
- await handle_key_edit(
- callback_query=callback_query,
- callback_data=callback_data,
- session=session,
- update=False,
- )
-
-
-@router.callback_query(
- AdminUserEditorCallback.filter(F.action == "users_renew"),
- IsAdminFilter(),
-)
-async def handle_user_choose_tariff_group(
- callback_query: CallbackQuery,
- callback_data: AdminUserEditorCallback,
- session: AsyncSession,
- state: FSMContext,
-):
- email = callback_data.data
- tg_id = callback_data.tg_id
-
- await state.set_state(RenewTariffState.selecting_group)
- await state.update_data(email=email, tg_id=tg_id)
-
- result = await session.execute(select(Tariff.group_code).distinct())
- groups = [row[0] for row in result.fetchall()]
-
- builder = InlineKeyboardBuilder()
- for group_code in groups:
- builder.button(text=group_code, callback_data=f"group:{group_code}")
- builder.button(text="🔙 Назад", callback_data="back:renew")
- builder.adjust(1)
-
- await callback_query.message.edit_text(
- text="📁 Выберите тарифную группу:",
- reply_markup=builder.as_markup(),
- )
-
-
-@router.callback_query(F.data.startswith("group:"), IsAdminFilter())
-async def handle_user_choose_tariff(
- callback_query: CallbackQuery,
- session: AsyncSession,
- state: FSMContext,
-):
- group_code = callback_query.data.split(":", 1)[1]
- await state.update_data(group_code=group_code)
- await state.set_state(RenewTariffState.selecting_tariff)
-
- result = await session.execute(
- select(Tariff).where(Tariff.group_code == group_code, Tariff.is_active.is_(True)).order_by(Tariff.id)
- )
- tariffs = result.scalars().all()
-
- if not tariffs:
- await callback_query.message.edit_text("❌ Нет активных тарифов в группе.")
- return
-
- builder = InlineKeyboardBuilder()
- for tariff in tariffs:
- builder.button(text=f"{tariff.name} – {int(tariff.price_rub)}₽", callback_data=f"confirm:{tariff.id}")
- builder.button(text="🔙 Назад", callback_data="back:group")
- builder.adjust(1)
-
- await callback_query.message.edit_text(
- text=f"📦 Выберите тариф для группы {group_code}:",
- reply_markup=builder.as_markup(),
- )
-
-
-@router.callback_query(F.data.startswith("confirm:"), IsAdminFilter())
-async def handle_user_renew_confirm(
- callback_query: CallbackQuery,
- session: AsyncSession,
- state: FSMContext,
-):
- tariff_id = int(callback_query.data.split(":")[1])
- data = await state.get_data()
- email = data["email"]
- tg_id = data["tg_id"]
-
- stmt = update(Key).where(Key.tg_id == tg_id, Key.email == email).values(tariff_id=tariff_id)
- await session.execute(stmt)
- await session.commit()
-
- await update_subscription(tg_id=tg_id, email=email, session=session)
-
- await state.clear()
-
- callback_data = AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id)
- await handle_key_edit(
- callback_query=callback_query,
- callback_data=callback_data,
- session=session,
- update=False,
- )
-
-
-@router.callback_query(F.data == "back:group", IsAdminFilter())
-async def handle_back_to_group(
- callback_query: CallbackQuery,
- state: FSMContext,
- session: AsyncSession,
-):
- await state.get_data()
-
- result = await session.execute(select(Tariff.group_code).distinct())
- groups = [row[0] for row in result.fetchall()]
-
- builder = InlineKeyboardBuilder()
- for group_code in groups:
- builder.button(text=group_code, callback_data=f"group:{group_code}")
- builder.button(text="🔙 Назад", callback_data="back:renew")
- builder.adjust(1)
-
- await callback_query.message.edit_text(
- text="📁 Выберите тарифную группу:",
- reply_markup=builder.as_markup(),
- )
- await state.set_state(RenewTariffState.selecting_group)
-
-
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_expiry_edit"),
IsAdminFilter(),
diff --git a/handlers/admin/users/users_tariffs.py b/handlers/admin/users/users_tariffs.py
new file mode 100644
index 00000000..db1a58b5
--- /dev/null
+++ b/handlers/admin/users/users_tariffs.py
@@ -0,0 +1,697 @@
+from datetime import datetime
+
+from aiogram import F, Router
+from aiogram.fsm.context import FSMContext
+from aiogram.types import CallbackQuery, InlineKeyboardButton
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from core.settings.tariffs_config import normalize_tariff_config
+from database import get_tariff_by_id
+from database.models import Key, Tariff
+from filters.admin import IsAdminFilter
+from handlers.keys.operations import renew_key_in_cluster
+from logger import logger
+
+from .keyboard import AdminUserEditorCallback
+from .users_states import RenewTariffState
+from .users_keys import handle_key_edit
+
+
+router = Router()
+
+
+@router.callback_query(F.data == "back:renew", IsAdminFilter())
+async def handle_back_to_key_menu(
+ callback_query: CallbackQuery,
+ session: AsyncSession,
+ state: FSMContext,
+):
+ data = await state.get_data()
+ email = data.get("email")
+ tg_id = data.get("tg_id")
+ await state.clear()
+
+ if not email or not tg_id:
+ await callback_query.message.edit_text("❌ Не найдены данные сессии.")
+ return
+
+ callback_data = AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id)
+
+ await handle_key_edit(
+ callback_query=callback_query,
+ callback_data=callback_data,
+ session=session,
+ update=False,
+ )
+
+
+@router.callback_query(
+ AdminUserEditorCallback.filter(F.action == "users_renew"),
+ IsAdminFilter(),
+)
+async def handle_user_choose_tariff_group(
+ callback_query: CallbackQuery,
+ callback_data: AdminUserEditorCallback,
+ session: AsyncSession,
+ state: FSMContext,
+):
+ email = callback_data.data
+ tg_id = callback_data.tg_id
+
+ await state.set_state(RenewTariffState.selecting_group)
+ await state.update_data(email=email, tg_id=tg_id)
+
+ result = await session.execute(select(Tariff.group_code).distinct())
+ groups = [row[0] for row in result.fetchall()]
+
+ builder = InlineKeyboardBuilder()
+ for group_code in groups:
+ builder.button(text=group_code, callback_data=f"group:{group_code}")
+ builder.button(text="🔙 Назад", callback_data="back:renew")
+ builder.adjust(1)
+
+ await callback_query.message.edit_text(
+ text="📁 Выберите тарифную группу:",
+ reply_markup=builder.as_markup(),
+ )
+
+
+@router.callback_query(F.data.startswith("group:"), IsAdminFilter())
+async def handle_user_choose_tariff(
+ callback_query: CallbackQuery,
+ session: AsyncSession,
+ state: FSMContext,
+):
+ group_code = callback_query.data.split(":", 1)[1]
+ await state.update_data(group_code=group_code)
+ await state.set_state(RenewTariffState.selecting_tariff)
+
+ result = await session.execute(
+ select(Tariff).where(Tariff.group_code == group_code, Tariff.is_active.is_(True)).order_by(Tariff.id)
+ )
+ tariffs = result.scalars().all()
+
+ if not tariffs:
+ await callback_query.message.edit_text("❌ Нет активных тарифов в группе.")
+ return
+
+ builder = InlineKeyboardBuilder()
+ for tariff in tariffs:
+ builder.button(text=f"{tariff.name} – {int(tariff.price_rub)}₽", callback_data=f"confirm:{tariff.id}")
+ builder.button(text="🔙 Назад", callback_data="back:group")
+ builder.adjust(1)
+
+ await callback_query.message.edit_text(
+ text=f"📦 Выберите тариф для группы {group_code}:",
+ reply_markup=builder.as_markup(),
+ )
+
+
+@router.callback_query(F.data.startswith("confirm:"), IsAdminFilter())
+async def handle_user_renew_confirm(
+ callback_query: CallbackQuery,
+ session: AsyncSession,
+ state: FSMContext,
+):
+ tariff_id = int(callback_query.data.split(":")[1])
+ data = await state.get_data()
+ email = data.get("email")
+ tg_id = data.get("tg_id")
+
+ if not email or not tg_id:
+ await callback_query.message.edit_text("❌ Не найдены данные сессии.")
+ await state.clear()
+ return
+
+ tariff = await get_tariff_by_id(session, tariff_id)
+ if not tariff:
+ await callback_query.message.edit_text("❌ Тариф не найден.")
+ await state.clear()
+ return
+
+ result = await session.execute(select(Key).where(Key.email == email, Key.tg_id == tg_id))
+ key_obj: Key | None = result.scalar_one_or_none()
+ if not key_obj:
+ await callback_query.message.edit_text("❌ Ключ не найден.")
+ await state.clear()
+ return
+
+ if tariff.get("configurable"):
+ raw_device_options = tariff.get("device_options")
+ raw_traffic_options = tariff.get("traffic_options_gb")
+
+ raw_device_options = raw_device_options if isinstance(raw_device_options, list) else []
+ raw_traffic_options = raw_traffic_options if isinstance(raw_traffic_options, list) else []
+
+ try:
+ device_options = sorted(raw_device_options, key=lambda v: (int(v) == 0, int(v)))
+ except (TypeError, ValueError):
+ device_options = raw_device_options
+
+ try:
+ traffic_options = sorted(raw_traffic_options, key=lambda v: (int(v) == 0, int(v)))
+ except (TypeError, ValueError):
+ traffic_options = raw_traffic_options
+
+ device_int_options: list[int] = []
+ for value in device_options:
+ try:
+ device_int_options.append(int(value))
+ except (TypeError, ValueError):
+ continue
+
+ traffic_int_options: list[int] = []
+ for value in traffic_options:
+ try:
+ traffic_int_options.append(int(value))
+ except (TypeError, ValueError):
+ continue
+
+ if not device_int_options and not traffic_int_options:
+ await callback_query.message.edit_text(
+ "❌ Конфигуратор для этого тарифа не настроен. Попробуйте выбрать другой тариф."
+ )
+ await state.clear()
+ return
+
+ cfg = normalize_tariff_config(tariff)
+
+ positive_device_values = [v for v in device_int_options if v > 0]
+ positive_traffic_values = [v for v in traffic_int_options if v > 0]
+
+ base_device_limit = cfg.get("base_device_limit")
+ if base_device_limit is None:
+ base_device_limit = tariff.get("device_limit")
+ if base_device_limit is None:
+ if positive_device_values:
+ base_device_limit = min(positive_device_values)
+ elif device_int_options:
+ base_device_limit = device_int_options[0]
+ base_device_limit = int(base_device_limit) if base_device_limit is not None else None
+
+ base_traffic_gb = cfg.get("base_traffic_gb")
+ if base_traffic_gb is None:
+ traffic_limit_raw = tariff.get("traffic_limit")
+ if traffic_limit_raw is not None:
+ try:
+ base_traffic_gb = int(traffic_limit_raw)
+ except (TypeError, ValueError):
+ base_traffic_gb = None
+ if base_traffic_gb is None:
+ if positive_traffic_values:
+ base_traffic_gb = min(positive_traffic_values)
+ elif traffic_int_options:
+ base_traffic_gb = traffic_int_options[0]
+ base_traffic_gb = int(base_traffic_gb) if base_traffic_gb is not None else None
+
+ selected_devices = (
+ base_device_limit if base_device_limit is not None else (device_int_options[0] if device_int_options else None)
+ )
+ selected_traffic_gb = (
+ base_traffic_gb if base_traffic_gb is not None else (traffic_int_options[0] if traffic_int_options else None)
+ )
+
+ await state.update_data(
+ renew_tariff_id=tariff_id,
+ renew_selected_device_limit=selected_devices,
+ renew_selected_traffic_gb=selected_traffic_gb,
+ renew_mode="renew",
+ )
+
+ builder = InlineKeyboardBuilder()
+
+ device_buttons: list[InlineKeyboardButton] = []
+ traffic_buttons: list[InlineKeyboardButton] = []
+
+ if device_int_options and len(device_int_options) > 1:
+ sel = int(selected_devices or 0)
+ for value in device_int_options:
+ mark = " ✅" if value == sel else ""
+ caption = "Безлимит устройств" if value == 0 else f"{value} устройств"
+ device_buttons.append(
+ InlineKeyboardButton(
+ text=f"{caption}{mark}",
+ callback_data=f"cfg_renew_devices|{tariff_id}|{value}",
+ )
+ )
+
+ if traffic_int_options and len(traffic_int_options) > 1:
+ sel = int(selected_traffic_gb or 0)
+ for value in traffic_int_options:
+ mark = " ✅" if value == sel else ""
+ caption = "Безлимит трафика" if value == 0 else f"{value} ГБ"
+ traffic_buttons.append(
+ InlineKeyboardButton(
+ text=f"{caption}{mark}",
+ callback_data=f"cfg_renew_traffic|{tariff_id}|{value}",
+ )
+ )
+
+ if device_buttons and traffic_buttons:
+ max_len = max(len(device_buttons), len(traffic_buttons))
+ for i in range(max_len):
+ row = []
+ if i < len(device_buttons):
+ row.append(device_buttons[i])
+ if i < len(traffic_buttons):
+ row.append(traffic_buttons[i])
+ builder.row(*row)
+ elif device_buttons:
+ for b in device_buttons:
+ builder.row(b)
+ elif traffic_buttons:
+ for b in traffic_buttons:
+ builder.row(b)
+
+ builder.row(InlineKeyboardButton(text="✅ Применить", callback_data=f"cfg_renew_apply|{tariff_id}"))
+ builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="back:group"))
+
+ devices_label = (
+ "Безлимит устройств"
+ if (selected_devices is not None and int(selected_devices) <= 0)
+ else (f"{int(selected_devices)} устройств" if selected_devices is not None else "—")
+ )
+ traffic_label = (
+ "Безлимит трафика"
+ if (selected_traffic_gb is not None and int(selected_traffic_gb) <= 0)
+ else (f"{int(selected_traffic_gb)} ГБ" if selected_traffic_gb is not None else "—")
+ )
+
+ await callback_query.message.edit_text(
+ text=(
+ "🧩 Выбор конфигурации тарифа\n\n"
+ f"📦 Тариф: {tariff.get('name', '—')}\n"
+ f"📱 Устройства: {devices_label}\n"
+ f"📊 Трафик: {traffic_label}\n\n"
+ "Выберите параметры и нажмите «✅ Применить»."
+ ),
+ reply_markup=builder.as_markup(),
+ )
+ return
+
+ device_limit = int(tariff.get("device_limit") or 0)
+
+ raw_traffic_limit = tariff.get("traffic_limit")
+ traffic_gb = 0
+ if raw_traffic_limit is not None:
+ try:
+ traffic_gb = int(raw_traffic_limit)
+ except (TypeError, ValueError):
+ traffic_gb = 0
+
+ old_tariff_id = key_obj.tariff_id
+ old_subgroup = None
+ if old_tariff_id:
+ old_subgroup = (
+ await session.execute(select(Tariff.subgroup_title).where(Tariff.id == old_tariff_id))
+ ).scalar_one_or_none()
+
+ new_subgroup = (
+ await session.execute(select(Tariff.subgroup_title).where(Tariff.id == tariff_id))
+ ).scalar_one_or_none()
+
+ new_expiry_time = int(key_obj.expiry_time or 0) or int(datetime.utcnow().timestamp() * 1000)
+
+ await session.execute(
+ update(Key)
+ .where(Key.tg_id == tg_id, Key.email == email)
+ .values(
+ tariff_id=tariff_id,
+ selected_device_limit=None,
+ current_device_limit=None,
+ selected_traffic_limit=None,
+ current_traffic_limit=None,
+ selected_price_rub=None,
+ )
+ )
+ await session.commit()
+
+ try:
+ ok = await renew_key_in_cluster(
+ cluster_id=key_obj.server_id,
+ email=email,
+ client_id=key_obj.client_id,
+ new_expiry_time=new_expiry_time,
+ total_gb=traffic_gb,
+ session=session,
+ hwid_device_limit=device_limit,
+ reset_traffic=False,
+ target_subgroup=new_subgroup,
+ old_subgroup=old_subgroup,
+ plan=tariff_id,
+ )
+ except Exception as e:
+ logger.error(f"[AdminRenew] renew_key_in_cluster failed: tg_id={tg_id} email={email} tariff_id={tariff_id}: {e}")
+ ok = False
+
+ await state.clear()
+
+ if not ok:
+ await callback_query.message.answer("❌ Не удалось обновить подписку на серверах (renew).")
+
+ callback_data_back = AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id)
+
+ await handle_key_edit(callback_query=callback_query, callback_data=callback_data_back, session=session, update=False)
+
+
+@router.callback_query(F.data.startswith("cfg_renew_devices|"), IsAdminFilter())
+async def handle_cfg_renew_devices(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
+ _, tariff_id_str, value_str = callback_query.data.split("|", 2)
+ tariff_id = int(tariff_id_str)
+ value = int(value_str)
+
+ data = await state.get_data()
+ if int(data.get("renew_tariff_id") or 0) != tariff_id:
+ await callback_query.answer("⚠️ Сессия устарела", show_alert=True)
+ return
+
+ await state.update_data(renew_selected_device_limit=value)
+
+ tariff = await get_tariff_by_id(session, tariff_id)
+ if not tariff:
+ await callback_query.message.edit_text("❌ Тариф не найден.")
+ await state.clear()
+ return
+
+ selected_devices = int((await state.get_data()).get("renew_selected_device_limit") or 0)
+ selected_traffic_gb = (await state.get_data()).get("renew_selected_traffic_gb")
+
+ raw_device_options = tariff.get("device_options")
+ raw_traffic_options = tariff.get("traffic_options_gb")
+
+ raw_device_options = raw_device_options if isinstance(raw_device_options, list) else []
+ raw_traffic_options = raw_traffic_options if isinstance(raw_traffic_options, list) else []
+
+ try:
+ device_options = sorted(raw_device_options, key=lambda v: (int(v) == 0, int(v)))
+ except (TypeError, ValueError):
+ device_options = raw_device_options
+
+ try:
+ traffic_options = sorted(raw_traffic_options, key=lambda v: (int(v) == 0, int(v)))
+ except (TypeError, ValueError):
+ traffic_options = raw_traffic_options
+
+ device_int_options: list[int] = []
+ for v in device_options:
+ try:
+ device_int_options.append(int(v))
+ except (TypeError, ValueError):
+ continue
+
+ traffic_int_options: list[int] = []
+ for v in traffic_options:
+ try:
+ traffic_int_options.append(int(v))
+ except (TypeError, ValueError):
+ continue
+
+ builder = InlineKeyboardBuilder()
+
+ device_buttons: list[InlineKeyboardButton] = []
+ traffic_buttons: list[InlineKeyboardButton] = []
+
+ if device_int_options and len(device_int_options) > 1:
+ for v in device_int_options:
+ mark = " ✅" if v == selected_devices else ""
+ caption = "Безлимит устройств" if v == 0 else f"{v} устройств"
+ device_buttons.append(
+ InlineKeyboardButton(text=f"{caption}{mark}", callback_data=f"cfg_renew_devices|{tariff_id}|{v}")
+ )
+
+ if traffic_int_options and len(traffic_int_options) > 1:
+ sel_tr = int(selected_traffic_gb or 0)
+ for v in traffic_int_options:
+ mark = " ✅" if v == sel_tr else ""
+ caption = "Безлимит трафика" if v == 0 else f"{v} ГБ"
+ traffic_buttons.append(
+ InlineKeyboardButton(text=f"{caption}{mark}", callback_data=f"cfg_renew_traffic|{tariff_id}|{v}")
+ )
+
+ if device_buttons and traffic_buttons:
+ max_len = max(len(device_buttons), len(traffic_buttons))
+ for i in range(max_len):
+ row = []
+ if i < len(device_buttons):
+ row.append(device_buttons[i])
+ if i < len(traffic_buttons):
+ row.append(traffic_buttons[i])
+ builder.row(*row)
+ elif device_buttons:
+ for b in device_buttons:
+ builder.row(b)
+ elif traffic_buttons:
+ for b in traffic_buttons:
+ builder.row(b)
+
+ builder.row(InlineKeyboardButton(text="✅ Применить", callback_data=f"cfg_renew_apply|{tariff_id}"))
+ builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="back:group"))
+
+ devices_label = "Безлимит устройств" if selected_devices <= 0 else f"{selected_devices} устройств"
+ traffic_label = (
+ "Безлимит трафика"
+ if (selected_traffic_gb is not None and int(selected_traffic_gb) <= 0)
+ else (f"{int(selected_traffic_gb)} ГБ" if selected_traffic_gb is not None else "—")
+ )
+
+ await callback_query.message.edit_text(
+ text=(
+ "🧩 Выбор конфигурации тарифа\n\n"
+ f"📦 Тариф: {tariff.get('name', '—')}\n"
+ f"📱 Устройства: {devices_label}\n"
+ f"📊 Трафик: {traffic_label}\n\n"
+ "Выберите параметры и нажмите «✅ Применить»."
+ ),
+ reply_markup=builder.as_markup(),
+ )
+ await callback_query.answer()
+
+
+@router.callback_query(F.data.startswith("cfg_renew_traffic|"), IsAdminFilter())
+async def handle_cfg_renew_traffic(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
+ _, tariff_id_str, value_str = callback_query.data.split("|", 2)
+ tariff_id = int(tariff_id_str)
+ value = int(value_str)
+
+ data = await state.get_data()
+ if int(data.get("renew_tariff_id") or 0) != tariff_id:
+ await callback_query.answer("⚠️ Сессия устарела", show_alert=True)
+ return
+
+ await state.update_data(renew_selected_traffic_gb=value)
+
+ tariff = await get_tariff_by_id(session, tariff_id)
+ if not tariff:
+ await callback_query.message.edit_text("❌ Тариф не найден.")
+ await state.clear()
+ return
+
+ selected_devices = (await state.get_data()).get("renew_selected_device_limit")
+ selected_traffic_gb = int((await state.get_data()).get("renew_selected_traffic_gb") or 0)
+
+ raw_device_options = tariff.get("device_options")
+ raw_traffic_options = tariff.get("traffic_options_gb")
+
+ raw_device_options = raw_device_options if isinstance(raw_device_options, list) else []
+ raw_traffic_options = raw_traffic_options if isinstance(raw_traffic_options, list) else []
+
+ try:
+ device_options = sorted(raw_device_options, key=lambda v: (int(v) == 0, int(v)))
+ except (TypeError, ValueError):
+ device_options = raw_device_options
+
+ try:
+ traffic_options = sorted(raw_traffic_options, key=lambda v: (int(v) == 0, int(v)))
+ except (TypeError, ValueError):
+ traffic_options = raw_traffic_options
+
+ device_int_options: list[int] = []
+ for v in device_options:
+ try:
+ device_int_options.append(int(v))
+ except (TypeError, ValueError):
+ continue
+
+ traffic_int_options: list[int] = []
+ for v in traffic_options:
+ try:
+ traffic_int_options.append(int(v))
+ except (TypeError, ValueError):
+ continue
+
+ builder = InlineKeyboardBuilder()
+
+ device_buttons: list[InlineKeyboardButton] = []
+ traffic_buttons: list[InlineKeyboardButton] = []
+
+ if device_int_options and len(device_int_options) > 1:
+ sel_dev = int(selected_devices or 0)
+ for v in device_int_options:
+ mark = " ✅" if v == sel_dev else ""
+ caption = "Безлимит устройств" if v == 0 else f"{v} устройств"
+ device_buttons.append(
+ InlineKeyboardButton(text=f"{caption}{mark}", callback_data=f"cfg_renew_devices|{tariff_id}|{v}")
+ )
+
+ if traffic_int_options and len(traffic_int_options) > 1:
+ for v in traffic_int_options:
+ mark = " ✅" if v == selected_traffic_gb else ""
+ caption = "Безлимит трафика" if v == 0 else f"{v} ГБ"
+ traffic_buttons.append(
+ InlineKeyboardButton(text=f"{caption}{mark}", callback_data=f"cfg_renew_traffic|{tariff_id}|{v}")
+ )
+
+ if device_buttons and traffic_buttons:
+ max_len = max(len(device_buttons), len(traffic_buttons))
+ for i in range(max_len):
+ row = []
+ if i < len(device_buttons):
+ row.append(device_buttons[i])
+ if i < len(traffic_buttons):
+ row.append(traffic_buttons[i])
+ builder.row(*row)
+ elif device_buttons:
+ for b in device_buttons:
+ builder.row(b)
+ elif traffic_buttons:
+ for b in traffic_buttons:
+ builder.row(b)
+
+ builder.row(InlineKeyboardButton(text="✅ Применить", callback_data=f"cfg_renew_apply|{tariff_id}"))
+ builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="back:group"))
+
+ devices_label = (
+ "Безлимит устройств"
+ if (selected_devices is not None and int(selected_devices) <= 0)
+ else (f"{int(selected_devices)} устройств" if selected_devices is not None else "—")
+ )
+ traffic_label = "Безлимит трафика" if selected_traffic_gb <= 0 else f"{selected_traffic_gb} ГБ"
+
+ await callback_query.message.edit_text(
+ text=(
+ "🧩 Выбор конфигурации тарифа\n\n"
+ f"📦 Тариф: {tariff.get('name', '—')}\n"
+ f"📱 Устройства: {devices_label}\n"
+ f"📊 Трафик: {traffic_label}\n\n"
+ "Выберите параметры и нажмите «✅ Применить»."
+ ),
+ reply_markup=builder.as_markup(),
+ )
+ await callback_query.answer()
+
+
+@router.callback_query(F.data.startswith("cfg_renew_apply|"), IsAdminFilter())
+async def handle_cfg_renew_apply(callback_query: CallbackQuery, session: AsyncSession, state: FSMContext):
+ _, tariff_id_str = callback_query.data.split("|", 1)
+ tariff_id = int(tariff_id_str)
+
+ data = await state.get_data()
+ email = data.get("email")
+ tg_id = data.get("tg_id")
+
+ if not email or not tg_id:
+ await callback_query.message.edit_text("❌ Не найдены данные сессии.")
+ await state.clear()
+ return
+
+ if int(data.get("renew_tariff_id") or 0) != tariff_id:
+ await callback_query.message.edit_text("❌ Сессия устарела. Выберите тариф заново.")
+ await state.clear()
+ return
+
+ selected_devices = data.get("renew_selected_device_limit")
+ selected_traffic_gb = data.get("renew_selected_traffic_gb")
+
+ if selected_devices is None and selected_traffic_gb is None:
+ await callback_query.message.edit_text("❌ Не выбраны параметры конфигурации.")
+ await state.clear()
+ return
+
+ result = await session.execute(select(Key).where(Key.email == email, Key.tg_id == tg_id))
+ key_obj: Key | None = result.scalar_one_or_none()
+ if not key_obj:
+ await callback_query.message.edit_text("❌ Ключ не найден.")
+ await state.clear()
+ return
+
+ old_tariff_id = key_obj.tariff_id
+ old_subgroup = None
+ if old_tariff_id:
+ old_subgroup = (
+ await session.execute(select(Tariff.subgroup_title).where(Tariff.id == old_tariff_id))
+ ).scalar_one_or_none()
+
+ new_subgroup = (
+ await session.execute(select(Tariff.subgroup_title).where(Tariff.id == tariff_id))
+ ).scalar_one_or_none()
+
+ new_expiry_time = int(key_obj.expiry_time or 0) or int(datetime.utcnow().timestamp() * 1000)
+
+ await session.execute(
+ update(Key)
+ .where(Key.tg_id == tg_id, Key.email == email)
+ .values(
+ tariff_id=tariff_id,
+ selected_device_limit=int(selected_devices) if selected_devices is not None else None,
+ current_device_limit=int(selected_devices) if selected_devices is not None else None,
+ selected_traffic_limit=int(selected_traffic_gb)
+ if (selected_traffic_gb is not None and int(selected_traffic_gb) > 0)
+ else None,
+ current_traffic_limit=int(selected_traffic_gb)
+ if (selected_traffic_gb is not None and int(selected_traffic_gb) > 0)
+ else None,
+ selected_price_rub=None,
+ )
+ )
+ await session.commit()
+
+ try:
+ ok = await renew_key_in_cluster(
+ cluster_id=key_obj.server_id,
+ email=email,
+ client_id=key_obj.client_id,
+ new_expiry_time=new_expiry_time,
+ total_gb=int(selected_traffic_gb or 0),
+ session=session,
+ hwid_device_limit=int(selected_devices or 0),
+ reset_traffic=False,
+ target_subgroup=new_subgroup,
+ old_subgroup=old_subgroup,
+ plan=tariff_id,
+ )
+ except Exception as e:
+ logger.error(f"[AdminRenewCfg] renew_key_in_cluster failed: tg_id={tg_id} email={email} tariff_id={tariff_id}: {e}")
+ ok = False
+
+ await state.clear()
+
+ if not ok:
+ await callback_query.message.answer("❌ Не удалось обновить подписку на серверах (renew).")
+
+ callback_data_back = AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=int(tg_id))
+
+ await handle_key_edit(callback_query=callback_query, callback_data=callback_data_back, session=session, update=False)
+
+
+@router.callback_query(F.data == "back:group", IsAdminFilter())
+async def handle_back_to_group(
+ callback_query: CallbackQuery,
+ state: FSMContext,
+ session: AsyncSession,
+):
+ result = await session.execute(select(Tariff.group_code).distinct())
+ groups = [row[0] for row in result.fetchall()]
+
+ builder = InlineKeyboardBuilder()
+ for group_code in groups:
+ builder.button(text=group_code, callback_data=f"group:{group_code}")
+ builder.button(text="🔙 Назад", callback_data="back:renew")
+ builder.adjust(1)
+
+ await callback_query.message.edit_text(
+ text="📁 Выберите тарифную группу:",
+ reply_markup=builder.as_markup(),
+ )
+ await state.set_state(RenewTariffState.selecting_group)
diff --git a/handlers/keys/key_renew.py b/handlers/keys/key_renew.py
index 65556c1e..a1004f91 100644
--- a/handlers/keys/key_renew.py
+++ b/handlers/keys/key_renew.py
@@ -464,17 +464,26 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC
selected_devices_db = record.get("selected_device_limit")
selected_traffic_db = record.get("selected_traffic_limit")
+ traffic_default = next(
+ (x for x in traffic_options_sorted if x != 0),
+ (traffic_options_sorted[0] if traffic_options_sorted else None),
+ )
+ device_default = next(
+ (x for x in device_options_sorted if x != 0),
+ (device_options_sorted[0] if device_options_sorted else None),
+ )
+
if traffic_options_sorted:
- selected_traffic_db = int(selected_traffic_db) if selected_traffic_db is not None else traffic_options_sorted[0]
+ selected_traffic_db = int(selected_traffic_db) if selected_traffic_db is not None else traffic_default
if selected_traffic_db not in traffic_options_sorted:
- selected_traffic_db = traffic_options_sorted[0]
+ selected_traffic_db = traffic_default
else:
selected_traffic_db = None
if device_options_sorted:
- selected_devices_db = int(selected_devices_db) if selected_devices_db is not None else device_options_sorted[0]
+ selected_devices_db = int(selected_devices_db) if selected_devices_db is not None else device_default
if selected_devices_db not in device_options_sorted:
- selected_devices_db = device_options_sorted[0]
+ selected_devices_db = device_default
else:
selected_devices_db = None
diff --git a/handlers/keys/operations/renewal.py b/handlers/keys/operations/renewal.py
index 205f3a5f..9ce2aebc 100644
--- a/handlers/keys/operations/renewal.py
+++ b/handlers/keys/operations/renewal.py
@@ -25,7 +25,7 @@ from panels._3xui import extend_client_key, get_xui_instance
from panels.remnawave import RemnawaveAPI
from .aggregated_links import make_aggregated_link
-from .subgroup_migration import migrate_between_subgroups
+from ...tariffs.subgroup_migration import migrate_between_subgroups
async def resolve_cluster(session: AsyncSession, cluster_id: str):
diff --git a/handlers/keys/operations/subgroup_migration.py b/handlers/tariffs/subgroup_migration.py
similarity index 98%
rename from handlers/keys/operations/subgroup_migration.py
rename to handlers/tariffs/subgroup_migration.py
index ed8ca01c..143b478f 100644
--- a/handlers/keys/operations/subgroup_migration.py
+++ b/handlers/tariffs/subgroup_migration.py
@@ -15,8 +15,8 @@ from logger import (
from panels._3xui import ClientConfig, add_client, extend_client_key, get_xui_instance
from panels.remnawave import RemnawaveAPI
-from .deletion import delete_on_3xui, delete_on_remnawave
-from .utils import bytes_from_gb, norm_name, split_by_panel
+from ..keys.operations.deletion import delete_on_3xui, delete_on_remnawave
+from ..keys.operations.utils import bytes_from_gb, norm_name, split_by_panel
async def ensure_on_remnawave(
diff --git a/utils/versioning.py b/utils/versioning.py
index 8c2f7219..7c7efab1 100644
--- a/utils/versioning.py
+++ b/utils/versioning.py
@@ -92,4 +92,4 @@ def get_git_commit_number() -> str:
def get_version() -> str:
- return f"v.5.1-b221229 {get_git_commit_number()}"
+ return f"v.5.1-b261235 {get_git_commit_number()}"