From 48e15fbaadd12cd3d194b6bfbaba31e607fc1fd9 Mon Sep 17 00:00:00 2001 From: Vladless Date: Sat, 14 Jun 2025 01:58:29 +0300 Subject: [PATCH] subgroups in tariffs --- database/models.py | 1 + handlers/admin/tariffs/keyboard.py | 26 +- handlers/admin/tariffs/tariffs_handler.py | 300 +++++++++++++++++++++- handlers/keys/key_mode/key_create.py | 74 +++++- handlers/keys/key_renew.py | 135 ++++++++-- 5 files changed, 507 insertions(+), 29 deletions(-) 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|"))