import re from collections import defaultdict from datetime import datetime import pytz from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.types import ( CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, ) from aiogram.utils.keyboard import InlineKeyboardBuilder from sqlalchemy import delete, distinct, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from database import create_tariff from database.models import Gift, Key, Server, Tariff from database.tariffs import ( create_subgroup_hash, find_subgroup_by_hash, get_tariffs, move_tariff_down as db_move_tariff_down, move_tariff_up as db_move_tariff_up, ) from filters.admin import IsAdminFilter from ..panel.keyboard import AdminPanelCallback from .keyboard import ( AdminTariffCallback, build_cancel_kb, build_edit_tariff_fields_kb, build_single_tariff_kb, build_tariff_arrangement_groups_kb, build_tariff_groups_kb, build_tariff_list_kb, build_tariff_menu_kb, build_tariffs_arrangement_kb, ) router = Router() class TariffCreateState(StatesGroup): group = State() name = State() duration = State() price = State() traffic = State() confirm_more = State() device_limit = State() vless = State() class TariffEditState(StatesGroup): choosing_field = State() editing_value = State() class TariffSubgroupState(StatesGroup): selecting_tariffs = State() entering_subgroup_title = State() class SubgroupEditState(StatesGroup): entering_new_title = State() confirming_deletion = State() editing_tariffs = State() MAX_TARIFF_NAME_LENGTH = 40 MAX_SUBGROUP_TITLE_LENGTH = 40 def validate_tariff_name(name: str) -> tuple[bool, str]: if len(name) > MAX_TARIFF_NAME_LENGTH: return False, f"Название тарифа слишком длинное. Максимум {MAX_TARIFF_NAME_LENGTH} символов." return True, "" def validate_subgroup_title(title: str) -> tuple[bool, str]: if len(title) > MAX_SUBGROUP_TITLE_LENGTH: return False, f"Название подгруппы слишком длинное. Максимум {MAX_SUBGROUP_TITLE_LENGTH} символов." return True, "" @router.callback_query(AdminPanelCallback.filter(F.action == "tariffs"), IsAdminFilter()) async def handle_tariff_menu(callback_query: CallbackQuery): text = ( "💸 Управление тарифами\n\n" "Вы можете выполнить следующие действия:\n\n" "🆕 Создать тариф\n" "
• Установите длительность (в днях)\n" "• Задайте цену (в рублях)\n" "• Задайте лимит устройств (hwid/ip_limit)\n" "• Укажите лимит трафика (в ГБ)\n\n" "📋 Редактировать тарифы\n" "
• Просматривайте список текущих тарифов\n" "• Изменяйте параметры или удаляйте при необходимости" ) await callback_query.message.edit_text(text=text, reply_markup=build_tariff_menu_kb()) @router.callback_query(AdminTariffCallback.filter(F.action == "create"), IsAdminFilter()) async def start_tariff_creation(callback: CallbackQuery, state: FSMContext): await state.set_state(TariffCreateState.group) await callback.message.edit_text( "📁 Введите код группы, в которую вы хотите добавить тариф.\n\n" "Например:
basic, vip, business\n\n"
"Специальные группы:\n"
"• discounts — тарифы со скидкой\n"
"• discounts_max — тарифы с максимальной скидкой\n"
"• gifts — тарифы для подарков\n"
"• trial — тариф для пробного периода",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.group, IsAdminFilter())
async def process_tariff_group(message: Message, state: FSMContext):
group_code = message.text.strip().lower()
if not re.fullmatch(r"[a-z0-9_-]+", group_code):
await message.answer(
"❌ Код группы должен содержать только латинские буквы, цифры, дефисы и подчёркивания.\n\nПовторите ввод:",
reply_markup=build_cancel_kb(),
)
return
await state.update_data(group_code=group_code)
await state.set_state(TariffCreateState.name)
await message.answer(
"📝 Введите название тарифа\n\n"
"Например: 30 дней или 1 месяц\n\n"
"Это название будет отображаться пользователю при выборе тарифа",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.name, IsAdminFilter())
async def process_tariff_name(message: Message, state: FSMContext):
name = message.text.strip()
is_valid, error_msg = validate_tariff_name(name)
if not is_valid:
await message.answer(
f"❌ {error_msg}\n\nПовторите ввод:",
reply_markup=build_cancel_kb(),
)
return
await state.update_data(name=name)
await state.set_state(TariffCreateState.duration)
await message.answer(
"📅 Введите длительность тарифа в днях (например: 30):",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.duration, IsAdminFilter())
async def process_tariff_duration(message: Message, state: FSMContext):
try:
days = int(message.text.strip())
if days <= 0:
raise ValueError
except ValueError:
await message.answer("❌ Введите корректное количество дней (целое число больше 0):")
return
await state.update_data(duration_days=days)
await state.set_state(TariffCreateState.price)
await message.answer(
"💰 Введите цену тарифа в рублях (например: 150)\n\n"
"Будет показано клиенту при выборе тарифа",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.price, IsAdminFilter())
async def process_tariff_price(message: Message, state: FSMContext):
try:
price = int(message.text.strip())
if price < 0:
raise ValueError
except ValueError:
await message.answer("❌ Введите корректную цену (целое число 0 или больше):")
return
await state.update_data(price_rub=price)
await state.set_state(TariffCreateState.traffic)
await message.answer(
"📦 Введите лимит трафика в ГБ (например: 100, 0 — безлимит):",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.traffic, IsAdminFilter())
async def process_tariff_traffic(message: Message, state: FSMContext):
try:
traffic = int(message.text.strip())
if traffic < 0:
raise ValueError
except ValueError:
await message.answer("❌ Введите корректный лимит трафика (целое число 0 или больше):")
return
await state.update_data(traffic_limit=traffic if traffic > 0 else None)
await state.set_state(TariffCreateState.device_limit)
await message.answer(
"📱 Введите лимит устройств (HWID) для тарифа (например: 3, 0 — безлимит):",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.device_limit, IsAdminFilter())
async def process_tariff_device_limit(message: Message, state: FSMContext):
try:
device_limit = int(message.text.strip())
if device_limit < 0:
raise ValueError
except ValueError:
await message.answer("❌ Введите корректный лимит устройств (целое число 0 или больше):")
return
await state.update_data(device_limit=device_limit if device_limit > 0 else None)
await state.set_state(TariffCreateState.vless)
await message.answer(
"🔗 Этот тариф для VLESS?",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да (VLESS)", callback_data="create_vless|1"),
InlineKeyboardButton(text="❌ Нет", callback_data="create_vless|0"),
],
[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_tariff_creation")],
]
),
)
@router.callback_query(F.data.startswith("create_vless|"), TariffCreateState.vless, IsAdminFilter())
async def select_vless_creation(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
_, flag = callback.data.split("|", 1)
vless_flag = flag == "1"
data = await state.get_data()
new_tariff = await create_tariff(
session,
{
"name": data["name"],
"group_code": data["group_code"],
"duration_days": data["duration_days"],
"price_rub": data["price_rub"],
"traffic_limit": data["traffic_limit"],
"device_limit": data.get("device_limit"),
"vless": vless_flag,
},
)
await state.set_state(TariffCreateState.confirm_more)
await callback.message.edit_text(
f"✅ Тариф {new_tariff.name} добавлен в группу {data['group_code']}.\n\n"
"➕ Хотите добавить ещё один тариф в эту группу?",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да", callback_data="add_more_tariff"),
InlineKeyboardButton(text="❌ Нет", callback_data="done_tariff_group"),
]
]
),
)
@router.callback_query(F.data == "add_more_tariff", IsAdminFilter())
async def handle_add_more_tariff(callback: CallbackQuery, state: FSMContext):
await state.set_state(TariffCreateState.name)
await callback.message.edit_text("📝 Введите название следующего тарифа:", reply_markup=build_cancel_kb())
@router.callback_query(F.data == "done_tariff_group", IsAdminFilter())
async def handle_done_tariff_group(callback: CallbackQuery, state: FSMContext):
await state.clear()
await callback.message.edit_text("✅ Группа тарифов успешно завершена.", reply_markup=build_tariff_menu_kb())
@router.callback_query(F.data == "cancel_tariff_creation", IsAdminFilter())
async def cancel_tariff_creation(callback: CallbackQuery, state: FSMContext):
await state.clear()
await callback.message.edit_text("❌ Создание тарифа отменено.", reply_markup=build_tariff_menu_kb())
@router.callback_query(AdminTariffCallback.filter(F.action == "list"), IsAdminFilter())
async def show_tariff_groups(callback: CallbackQuery, session: AsyncSession):
result = await session.execute(
select(distinct(Tariff.group_code)).where(Tariff.group_code.isnot(None)).order_by(Tariff.group_code)
)
groups = [row[0] for row in result.fetchall()]
if not groups:
await callback.message.edit_text("❌ Нет сохранённых тарифов.", reply_markup=build_tariff_menu_kb())
return
special_groups = {
"discounts": "🔻 Скидки",
"discounts_max": "🔻 Макс. скидки",
"gifts": "🎁 Подарки",
"trial": "🚀 Пробный период",
}
text = "📋 Выберите тарифную группу:\n\n"
text += "Специальные группы:\n"
for code, label in special_groups.items():
status = "✅ создана" if code in groups else "❌ не создана"
text += f"{label} — {code} — {status}\n"
text += "\n"
await callback.message.edit_text(text, reply_markup=build_tariff_groups_kb(groups))
@router.callback_query(AdminTariffCallback.filter(F.action == "arrange"), IsAdminFilter())
async def show_tariff_arrangement_menu(callback: CallbackQuery, session: AsyncSession):
result = await session.execute(
select(distinct(Tariff.group_code)).where(Tariff.group_code.isnot(None)).order_by(Tariff.group_code)
)
groups = [row[0] for row in result.fetchall()]
if not groups:
await callback.message.edit_text("❌ Нет доступных групп тарифов.")
return
await callback.message.edit_text(
"🔢 Управление расположением тарифов\n\n"
"📋 Как это работает:\n"
"• Тарифы отображаются в порядке их расположения\n"
"• Меньший номер = выше в списке\n"
"• Новые тарифы добавляются в конец списка\n"
"• ⬆️ поднимает тариф выше (номер уменьшается)\n"
"• ⬇️ опускает тариф ниже (номер увеличивается)\n"
"• Подгруппы сортируются по общей сумме тарифов внутри\n\n"
"Выберите группу для управления расположением:",
reply_markup=build_tariff_arrangement_groups_kb(groups),
)
def tariff_to_dict(tariff) -> dict:
if isinstance(tariff, dict):
return tariff
return {
"id": tariff.id,
"name": tariff.name,
"price_rub": tariff.price_rub,
"group_code": tariff.group_code,
"subgroup_title": tariff.subgroup_title,
"sort_order": tariff.sort_order,
}
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("group|")), IsAdminFilter())
async def show_tariffs_in_group(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
group_code = callback_data.action.split("|")[1]
tariffs = await get_tariffs(session, group_code=group_code)
if not tariffs:
await callback.message.edit_text("❌ В этой группе пока нет тарифов.")
return
tariff_dicts = [tariff_to_dict(t) for t in tariffs]
await callback.message.edit_text(
f"📦 Тарифы группы: {group_code}",
reply_markup=build_tariff_list_kb(tariff_dicts),
)
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("arrange_group|")), IsAdminFilter())
async def show_tariffs_arrangement(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
group_code = callback_data.action.split("|")[1]
tariffs_data = await get_tariffs(session, group_code=group_code, with_subgroup_weights=True)
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
subgroup_weights = tariffs_data["subgroup_weights"]
if not tariffs:
await callback.message.edit_text("❌ В этой группе пока нет активных тарифов.")
return
grouped_tariffs = defaultdict(list)
for t in tariffs:
grouped_tariffs[t.get("subgroup_title")].append(t)
sorted_subgroups = sorted([k for k in grouped_tariffs if k], key=lambda x: (subgroup_weights.get(x, 999999), x))
moscow_tz = pytz.timezone("Europe/Moscow")
now = datetime.now(moscow_tz)
current_time = now.strftime("%d.%m.%y %H:%M:%S МСК")
text = f"🔢 Итоговая сортировка тарифов в группе: {group_code}\n\n"
if grouped_tariffs.get(None):
text += "📋 Основные тарифы:\n"
for t in grouped_tariffs[None]:
sort_order = t.get("sort_order", 1)
text += f"• {t.get('name')} [позиция: {sort_order}]\n"
text += "\n"
if sorted_subgroups:
text += "📁 Подгруппы:\n"
for subgroup in sorted_subgroups:
subgroup_weight = subgroup_weights.get(subgroup, 999999)
text += f"• {subgroup} [вес группы: {subgroup_weight}]\n"
for t in grouped_tariffs[subgroup]:
sort_order = t.get("sort_order", 1)
text += f" └ {t.get('name')} [позиция: {sort_order}]\n"
text += "\n"
text += f"\n{current_time}"
await callback.message.edit_text(
text,
reply_markup=build_tariffs_arrangement_kb(group_code, tariffs),
)
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("view|")), IsAdminFilter())
async def view_tariff(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
tariff_id = int(callback_data.action.split("|")[1])
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.message.edit_text("❌ Тариф не найден.")
return
text, markup = render_tariff_card(tariff)
await callback.message.edit_text(text=text, reply_markup=markup)
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("delete|")), IsAdminFilter())
async def confirm_tariff_deletion(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
tariff_id = int(callback_data.action.split("|")[1])
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.message.edit_text("❌ Тариф не найден.")
return
group_code = tariff.group_code
if group_code == "gifts":
gift_check = await session.execute(select(Gift).where(Gift.tariff_id == tariff_id).limit(1))
if gift_check.scalar_one_or_none():
result = await session.execute(select(Tariff).where(Tariff.group_code == "gifts", Tariff.id != tariff_id))
other_tariffs = result.scalars().all()
if not other_tariffs:
await callback.message.edit_text(
"❌ Нельзя удалить тариф — он используется в подарках, а других тарифов в группе 'gifts' нет.",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack()
)
]
]
),
)
return
builder = InlineKeyboardBuilder()
for t in other_tariffs:
builder.button(
text=f"{t.name} — {t.price_rub}₽",
callback_data=f"confirm_delete_tariff_with_replace|{tariff_id}|{t.id}",
)
builder.button(text="❌ Отмена", callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack())
await callback.message.edit_text(
"Этот тариф используется в подарках.\n\n"
"Выберите тариф, на который заменить его во всех подарках перед удалением:",
reply_markup=builder.as_markup(),
)
return
await callback.message.edit_text(
"⚠️ Вы уверены, что хотите удалить этот тариф?",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да", callback_data=f"confirm_delete_tariff|{tariff_id}"),
InlineKeyboardButton(
text="❌ Отмена", callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack()
),
]
]
),
)
@router.callback_query(F.data.startswith("confirm_delete_tariff_with_replace|"), IsAdminFilter())
async def delete_tariff_with_gift_replacement(callback: CallbackQuery, session: AsyncSession):
_, tariff_id_str, replacement_id_str = callback.data.split("|")
tariff_id = int(tariff_id_str)
replacement_id = int(replacement_id_str)
await session.execute(update(Gift).where(Gift.tariff_id == tariff_id).values(tariff_id=replacement_id))
await session.execute(update(Key).where(Key.tariff_id == tariff_id).values(tariff_id=None))
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.message.edit_text("❌ Тариф не найден.")
return
group_code = tariff.group_code
await session.execute(delete(Tariff).where(Tariff.id == tariff_id))
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code))
remaining_tariffs = result.scalars().all()
if not remaining_tariffs:
await session.execute(update(Server).where(Server.tariff_group == group_code).values(tariff_group=None))
await session.commit()
await callback.message.edit_text("🗑 Тариф удалён. Все подарки обновлены.", reply_markup=build_tariff_menu_kb())
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("edit|")), IsAdminFilter())
async def start_edit_tariff(callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext):
tariff_id = int(callback_data.action.split("|")[1])
await state.update_data(tariff_id=tariff_id)
await state.set_state(TariffEditState.choosing_field)
await callback.message.edit_text(
"✏️ Что вы хотите изменить?",
reply_markup=build_edit_tariff_fields_kb(tariff_id),
)
@router.callback_query(F.data.startswith("confirm_delete_tariff|"), IsAdminFilter())
async def delete_tariff(callback: CallbackQuery, session: AsyncSession):
tariff_id = int(callback.data.split("|", 1)[1])
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.message.edit_text("❌ Тариф не найден.")
return
group_code = tariff.group_code
await session.execute(update(Key).where(Key.tariff_id == tariff_id).values(tariff_id=None))
await session.execute(delete(Tariff).where(Tariff.id == tariff_id))
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code))
remaining_tariffs = result.scalars().all()
if not remaining_tariffs:
await session.execute(update(Server).where(Server.tariff_group == group_code).values(tariff_group=None))
await session.commit()
await callback.message.edit_text("🗑 Тариф успешно удалён.", reply_markup=build_tariff_menu_kb())
@router.callback_query(F.data.startswith("edit_field|"), IsAdminFilter())
async def ask_new_value(callback: CallbackQuery, state: FSMContext):
_, _tariff_id, field = callback.data.split("|")
await state.update_data(field=field)
await state.set_state(TariffEditState.editing_value)
if field == "vless":
data = await state.get_data()
tariff_id = int(data["tariff_id"])
await callback.message.edit_text(
"🔗 Установить флаг VLESS:",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да (VLESS)", callback_data=f"set_vless|{tariff_id}|1"),
InlineKeyboardButton(text="❌ Нет", callback_data=f"set_vless|{tariff_id}|0"),
],
[
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack(),
)
],
]
),
)
return
field_names = {
"name": "название тарифа",
"duration_days": "длительность в днях",
"price_rub": "цену в рублях",
"traffic_limit": "лимит трафика в ГБ (0 — безлимит)",
"device_limit": "лимит устройств (0 — безлимит)",
"vless": "VLESS (да/нет)",
}
await callback.message.edit_text(
f"✏️ Введите новое значение для {field_names.get(field, field)}:",
reply_markup=build_cancel_kb(),
)
@router.callback_query(F.data.startswith("set_vless|"), TariffEditState.editing_value, IsAdminFilter())
async def set_vless_flag(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
_, tariff_id_str, flag = callback.data.split("|", 2)
tariff_id = int(tariff_id_str)
vless_flag = flag == "1"
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.message.edit_text("❌ Тариф не найден.")
await state.clear()
return
tariff.vless = vless_flag
tariff.updated_at = datetime.utcnow()
await session.commit()
await state.clear()
text, markup = render_tariff_card(tariff)
await callback.message.edit_text(text=text, reply_markup=markup)
@router.message(TariffEditState.editing_value, IsAdminFilter())
async def apply_edit(message: Message, state: FSMContext, session: AsyncSession):
data = await state.get_data()
tariff_id = data["tariff_id"]
field = data["field"]
value = message.text.strip()
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await message.answer("❌ Тариф не найден.")
await state.clear()
return
if field == "name":
is_valid, error_msg = validate_tariff_name(value)
if not is_valid:
await message.answer(
f"❌ {error_msg}\n\nПовторите ввод:",
reply_markup=build_cancel_kb(),
)
return
if field in ["duration_days", "price_rub", "traffic_limit", "device_limit"]:
try:
num = int(value)
if num < 0:
raise ValueError
if field in ["traffic_limit", "device_limit"]:
value = num if num > 0 else None
else:
value = num
except ValueError:
await message.answer("❌ Введите корректное число.")
return
setattr(tariff, field, value)
tariff.updated_at = datetime.utcnow()
await session.commit()
await state.clear()
text, markup = render_tariff_card(tariff)
await message.answer(text=text, reply_markup=markup)
@router.callback_query(F.data.startswith("toggle_active|"), IsAdminFilter())
async def toggle_tariff_status(callback: CallbackQuery, session: AsyncSession):
tariff_id = int(callback.data.split("|")[1])
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.message.edit_text("❌ Тариф не найден.")
return
tariff.is_active = not tariff.is_active
await session.commit()
text, markup = render_tariff_card(tariff)
await callback.message.edit_text(text=text, reply_markup=markup)
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("create|")), IsAdminFilter())
async def start_tariff_creation_existing_group(
callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext
):
group_code = callback_data.action.split("|", 1)[1]
await state.update_data(group_code=group_code)
await state.set_state(TariffCreateState.name)
await callback.message.edit_text(
f"📦 Добавление нового тарифа в группу {group_code}\n\n📝 Введите название тарифа:",
reply_markup=build_cancel_kb(),
)
def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
traffic_text = f"{tariff.traffic_limit} ГБ" if tariff.traffic_limit else "Безлимит"
device_text = f"{tariff.device_limit}" if tariff.device_limit is not None else "Безлимит"
sort_order = getattr(tariff, "sort_order", 1)
vless_text = "Да" if getattr(tariff, "vless", False) else "Нет"
text = (
f"📄 Тариф: {tariff.name}\n\n"
f"📁 Группа: {tariff.group_code}\n"
f"📅 Длительность: {tariff.duration_days} дней\n"
f"💰 Стоимость: {tariff.price_rub}₽\n"
f"📦 Трафик: {traffic_text}\n"
f"📱 Устройств: {device_text}\n"
f"🔗 VLESS: {vless_text}\n"
f"🔢 Позиция: {sort_order}\n"
f"{'✅ Активен' if tariff.is_active else '⛔ Отключен'}"
)
return text, build_single_tariff_kb(tariff.id, tariff.group_code)
@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]
tariffs = await get_tariffs(session, group_code=group_code)
tariffs = [t for t in tariffs if not t.get("subgroup_title") or t.get("subgroup_title") == ""]
if not tariffs:
await callback.message.edit_text(
"❌ Нет доступных тарифов для группировки.\n\nВсе тарифы уже находятся в подгруппах.",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="⬅️ Назад", callback_data=AdminTariffCallback(action=f"group|{group_code}").pack()
)
]
]
),
)
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.get('name')}", callback_data=f"sub_select|{tariff.get('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"]
tariffs = await get_tariffs(session, group_code=group_code)
tariffs = [t for t in tariffs if not t.get("subgroup_title") or t.get("subgroup_title") == ""]
builder = InlineKeyboardBuilder()
for tariff in tariffs:
is_selected = tariff.get("id") in selected
prefix = "✅ " if is_selected else ""
builder.row(
InlineKeyboardButton(text=f"{prefix}{tariff.get('name')}", callback_data=f"sub_select|{tariff.get('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()
is_valid, error_msg = validate_subgroup_title(title)
if not is_valid:
await message.answer(
f"❌ {error_msg}\n\nПовторите ввод:",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_subgrouping")]]
),
)
return
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):
_, subgroup_hash, group_code = callback.data.split("|", 2)
subgroup_title = await find_subgroup_by_hash(session, subgroup_hash, group_code)
if not subgroup_title:
await callback.message.edit_text("❌ Подгруппа не найдена.")
return
tariffs = await get_tariffs(session, group_code=group_code)
tariffs = [t for t in tariffs if t.get("subgroup_title") == subgroup_title]
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|{subgroup_hash}|{group_code}",
)
)
builder.row(
InlineKeyboardButton(
text="✏️ Редактировать подгруппу",
callback_data=f"edit_subgroup_tariffs|{subgroup_hash}|{group_code}",
)
)
builder.row(
InlineKeyboardButton(
text="🗑 Удалить подгруппу",
callback_data=f"delete_subgroup|{subgroup_hash}|{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, session: AsyncSession):
_, subgroup_hash, group_code = callback.data.split("|", 2)
subgroup_title = await find_subgroup_by_hash(session, subgroup_hash, group_code)
if not subgroup_title:
await callback.message.edit_text("❌ Подгруппа не найдена.")
return
await state.update_data(
subgroup_title=subgroup_title,
group_code=group_code,
subgroup_hash=subgroup_hash,
)
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|{subgroup_hash}|{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()
is_valid, error_msg = validate_subgroup_title(new_title)
if not is_valid:
data = await state.get_data()
subgroup_hash = data.get("subgroup_hash")
group_code = data.get("group_code")
await message.answer(
f"❌ {error_msg}\n\nПовторите ввод:",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"
)
]
]
),
)
return
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()
create_subgroup_hash(new_title, group_code)
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, session: AsyncSession):
_, subgroup_hash, group_code = callback.data.split("|", 2)
subgroup_title = await find_subgroup_by_hash(session, subgroup_hash, group_code)
if not subgroup_title:
await callback.message.edit_text("❌ Подгруппа не найдена.")
return
await state.update_data(
subgroup_title=subgroup_title,
group_code=group_code,
subgroup_hash=subgroup_hash,
)
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|{subgroup_hash}|{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()
)
]
]
),
)
@router.callback_query(F.data.startswith("edit_subgroup_tariffs|"), IsAdminFilter())
async def start_edit_subgroup_tariffs(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
_, subgroup_hash, group_code = callback.data.split("|", 2)
subgroup_title = await find_subgroup_by_hash(session, subgroup_hash, group_code)
if not subgroup_title:
await callback.message.edit_text("❌ Подгруппа не найдена.")
return
all_tariffs_to_show = await get_tariffs(session, group_code=group_code)
all_tariffs_to_show = [
t
for t in all_tariffs_to_show
if t.get("subgroup_title") == subgroup_title or not t.get("subgroup_title") or t.get("subgroup_title") == ""
]
subgroup_tariff_ids = {t.get("id") for t in all_tariffs_to_show if t.get("subgroup_title") == subgroup_title}
if not all_tariffs_to_show:
await callback.message.edit_text(
"❌ Нет доступных тарифов для редактирования.",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}")]
]
),
)
return
await state.set_state(SubgroupEditState.editing_tariffs)
await state.update_data(
subgroup_title=subgroup_title,
group_code=group_code,
subgroup_hash=subgroup_hash,
selected_tariff_ids=list(subgroup_tariff_ids),
)
builder = InlineKeyboardBuilder()
for tariff in all_tariffs_to_show:
is_in_subgroup = tariff.get("id") in subgroup_tariff_ids
prefix = "✅ " if is_in_subgroup else ""
builder.row(
InlineKeyboardButton(
text=f"{prefix}{tariff.get('name')}", callback_data=f"edit_sub_toggle|{tariff.get('id')}"
)
)
builder.row(
InlineKeyboardButton(text="💾 Сохранить", callback_data="edit_sub_save"),
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"),
)
await callback.message.edit_text(
f"✏️ Редактирование подгруппы: {subgroup_title}\n\n"
"✅ - тарифы в подгруппе\n\n"
"Нажмите на тариф, чтобы добавить/убрать его:",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("edit_sub_toggle|"), SubgroupEditState.editing_tariffs, IsAdminFilter())
async def toggle_tariff_in_subgroup_edit(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
tariff_id = int(callback.data.split("|")[1])
data = await state.get_data()
selected_ids = set(data.get("selected_tariff_ids", []))
if tariff_id in selected_ids:
selected_ids.remove(tariff_id)
else:
selected_ids.add(tariff_id)
await state.update_data(selected_tariff_ids=list(selected_ids))
subgroup_title = data["subgroup_title"]
group_code = data["group_code"]
subgroup_hash = data["subgroup_hash"]
all_tariffs_to_show = await get_tariffs(session, group_code=group_code)
all_tariffs_to_show = [
t
for t in all_tariffs_to_show
if t.get("subgroup_title") == subgroup_title or not t.get("subgroup_title") or t.get("subgroup_title") == ""
]
builder = InlineKeyboardBuilder()
for tariff in all_tariffs_to_show:
is_selected = tariff.get("id") in selected_ids
prefix = "✅ " if is_selected else ""
builder.row(
InlineKeyboardButton(
text=f"{prefix}{tariff.get('name')}", callback_data=f"edit_sub_toggle|{tariff.get('id')}"
)
)
builder.row(
InlineKeyboardButton(text="💾 Сохранить", callback_data="edit_sub_save"),
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"),
)
await callback.message.edit_reply_markup(reply_markup=builder.as_markup())
@router.callback_query(F.data == "edit_sub_save", SubgroupEditState.editing_tariffs, IsAdminFilter())
async def save_subgroup_tariffs_changes(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
data = await state.get_data()
subgroup_title = data["subgroup_title"]
group_code = data["group_code"]
subgroup_hash = data["subgroup_hash"]
selected_tariff_ids = set(data.get("selected_tariff_ids", []))
result = await session.execute(
select(Tariff).where(Tariff.group_code == group_code, Tariff.subgroup_title == subgroup_title)
)
current_subgroup_tariffs = result.scalars().all()
current_tariff_ids = {t.id for t in current_subgroup_tariffs}
to_add = selected_tariff_ids - current_tariff_ids
to_remove = current_tariff_ids - selected_tariff_ids
if to_remove:
await session.execute(
update(Tariff).where(Tariff.id.in_(to_remove)).values(subgroup_title=None, updated_at=datetime.utcnow())
)
if to_add:
await session.execute(
update(Tariff)
.where(Tariff.id.in_(to_add))
.values(subgroup_title=subgroup_title, updated_at=datetime.utcnow())
)
await session.commit()
await state.clear()
if not selected_tariff_ids:
await callback.message.edit_text(
f"✅ Подгруппа {subgroup_title} была расформирована.",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="⬅️ Назад к группе тарифов",
callback_data=AdminTariffCallback(action=f"group|{group_code}").pack(),
)
]
]
),
)
return
changes_text = []
if to_add:
added_names = []
for tariff_id in to_add:
result = await session.execute(select(Tariff.name).where(Tariff.id == tariff_id))
name = result.scalar_one()
if name:
added_names.append(name)
changes_text.append(f"➕ Добавлено: {', '.join(added_names)}")
if to_remove:
removed_names = []
for tariff_id in to_remove:
result = await session.execute(select(Tariff.name).where(Tariff.id == tariff_id))
name = result.scalar_one()
if name:
removed_names.append(name)
changes_text.append(f"➖ Удалено: {', '.join(removed_names)}")
if not changes_text:
changes_text.append("Изменений не было")
await callback.message.edit_text(
f"✅ Подгруппа обновлена: {subgroup_title}\n\n{chr(10).join(changes_text)}",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="⬅️ Назад к подгруппе", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"
)
]
]
),
)
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("move_up|")), IsAdminFilter())
async def move_tariff_up(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
tariff_id = int(callback_data.action.split("|")[1])
success = await db_move_tariff_up(session, tariff_id)
if not success:
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
return
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.answer("❌ Тариф не найден", show_alert=True)
return
text, markup = render_tariff_card(tariff)
await callback.message.edit_text(text=text, reply_markup=markup)
await callback.answer("✅ Тариф перемещен выше (-1)")
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("move_down|")), IsAdminFilter())
async def move_tariff_down(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
tariff_id = int(callback_data.action.split("|")[1])
success = await db_move_tariff_down(session, tariff_id)
if not success:
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
return
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.answer("❌ Тариф не найден", show_alert=True)
return
text, markup = render_tariff_card(tariff)
await callback.message.edit_text(text=text, reply_markup=markup)
await callback.answer("✅ Тариф перемещен ниже (+1)")
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("quick_move_up|")), IsAdminFilter())
async def quick_move_tariff_up(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
parts = callback_data.action.split("|")
tariff_id = int(parts[1])
group_code = parts[2]
success = await db_move_tariff_up(session, tariff_id)
if not success:
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
return
await callback.answer("✅ Тариф перемещен выше (-1)")
new_callback_data = AdminTariffCallback(action=f"arrange_group|{group_code}")
await show_tariffs_arrangement(callback, new_callback_data, session)
@router.callback_query(AdminTariffCallback.filter(F.action.startswith("quick_move_down|")), IsAdminFilter())
async def quick_move_tariff_down(callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession):
parts = callback_data.action.split("|")
tariff_id = int(parts[1])
group_code = parts[2]
success = await db_move_tariff_down(session, tariff_id)
if not success:
await callback.answer("❌ Ошибка при перемещении тарифа", show_alert=True)
return
await callback.answer("✅ Тариф перемещен ниже (+1)")
new_callback_data = AdminTariffCallback(action=f"arrange_group|{group_code}")
await show_tariffs_arrangement(callback, new_callback_data, session)