diff --git a/database/models.py b/database/models.py
index 3c6f48df..4c86a69d 100644
--- a/database/models.py
+++ b/database/models.py
@@ -77,6 +77,7 @@ class Tariff(DictLikeMixin, Base):
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
+ subgroup_title = Column(String, nullable=True)
class Server(DictLikeMixin, Base):
diff --git a/handlers/admin/tariffs/keyboard.py b/handlers/admin/tariffs/keyboard.py
index 2cbd6caf..9e7438f6 100644
--- a/handlers/admin/tariffs/keyboard.py
+++ b/handlers/admin/tariffs/keyboard.py
@@ -1,6 +1,7 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
+from collections import defaultdict
from ..panel.keyboard import AdminPanelCallback
@@ -66,7 +67,22 @@ def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
else:
group_code = "unknown"
+ grouped = defaultdict(list)
for t in tariffs:
+ subgroup = t.get("subgroup_title")
+ grouped[subgroup].append(t)
+
+ for subgroup_title, items in grouped.items():
+ if subgroup_title:
+ safe_subgroup = subgroup_title.replace(" ", "_")
+ builder.row(
+ InlineKeyboardButton(
+ text=f"{subgroup_title}",
+ callback_data=f"view_subgroup|{safe_subgroup}|{group_code}"
+ )
+ )
+
+ for t in grouped.get(None, []):
title = f"{t['name']} — {t['price_rub']}₽"
builder.row(
InlineKeyboardButton(
@@ -84,7 +100,15 @@ def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
builder.row(
InlineKeyboardButton(
- text="⬅️ Назад", callback_data=AdminTariffCallback(action="list").pack()
+ text="Сгруппировать в подгруппу",
+ callback_data=f"start_subgrouping|{group_code}"
+ )
+ )
+
+ builder.row(
+ InlineKeyboardButton(
+ text="⬅️ Назад",
+ callback_data=AdminTariffCallback(action="list").pack(),
)
)
diff --git a/handlers/admin/tariffs/tariffs_handler.py b/handlers/admin/tariffs/tariffs_handler.py
index 6d7d807e..4c5e8b49 100644
--- a/handlers/admin/tariffs/tariffs_handler.py
+++ b/handlers/admin/tariffs/tariffs_handler.py
@@ -9,6 +9,7 @@ from aiogram.types import (
InlineKeyboardMarkup,
Message,
)
+from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import delete, distinct, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -45,6 +46,16 @@ class TariffEditState(StatesGroup):
editing_value = State()
+class TariffSubgroupState(StatesGroup):
+ selecting_tariffs = State()
+ entering_subgroup_title = State()
+
+
+class SubgroupEditState(StatesGroup):
+ entering_new_title = State()
+ confirming_deletion = State()
+
+
@router.callback_query(
AdminPanelCallback.filter(F.action == "tariffs"), IsAdminFilter()
)
@@ -271,6 +282,7 @@ def tariff_to_dict(tariff: Tariff) -> dict:
"name": tariff.name,
"price_rub": tariff.price_rub,
"group_code": tariff.group_code,
+ "subgroup_title": tariff.subgroup_title
}
@@ -503,4 +515,290 @@ def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
f"{'✅ Активен' if tariff.is_active else '⛔ Отключен'}"
)
- return text, build_single_tariff_kb(tariff.id)
\ No newline at end of file
+ return text, build_single_tariff_kb(tariff.id)
+
+
+@router.callback_query(F.data.startswith("start_subgrouping|"), IsAdminFilter())
+async def start_subgrouping(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
+ group_code = callback.data.split("|", 1)[1]
+
+ result = await session.execute(
+ select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id)
+ )
+ tariffs = result.scalars().all()
+
+ if not tariffs:
+ await callback.message.edit_text("❌ В этой группе нет тарифов.")
+ return
+
+ await state.set_state(TariffSubgroupState.selecting_tariffs)
+ await state.update_data(
+ group_code=group_code,
+ selected_tariff_ids=[]
+ )
+
+ builder = InlineKeyboardBuilder()
+ for tariff in tariffs:
+ builder.row(
+ InlineKeyboardButton(
+ text=f"{tariff.name}",
+ callback_data=f"sub_select|{tariff.id}"
+ )
+ )
+
+ builder.row(
+ InlineKeyboardButton(text="➡️ Продолжить", callback_data="subgroup_continue"),
+ InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping")
+ )
+
+ await callback.message.edit_text(
+ "Выберите тарифы, которые нужно объединить в подгруппу:",
+ reply_markup=builder.as_markup()
+ )
+
+
+@router.callback_query(F.data.startswith("sub_select|"), TariffSubgroupState.selecting_tariffs, IsAdminFilter())
+async def toggle_tariff_subgroup_selection(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
+ tariff_id = int(callback.data.split("|")[1])
+ data = await state.get_data()
+ selected = set(data.get("selected_tariff_ids", []))
+
+ if tariff_id in selected:
+ selected.remove(tariff_id)
+ else:
+ selected.add(tariff_id)
+
+ await state.update_data(selected_tariff_ids=list(selected))
+
+ group_code = data["group_code"]
+ result = await session.execute(
+ select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id)
+ )
+ tariffs = result.scalars().all()
+
+ builder = InlineKeyboardBuilder()
+ for tariff in tariffs:
+ is_selected = tariff.id in selected
+ prefix = "✅ " if is_selected else ""
+ builder.row(
+ InlineKeyboardButton(
+ text=f"{prefix}{tariff.name}",
+ callback_data=f"sub_select|{tariff.id}"
+ )
+ )
+
+ builder.row(
+ InlineKeyboardButton(text="➡️ Продолжить", callback_data="subgroup_continue"),
+ InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping")
+ )
+
+ await callback.message.edit_reply_markup(reply_markup=builder.as_markup())
+
+
+@router.callback_query(
+ F.data == "subgroup_continue",
+ TariffSubgroupState.selecting_tariffs,
+ IsAdminFilter(),
+)
+async def ask_subgroup_title(callback: CallbackQuery, state: FSMContext):
+ data = await state.get_data()
+ if not data.get("selected_tariff_ids"):
+ await callback.answer("Выберите хотя бы один тариф", show_alert=True)
+ return
+
+ await state.set_state(TariffSubgroupState.entering_subgroup_title)
+
+ keyboard = InlineKeyboardMarkup(
+ inline_keyboard=[
+ [InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping")]
+ ]
+ )
+
+ await callback.message.edit_text(
+ "📁 Введите название новой подгруппы:",
+ reply_markup=keyboard,
+ )
+
+
+@router.message(TariffSubgroupState.entering_subgroup_title, IsAdminFilter())
+async def apply_subgroup_title(message: Message, state: FSMContext, session: AsyncSession):
+ title = message.text.strip()
+ data = await state.get_data()
+ selected_ids = data.get("selected_tariff_ids", [])
+
+ if not selected_ids:
+ await message.answer("❌ Нет выбранных тарифов.")
+ await state.clear()
+ return
+
+ await session.execute(
+ update(Tariff)
+ .where(Tariff.id.in_(selected_ids))
+ .values(subgroup_title=title, updated_at=datetime.utcnow())
+ )
+ await session.commit()
+ await state.clear()
+
+ await message.answer(f"✅ {len(selected_ids)} тарифов сгруппированы в подгруппу: {title}.", reply_markup=build_tariff_menu_kb())
+
+
+@router.callback_query(F.data == "cancel_subgrouping", IsAdminFilter())
+async def cancel_subgrouping(callback: CallbackQuery, state: FSMContext):
+ await state.clear()
+ await callback.message.edit_text("❌ Группировка в подгруппу отменена.", reply_markup=build_tariff_menu_kb())
+
+
+@router.callback_query(F.data.startswith("view_subgroup|"), IsAdminFilter())
+async def view_subgroup_tariffs(callback: CallbackQuery, session: AsyncSession):
+ _, encoded_subgroup, group_code = callback.data.split("|", 2)
+ subgroup_title = encoded_subgroup.replace("_", " ")
+
+ result = await session.execute(
+ select(Tariff).where(
+ Tariff.group_code == group_code,
+ Tariff.subgroup_title == subgroup_title
+ ).order_by(Tariff.id)
+ )
+ tariffs = result.scalars().all()
+
+ if not tariffs:
+ await callback.message.edit_text("❌ В этой подгруппе пока нет тарифов.")
+ return
+
+ tariffs_dicts = [tariff_to_dict(t) for t in tariffs]
+
+ builder = InlineKeyboardBuilder()
+ for t in tariffs_dicts:
+ title = f"{t['name']} — {t['price_rub']}₽"
+ builder.row(
+ InlineKeyboardButton(
+ text=title,
+ callback_data=AdminTariffCallback(action=f"view|{t['id']}").pack(),
+ )
+ )
+
+ builder.row(
+ InlineKeyboardButton(
+ text="📝 Переименовать подгруппу",
+ callback_data=f"rename_subgroup|{encoded_subgroup}|{group_code}",
+ )
+ )
+ builder.row(
+ InlineKeyboardButton(
+ text="🗑 Удалить подгруппу",
+ callback_data=f"delete_subgroup|{encoded_subgroup}|{group_code}",
+ )
+ )
+
+ builder.row(
+ InlineKeyboardButton(
+ text="⬅️ Назад",
+ callback_data=AdminTariffCallback(action=f"group|{group_code}").pack(),
+ )
+ )
+
+ await callback.message.edit_text(
+ f"📂 Подгруппа: {subgroup_title}",
+ reply_markup=builder.as_markup(),
+ )
+
+
+@router.callback_query(F.data.startswith("rename_subgroup|"), IsAdminFilter())
+async def start_rename_subgroup(callback: CallbackQuery, state: FSMContext):
+ _, encoded_subgroup, group_code = callback.data.split("|", 2)
+ subgroup_title = encoded_subgroup.replace("_", " ")
+
+ await state.update_data(
+ subgroup_title=subgroup_title,
+ group_code=group_code,
+ )
+
+ await state.set_state(SubgroupEditState.entering_new_title)
+ await callback.message.edit_text(
+ f"📝 Введите новое название подгруппы:\n{subgroup_title}\n\nИли нажмите Отмена.",
+ reply_markup=InlineKeyboardMarkup(
+ inline_keyboard=[
+ [InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{encoded_subgroup}|{group_code}")]
+ ]
+ )
+ )
+
+
+@router.message(SubgroupEditState.entering_new_title, IsAdminFilter())
+async def save_new_subgroup_title(message: Message, state: FSMContext, session: AsyncSession):
+ new_title = message.text.strip()
+ data = await state.get_data()
+ old_title = data["subgroup_title"]
+ group_code = data["group_code"]
+
+ await session.execute(
+ update(Tariff)
+ .where(
+ Tariff.group_code == group_code,
+ Tariff.subgroup_title == old_title,
+ )
+ .values(subgroup_title=new_title)
+ )
+ await session.commit()
+ await state.clear()
+
+ await message.answer(
+ f"✅ Подгруппа {old_title} переименована в {new_title}.",
+ reply_markup=InlineKeyboardMarkup(
+ inline_keyboard=[
+ [InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"group|{group_code}").pack())]
+ ]
+ )
+ )
+
+
+@router.callback_query(F.data.startswith("delete_subgroup|"), IsAdminFilter())
+async def confirm_delete_subgroup(callback: CallbackQuery, state: FSMContext):
+ _, encoded_subgroup, group_code = callback.data.split("|", 2)
+ subgroup_title = encoded_subgroup.replace("_", " ")
+
+ await state.update_data(
+ subgroup_title=subgroup_title,
+ group_code=group_code,
+ )
+ await state.set_state(SubgroupEditState.confirming_deletion)
+
+ await callback.message.edit_text(
+ f"❗ Вы уверены, что хотите удалить подгруппу {subgroup_title}?\n"
+ "Это удалит поле `subgroup_title` у всех связанных тарифов.",
+ reply_markup=InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(text="✅ Удалить", callback_data="confirm_subgroup_deletion"),
+ InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{encoded_subgroup}|{group_code}")
+ ]
+ ]
+ )
+ )
+
+
+@router.callback_query(F.data == "confirm_subgroup_deletion", SubgroupEditState.confirming_deletion, IsAdminFilter())
+async def perform_subgroup_deletion(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ subgroup_title = data["subgroup_title"]
+ group_code = data["group_code"]
+
+ await session.execute(
+ update(Tariff)
+ .where(
+ Tariff.group_code == group_code,
+ Tariff.subgroup_title == subgroup_title
+ )
+ .values(subgroup_title=None)
+ )
+ await session.commit()
+ await state.clear()
+
+ await callback.message.edit_text(
+ f"✅ Подгруппа {subgroup_title} удалена.",
+ reply_markup=InlineKeyboardMarkup(
+ inline_keyboard=[
+ [InlineKeyboardButton(text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"group|{group_code}").pack())]
+ ]
+ )
+ )
diff --git a/handlers/keys/key_mode/key_create.py b/handlers/keys/key_mode/key_create.py
index b2d4174e..65645176 100644
--- a/handlers/keys/key_mode/key_create.py
+++ b/handlers/keys/key_mode/key_create.py
@@ -1,5 +1,7 @@
from datetime import datetime, timedelta
from typing import Any
+from collections import defaultdict
+from sqlalchemy.ext.asyncio import AsyncSession
import pytz
from aiogram import F, Router
@@ -104,14 +106,30 @@ async def handle_key_creation(
)
return
- builder = InlineKeyboardBuilder()
+ grouped_tariffs = defaultdict(list)
for t in tariffs:
+ subgroup = t.get("subgroup_title")
+ grouped_tariffs[subgroup].append(t)
+
+ builder = InlineKeyboardBuilder()
+
+ for t in grouped_tariffs.get(None, []):
builder.row(
InlineKeyboardButton(
text=f"{t['name']} — {t['price_rub']}₽",
callback_data=f"select_tariff_plan|{t['id']}",
)
)
+
+ for subgroup in sorted(k for k in grouped_tariffs if k):
+ safe = subgroup.replace(" ", "_")
+ builder.row(
+ InlineKeyboardButton(
+ text=f"{subgroup}",
+ callback_data=f"tariff_subgroup_user|{safe}",
+ )
+ )
+
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
target_message = (
@@ -126,10 +144,62 @@ async def handle_key_creation(
reply_markup=builder.as_markup(),
)
- await state.update_data(tg_id=tg_id)
+ await state.update_data(tg_id=tg_id, cluster_name=cluster_name)
await state.set_state(Form.waiting_for_server_selection)
+@router.callback_query(F.data.startswith("tariff_subgroup_user|"))
+async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMContext, session: Any):
+ subgroup = callback.data.split("|")[1].replace("_", " ")
+ data = await state.get_data()
+ cluster_name = data.get("cluster_name")
+
+ tariffs = await get_tariffs_for_cluster(session, cluster_name)
+ filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup]
+
+ if not filtered:
+ await edit_or_send_message(
+ target_message=callback.message,
+ text="❌ В этой подгруппе пока нет тарифов.",
+ reply_markup=None,
+ )
+ return
+
+ builder = InlineKeyboardBuilder()
+ for t in filtered:
+ builder.row(
+ InlineKeyboardButton(
+ text=f"{t['name']} — {t['price_rub']}₽",
+ callback_data=f"select_tariff_plan|{t['id']}",
+ )
+ )
+ builder.row(
+ InlineKeyboardButton(
+ text="⬅️ Назад",
+ callback_data="back_to_tariff_group_list"
+ )
+ )
+ builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
+
+ await edit_or_send_message(
+ target_message=callback.message,
+ text=f"{subgroup}\n\nВыберите тариф:",
+ reply_markup=builder.as_markup(),
+ )
+
+
+@router.callback_query(F.data == "back_to_tariff_group_list")
+async def back_to_tariff_group_list(callback: CallbackQuery, state: FSMContext, session: Any):
+ data = await state.get_data()
+ tg_id = callback.from_user.id
+ await handle_key_creation(
+ tg_id=tg_id,
+ state=state,
+ session=session,
+ message_or_query=callback,
+ )
+
+
@router.callback_query(F.data.startswith("select_tariff_plan|"))
async def select_tariff_plan(
callback_query: CallbackQuery, session: Any, state: FSMContext
diff --git a/handlers/keys/key_renew.py b/handlers/keys/key_renew.py
index 69bdf864..1ed34249 100644
--- a/handlers/keys/key_renew.py
+++ b/handlers/keys/key_renew.py
@@ -1,5 +1,7 @@
from datetime import datetime, timedelta
from typing import Any
+from aiogram.fsm.context import FSMContext
+from collections import defaultdict
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
@@ -44,9 +46,7 @@ moscow_tz = pytz.timezone("Europe/Moscow")
@router.callback_query(F.data.startswith("renew_key|"))
-async def process_callback_renew_key(
- callback_query: CallbackQuery, session: AsyncSession
-):
+async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
@@ -85,43 +85,50 @@ async def process_callback_renew_key(
await callback_query.message.answer("❌ Не удалось определить тарифную группу.")
return
- cluster_group = row[0]
- selected_tariffs = []
- target_group = cluster_group
+ group_code = row[0]
if tariff_id:
if await check_tariff_exists(session, tariff_id):
current_tariff = await get_tariff_by_id(session, tariff_id)
-
if current_tariff["group_code"] not in ["discounts", "discounts_max", "gifts"]:
- target_group = current_tariff["group_code"]
+ group_code = current_tariff["group_code"]
- tariffs = await get_tariffs(session, group_code=target_group)
-
+ tariffs = await get_tariffs(session, group_code=group_code)
+ tariffs = [t for t in tariffs if t["is_active"]]
if not tariffs:
await callback_query.message.answer("❌ Нет доступных тарифов для продления.")
return
- selected_tariffs = [t for t in tariffs if t["is_active"]]
- selected_tariffs = sorted(selected_tariffs, key=lambda t: t["id"])
+ grouped_tariffs = defaultdict(list)
+ for t in tariffs:
+ subgroup = t.get("subgroup_title")
+ grouped_tariffs[subgroup].append(t)
- if not selected_tariffs:
- await callback_query.message.answer("❌ Нет доступных тарифов для продления.")
- return
+ await state.update_data(
+ renew_key_name=key_name,
+ renew_client_id=client_id
+ )
builder = InlineKeyboardBuilder()
- for t in selected_tariffs:
- button_text = f"{t['name']} — {t['price_rub']}₽"
+
+ for t in grouped_tariffs.get(None, []):
builder.row(
InlineKeyboardButton(
- text=button_text,
+ text=f"{t['name']} — {t['price_rub']}₽",
callback_data=f"renew_plan|{t['id']}|{client_id}",
)
)
- builder.row(
- InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}")
- )
+ for subgroup in sorted(k for k in grouped_tariffs if k):
+ safe = subgroup.replace(" ", "_")[:30]
+ builder.row(
+ InlineKeyboardButton(
+ text=subgroup,
+ callback_data=f"renew_subgroup|{safe}",
+ )
+ )
+
+ builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
balance = await get_balance(session, tg_id)
response_message = PLAN_SELECTION_MSG.format(
@@ -138,12 +145,90 @@ async def process_callback_renew_key(
)
except Exception as e:
- logger.error(
- f"[RENEW] Ошибка в process_callback_renew_key для tg_id={tg_id}: {e}"
+ logger.error(f"[RENEW] Ошибка в process_callback_renew_key для tg_id={tg_id}: {e}")
+ await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
+
+
+@router.callback_query(F.data.startswith("renew_subgroup|"))
+async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
+ try:
+ parts = callback.data.split("|")
+ subgroup = parts[1].replace("_", " ")
+
+ data = await state.get_data()
+ client_id = data.get("renew_client_id")
+ key_name = data.get("renew_key_name")
+
+ if not client_id or not key_name:
+ await callback.message.answer("❌ Данные для подгруппы не найдены.")
+ return
+
+ record = await get_key_details(session, key_name)
+ if not record:
+ await callback.message.answer("❌ Ключ не найден.")
+ return
+
+ server_id = record["server_id"]
+ try:
+ server_id_int = int(server_id)
+ filter_condition = or_(
+ Server.id == server_id_int,
+ Server.server_name == server_id,
+ Server.cluster_name == server_id,
+ )
+ except ValueError:
+ filter_condition = or_(
+ Server.server_name == server_id,
+ Server.cluster_name == server_id,
+ )
+
+ row = await session.execute(
+ select(Server.tariff_group).where(filter_condition).limit(1)
)
- await callback_query.message.answer(
- "❌ Произошла ошибка при обработке. Попробуйте позже."
+ row = row.first()
+ if not row or not row[0]:
+ logger.warning(f"[RENEW_SUBGROUP] Тарифная группа не найдена для server_id={server_id}")
+ await callback.message.answer("❌ Не удалось определить тарифную группу.")
+ return
+
+ group_code = row[0]
+ tariffs = await get_tariffs(session, group_code=group_code)
+ filtered = [t for t in tariffs if t["subgroup_title"] == subgroup and t["is_active"]]
+
+ if not filtered:
+ await edit_or_send_message(
+ target_message=callback.message,
+ text="❌ В этой подгруппе пока нет тарифов.",
+ reply_markup=None,
+ )
+ return
+
+ builder = InlineKeyboardBuilder()
+ for t in filtered:
+ builder.row(
+ InlineKeyboardButton(
+ text=f"{t['name']} — {t['price_rub']}₽",
+ callback_data=f"renew_plan|{t['id']}|{client_id}",
+ )
+ )
+
+ builder.row(
+ InlineKeyboardButton(
+ text="⬅️ Назад",
+ callback_data=f"renew_key|{key_name}"
+ )
)
+ builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
+
+ await edit_or_send_message(
+ target_message=callback.message,
+ text=f"{subgroup}\n\nВыберите тариф:",
+ reply_markup=builder.as_markup(),
+ )
+
+ except Exception as e:
+ logger.error(f"[RENEW_SUBGROUP] Ошибка при отображении подгруппы: {e}")
+ await callback.message.answer("❌ Произошла ошибка при отображении тарифов.")
@router.callback_query(F.data.startswith("renew_plan|"))