diff --git a/handlers/admin/users/keyboard.py b/handlers/admin/users/keyboard.py
index 3bbeb754..207a6e1e 100644
--- a/handlers/admin/users/keyboard.py
+++ b/handlers/admin/users/keyboard.py
@@ -243,7 +243,7 @@ def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
return builder.as_markup()
-def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
+def build_key_edit_kb(key_details: dict, email: str, is_configurable: bool = False) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
is_frozen = (
@@ -264,6 +264,13 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
text="📦 Тариф",
callback_data=AdminUserEditorCallback(action="users_renew", data=email, tg_id=key_details["tg_id"]).pack(),
)
+ if is_configurable:
+ builder.button(
+ text="📱 Конфигурация",
+ callback_data=AdminUserEditorCallback(
+ action="users_edit_config", data=email, tg_id=key_details["tg_id"]
+ ).pack(),
+ )
builder.button(
text="❌ Удалить",
callback_data=AdminUserEditorCallback(action="users_delete_key", data=email, tg_id=key_details["tg_id"]).pack(),
diff --git a/handlers/admin/users/users_keys.py b/handlers/admin/users/users_keys.py
index 3dca9bfb..ad849315 100644
--- a/handlers/admin/users/users_keys.py
+++ b/handlers/admin/users/users_keys.py
@@ -157,7 +157,7 @@ async def handle_key_edit(
)
if not update or not getattr(callback_data, "edit", False):
- kb_markup = build_key_edit_kb(key_obj.__dict__, email)
+ kb_markup = build_key_edit_kb(key_obj.__dict__, email, is_configurable=is_configurable)
kb_builder = InlineKeyboardBuilder.from_markup(kb_markup)
hook_buttons = await process_admin_key_edit_menu(
email=email,
@@ -1227,3 +1227,422 @@ async def change_expiry_time(expiry_time: int, email: str, session: AsyncSession
await update_key_expiry(session, client_id, expiry_time)
return None
+
+
+@router.callback_query(
+ AdminUserEditorCallback.filter(F.action == "users_edit_config"),
+ IsAdminFilter(),
+)
+async def handle_edit_config_start(
+ callback_query: CallbackQuery,
+ callback_data: AdminUserEditorCallback,
+ state: FSMContext,
+ session: AsyncSession,
+):
+ email = callback_data.data
+ tg_id = callback_data.tg_id
+
+ result = await session.execute(select(Key).where(Key.email == email))
+ key_obj: Key | None = result.scalar_one_or_none()
+
+ if not key_obj:
+ await callback_query.message.edit_text("❌ Ключ не найден.", reply_markup=build_editor_kb(tg_id))
+ return
+
+ if not key_obj.tariff_id:
+ await callback_query.message.edit_text(
+ "❌ У ключа не назначен тариф.",
+ reply_markup=build_key_edit_kb(key_obj.__dict__, email),
+ )
+ return
+
+ tariff = await get_tariff_by_id(session, key_obj.tariff_id)
+ if not tariff or not tariff.get("configurable"):
+ await callback_query.message.edit_text(
+ "❌ Тариф не поддерживает конфигурацию.",
+ reply_markup=build_key_edit_kb(key_obj.__dict__, email),
+ )
+ return
+
+ base_devices = key_obj.selected_device_limit or tariff.get("device_limit") or 1
+ current_devices = key_obj.current_device_limit or base_devices
+ extra_devices = max(0, current_devices - base_devices)
+
+ base_traffic = key_obj.selected_traffic_limit
+ current_traffic = key_obj.current_traffic_limit
+ extra_traffic = max(0, (current_traffic or 0) - (base_traffic or 0)) if current_traffic and base_traffic else 0
+
+ await state.set_state(UserEditorState.config_menu)
+ await state.update_data(
+ email=email,
+ tg_id=tg_id,
+ tariff_id=key_obj.tariff_id,
+ cfg_base_devices=base_devices,
+ cfg_extra_devices=extra_devices,
+ cfg_base_traffic=base_traffic,
+ cfg_extra_traffic=extra_traffic,
+ )
+
+ await render_config_menu(callback_query, state, session)
+
+
+async def render_config_menu(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ email = data.get("email")
+ tg_id = data.get("tg_id")
+ tariff_id = data.get("tariff_id")
+
+ tariff = await get_tariff_by_id(session, tariff_id)
+ if not tariff:
+ await callback_query.message.edit_text("❌ Тариф не найден.")
+ await state.clear()
+ return
+
+ base_devices = data.get("cfg_base_devices") or 1
+ extra_devices = data.get("cfg_extra_devices") or 0
+ base_traffic = data.get("cfg_base_traffic")
+ extra_traffic = data.get("cfg_extra_traffic") or 0
+
+ text = (
+ f"⚙️ Конфигурация ключа\n\n"
+ f"🔑 Ключ: {email}\n"
+ f"📦 Тариф: {tariff.get('name')}\n\n"
+ )
+
+ extra_dev_str = f" + {extra_devices} (докуплено)" if extra_devices > 0 else ""
+ text += f"📱 Устройства: {base_devices}{extra_dev_str}\n"
+
+ if base_traffic:
+ extra_traf_str = f" + {extra_traffic} ГБ (докуплено)" if extra_traffic > 0 else ""
+ text += f"📊 Трафик: {base_traffic} ГБ{extra_traf_str}\n"
+ else:
+ text += f"📊 Трафик: безлимит\n"
+
+ text += "\nВыберите что редактировать:"
+
+ builder = InlineKeyboardBuilder()
+ builder.row(
+ InlineKeyboardButton(text="📦 Тариф (база)", callback_data="cfg_edit_base"),
+ InlineKeyboardButton(text="➕ Докупка", callback_data="cfg_edit_addon"),
+ )
+ builder.row(InlineKeyboardButton(text="💾 Сохранить", callback_data="cfg_save"))
+ builder.row(
+ InlineKeyboardButton(
+ text="🔙 Назад",
+ callback_data=AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id).pack(),
+ )
+ )
+
+ await state.set_state(UserEditorState.config_menu)
+ await callback_query.message.edit_text(text=text, reply_markup=builder.as_markup())
+
+
+@router.callback_query(F.data == "cfg_edit_base", UserEditorState.config_menu, IsAdminFilter())
+async def handle_cfg_edit_base(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ tariff = await get_tariff_by_id(session, data.get("tariff_id"))
+ device_options = tariff.get("device_options") or [] if tariff else []
+ traffic_options = tariff.get("traffic_options_gb") or [] if tariff else []
+
+ builder = InlineKeyboardBuilder()
+ if device_options:
+ builder.row(InlineKeyboardButton(text="📱 Устройства", callback_data="cfg_base_devices"))
+ if traffic_options:
+ builder.row(InlineKeyboardButton(text="📊 Трафик", callback_data="cfg_base_traffic"))
+ builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="cfg_back_menu"))
+
+ await callback_query.message.edit_text(
+ "📦 Редактирование базы тарифа\n\nВыберите параметр:",
+ reply_markup=builder.as_markup(),
+ )
+
+
+@router.callback_query(F.data == "cfg_edit_addon", UserEditorState.config_menu, IsAdminFilter())
+async def handle_cfg_edit_addon(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ tariff = await get_tariff_by_id(session, data.get("tariff_id"))
+ device_options = tariff.get("device_options") or [] if tariff else []
+ traffic_options = tariff.get("traffic_options_gb") or [] if tariff else []
+
+ builder = InlineKeyboardBuilder()
+ if device_options:
+ builder.row(InlineKeyboardButton(text="📱 Устройства", callback_data="cfg_addon_devices"))
+ if traffic_options:
+ builder.row(InlineKeyboardButton(text="📊 Трафик", callback_data="cfg_addon_traffic"))
+ builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="cfg_back_menu"))
+
+ await callback_query.message.edit_text(
+ "➕ Редактирование докупки\n\nВыберите параметр:",
+ reply_markup=builder.as_markup(),
+ )
+
+
+@router.callback_query(F.data == "cfg_back_menu", UserEditorState.config_menu, IsAdminFilter())
+async def handle_cfg_back_menu(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
+ await render_config_menu(callback_query, state, session)
+
+
+@router.callback_query(F.data == "cfg_base_devices", UserEditorState.config_menu, IsAdminFilter())
+async def handle_cfg_base_devices(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ tariff = await get_tariff_by_id(session, data.get("tariff_id"))
+ device_options = tariff.get("device_options") or [] if tariff else []
+ base_devices = data.get("cfg_base_devices") or 1
+
+ builder = InlineKeyboardBuilder()
+ for opt in sorted(device_options):
+ mark = " ✅" if int(opt) == int(base_devices) else ""
+ builder.button(text=f"{opt} устр.{mark}", callback_data=f"cfg_set_base_dev:{opt}")
+ builder.adjust(3)
+ builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="cfg_back_menu"))
+
+ await state.set_state(UserEditorState.config_select_base)
+ await state.update_data(cfg_param="devices")
+ await callback_query.message.edit_text(
+ "📱 Выберите базу устройств:",
+ reply_markup=builder.as_markup(),
+ )
+
+
+@router.callback_query(F.data == "cfg_base_traffic", UserEditorState.config_menu, IsAdminFilter())
+async def handle_cfg_base_traffic(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ tariff = await get_tariff_by_id(session, data.get("tariff_id"))
+ traffic_options = tariff.get("traffic_options_gb") or [] if tariff else []
+ base_traffic = data.get("cfg_base_traffic")
+
+ builder = InlineKeyboardBuilder()
+ for opt in sorted(traffic_options):
+ is_sel = (base_traffic is None and opt == 0) or (base_traffic is not None and int(opt) == int(base_traffic))
+ mark = " ✅" if is_sel else ""
+ label = "безлимит" if opt == 0 else f"{opt} ГБ"
+ builder.button(text=f"{label}{mark}", callback_data=f"cfg_set_base_traf:{opt}")
+ builder.adjust(2)
+ builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="cfg_back_menu"))
+
+ await state.set_state(UserEditorState.config_select_base)
+ await state.update_data(cfg_param="traffic")
+ await callback_query.message.edit_text(
+ "📊 Выберите базу трафика:",
+ reply_markup=builder.as_markup(),
+ )
+
+
+@router.callback_query(F.data.startswith("cfg_set_base_dev:"), UserEditorState.config_select_base, IsAdminFilter())
+async def handle_cfg_set_base_dev(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
+ base_devices = int(callback_query.data.split(":")[1])
+ await state.update_data(cfg_base_devices=base_devices)
+ await callback_query.answer(f"✅ База устройств: {base_devices}")
+ await state.set_state(UserEditorState.config_menu)
+ await render_config_menu(callback_query, state, session)
+
+
+@router.callback_query(F.data.startswith("cfg_set_base_traf:"), UserEditorState.config_select_base, IsAdminFilter())
+async def handle_cfg_set_base_traf(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
+ traffic_gb = int(callback_query.data.split(":")[1])
+ await state.update_data(cfg_base_traffic=traffic_gb if traffic_gb > 0 else None)
+ label = "безлимит" if traffic_gb == 0 else f"{traffic_gb} ГБ"
+ await callback_query.answer(f"✅ База трафика: {label}")
+ await state.set_state(UserEditorState.config_menu)
+ await render_config_menu(callback_query, state, session)
+
+
+@router.callback_query(F.data == "cfg_addon_devices", UserEditorState.config_menu, IsAdminFilter())
+async def handle_cfg_addon_devices(callback_query: CallbackQuery, state: FSMContext):
+ data = await state.get_data()
+ extra_devices = data.get("cfg_extra_devices") or 0
+
+ await state.set_state(UserEditorState.config_input_addon)
+ await state.update_data(cfg_param="devices")
+
+ builder = InlineKeyboardBuilder()
+ builder.row(InlineKeyboardButton(text="🔙 Отмена", callback_data="cfg_cancel_input"))
+
+ await callback_query.message.edit_text(
+ f"📱 Докупка устройств\n\n"
+ f"Текущее значение: {extra_devices}\n\n"
+ f"Введите новое количество докупленных устройств (число):",
+ reply_markup=builder.as_markup(),
+ )
+
+
+@router.callback_query(F.data == "cfg_addon_traffic", UserEditorState.config_menu, IsAdminFilter())
+async def handle_cfg_addon_traffic(callback_query: CallbackQuery, state: FSMContext):
+ data = await state.get_data()
+ extra_traffic = data.get("cfg_extra_traffic") or 0
+
+ await state.set_state(UserEditorState.config_input_addon)
+ await state.update_data(cfg_param="traffic")
+
+ builder = InlineKeyboardBuilder()
+ builder.row(InlineKeyboardButton(text="🔙 Отмена", callback_data="cfg_cancel_input"))
+
+ await callback_query.message.edit_text(
+ f"📊 Докупка трафика\n\n"
+ f"Текущее значение: {extra_traffic} ГБ\n\n"
+ f"Введите новое количество докупленного трафика в ГБ (число):",
+ reply_markup=builder.as_markup(),
+ )
+
+
+@router.callback_query(F.data == "cfg_cancel_input", UserEditorState.config_input_addon, IsAdminFilter())
+async def handle_cfg_cancel_input(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
+ await state.set_state(UserEditorState.config_menu)
+ await render_config_menu(callback_query, state, session)
+
+
+@router.message(UserEditorState.config_input_addon, IsAdminFilter())
+async def handle_cfg_input_addon(message: Message, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ param = data.get("cfg_param")
+ email = data.get("email")
+ tg_id = data.get("tg_id")
+ tariff_id = data.get("tariff_id")
+
+ if not message.text or not message.text.isdigit():
+ await message.answer("❌ Введите корректное число.")
+ return
+
+ value = int(message.text)
+ if value < 0:
+ await message.answer("❌ Значение не может быть отрицательным.")
+ return
+
+ if param == "devices":
+ await state.update_data(cfg_extra_devices=value)
+ else:
+ await state.update_data(cfg_extra_traffic=value)
+
+ await state.set_state(UserEditorState.config_menu)
+
+ data = await state.get_data()
+ tariff = await get_tariff_by_id(session, tariff_id)
+
+ base_devices = data.get("cfg_base_devices") or 1
+ extra_devices = data.get("cfg_extra_devices") or 0
+ base_traffic = data.get("cfg_base_traffic")
+ extra_traffic = data.get("cfg_extra_traffic") or 0
+
+ text = (
+ f"⚙️ Конфигурация ключа\n\n"
+ f"🔑 Ключ: {email}\n"
+ f"📦 Тариф: {tariff.get('name') if tariff else '—'}\n\n"
+ )
+
+ extra_dev_str = f" + {extra_devices} (докуплено)" if extra_devices > 0 else ""
+ text += f"📱 Устройства: {base_devices}{extra_dev_str}\n"
+
+ if base_traffic:
+ extra_traf_str = f" + {extra_traffic} ГБ (докуплено)" if extra_traffic > 0 else ""
+ text += f"📊 Трафик: {base_traffic} ГБ{extra_traf_str}\n"
+ else:
+ text += f"📊 Трафик: безлимит\n"
+
+ text += "\nВыберите что редактировать:"
+
+ builder = InlineKeyboardBuilder()
+ builder.row(
+ InlineKeyboardButton(text="📦 Тариф (база)", callback_data="cfg_edit_base"),
+ InlineKeyboardButton(text="➕ Докупка", callback_data="cfg_edit_addon"),
+ )
+ builder.row(InlineKeyboardButton(text="💾 Сохранить", callback_data="cfg_save"))
+ builder.row(
+ InlineKeyboardButton(
+ text="🔙 Назад",
+ callback_data=AdminUserEditorCallback(action="users_key_edit", data=email, tg_id=tg_id).pack(),
+ )
+ )
+
+ await message.answer(text=text, reply_markup=builder.as_markup())
+
+
+@router.callback_query(F.data == "cfg_save", UserEditorState.config_menu, IsAdminFilter())
+async def handle_cfg_save(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ email = data.get("email")
+ tg_id = data.get("tg_id")
+ tariff_id = data.get("tariff_id")
+
+ base_devices = data.get("cfg_base_devices") or 1
+ extra_devices = data.get("cfg_extra_devices") or 0
+ total_devices = base_devices + extra_devices
+
+ base_traffic = data.get("cfg_base_traffic")
+ extra_traffic = data.get("cfg_extra_traffic") or 0
+ total_traffic = (base_traffic + extra_traffic) if base_traffic else None
+
+ tariff = await get_tariff_by_id(session, tariff_id)
+ selected_price = None
+ if tariff:
+ base_price = tariff.get("price_rub") or 0
+
+ device_step = tariff.get("device_step_rub") or 0
+ tariff_base_devices = tariff.get("device_limit") or 1
+ extra_base_devices = max(0, base_devices - tariff_base_devices)
+ devices_extra_price = extra_base_devices * device_step
+
+ traffic_step = tariff.get("traffic_step_rub") or 0
+ tariff_base_traffic = tariff.get("traffic_limit") or 0
+ extra_base_traffic = max(0, (base_traffic or 0) - tariff_base_traffic) if base_traffic else 0
+ traffic_extra_price = extra_base_traffic * traffic_step
+
+ selected_price = base_price + devices_extra_price + traffic_extra_price
+
+ result = await session.execute(select(Key).where(Key.email == email))
+ key_obj: Key | None = result.scalar_one_or_none()
+
+ if not key_obj:
+ await callback_query.message.edit_text("❌ Ключ не найден.", reply_markup=build_editor_kb(tg_id))
+ await state.clear()
+ return
+
+ try:
+ await renew_key_in_cluster(
+ cluster_id=key_obj.server_id,
+ email=email,
+ client_id=key_obj.client_id,
+ new_expiry_time=key_obj.expiry_time,
+ total_gb=total_traffic or 0,
+ session=session,
+ hwid_device_limit=total_devices,
+ reset_traffic=False,
+ plan=tariff_id,
+ )
+
+ await session.execute(
+ update(Key)
+ .where(Key.email == email)
+ .values(
+ selected_device_limit=base_devices,
+ current_device_limit=total_devices,
+ selected_traffic_limit=base_traffic,
+ current_traffic_limit=total_traffic,
+ selected_price_rub=selected_price,
+ )
+ )
+ await session.commit()
+
+ await state.clear()
+ await callback_query.answer("✅ Конфигурация сохранена", show_alert=True)
+
+ 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,
+ )
+
+ except Exception as e:
+ logger.error(f"[EditConfig] Ошибка при сохранении конфигурации: {e}")
+ await callback_query.message.edit_text(
+ "❌ Не удалось сохранить конфигурацию. Попробуйте позже.",
+ reply_markup=build_editor_kb(tg_id),
+ )
+ await state.clear()
+
+
+@router.callback_query(F.data == "cfg_back_menu", IsAdminFilter())
+async def handle_cfg_back_menu_any(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
+ await state.set_state(UserEditorState.config_menu)
+ await render_config_menu(callback_query, state, session)
diff --git a/handlers/admin/users/users_states.py b/handlers/admin/users/users_states.py
index 935f7f0e..3a0ab499 100644
--- a/handlers/admin/users/users_states.py
+++ b/handlers/admin/users/users_states.py
@@ -11,6 +11,9 @@ class UserEditorState(StatesGroup):
selecting_cluster = State()
selecting_duration = State()
selecting_country = State()
+ config_menu = State()
+ config_select_base = State()
+ config_input_addon = State()
class RenewTariffState(StatesGroup):