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):