import logging
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Dict, Optional, Tuple
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.promo_group import (
get_promo_groups_with_counts,
get_promo_group_by_id,
create_promo_group,
update_promo_group,
delete_promo_group,
get_promo_group_members,
count_promo_group_members,
)
from app.database.models import PromoGroup
from app.localization.texts import get_texts
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.keyboards.admin import (
get_admin_pagination_keyboard,
get_confirmation_keyboard,
)
from app.utils.pricing_utils import format_period_description
logger = logging.getLogger(__name__)
def _format_discount_lines(texts, group) -> list[str]:
return [
texts.t(
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER",
"💸 Скидки промогруппы:",
),
texts.t(
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS",
"• Серверы: {percent}%",
).format(percent=group.server_discount_percent),
texts.t(
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC",
"• Трафик: {percent}%",
).format(percent=group.traffic_discount_percent),
texts.t(
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES",
"• Устройства: {percent}%",
).format(percent=group.device_discount_percent),
]
def _format_addon_discounts_line(texts, group: PromoGroup) -> str:
enabled = getattr(group, "apply_discounts_to_addons", True)
if enabled:
return texts.t(
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED",
"🧩 Скидки на доп. услуги: включены",
)
return texts.t(
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED",
"🧩 Скидки на доп. услуги: отключены",
)
def _get_addon_discounts_button_text(texts, group: PromoGroup) -> str:
enabled = getattr(group, "apply_discounts_to_addons", True)
if enabled:
return texts.t(
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE",
"🧩 Отключить скидки на доп. услуги",
)
return texts.t(
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE",
"🧩 Включить скидки на доп. услуги",
)
def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]:
if not raw or not isinstance(raw, dict):
return {}
normalized: Dict[int, int] = {}
for key, value in raw.items():
try:
period = int(key)
percent = int(value)
except (TypeError, ValueError):
continue
normalized[period] = max(0, min(100, percent))
return normalized
def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]:
discounts = _normalize_periods_dict(getattr(group, "period_discounts", None))
if discounts:
return dict(sorted(discounts.items()))
if group.is_default and settings.is_base_promo_group_period_discount_enabled():
try:
base_discounts = settings.get_base_promo_group_period_discounts()
normalized = _normalize_periods_dict(base_discounts)
return dict(sorted(normalized.items()))
except Exception:
return {}
return {}
def _format_period_discounts_lines(texts, group: PromoGroup, language: str) -> list:
discounts = _collect_period_discounts(group)
if not discounts:
return []
header = texts.t(
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER",
"⏳ Скидки по периодам:",
)
lines = [header]
for period_days, percent in discounts.items():
period_display = format_period_description(period_days, language)
lines.append(
texts.t("PROMO_GROUP_PERIOD_DISCOUNT_ITEM", "{period} — {percent}%").format(
period=period_display,
percent=percent,
)
)
return lines
def _format_period_discounts_value(discounts: Dict[int, int]) -> str:
if not discounts:
return "0"
return ", ".join(
f"{period}:{percent}"
for period, percent in sorted(discounts.items())
)
def _parse_period_discounts_input(value: str) -> Dict[int, int]:
cleaned = (value or "").strip()
if not cleaned or cleaned in {"0", "-"}:
return {}
cleaned = cleaned.replace(";", ",").replace("\n", ",")
parts = [part.strip() for part in cleaned.split(",") if part.strip()]
if not parts:
return {}
discounts: Dict[int, int] = {}
for part in parts:
if ":" not in part:
raise ValueError
period_raw, percent_raw = part.split(":", 1)
period = int(period_raw.strip())
percent = int(percent_raw.strip())
if period <= 0:
raise ValueError
discounts[period] = max(0, min(100, percent))
return discounts
async def _prompt_for_period_discounts(
message: types.Message,
state: FSMContext,
prompt_key: str,
default_text: str,
*,
current_value: Optional[str] = None,
):
data = await state.get_data()
texts = get_texts(data.get("language", "ru"))
prompt_text = texts.t(prompt_key, default_text)
if current_value is not None:
try:
prompt_text = prompt_text.format(current=current_value)
except KeyError:
pass
await message.answer(prompt_text)
def _format_rubles(amount_kopeks: int) -> str:
if amount_kopeks <= 0:
return "0"
rubles = Decimal(amount_kopeks) / Decimal(100)
if rubles == rubles.to_integral_value():
formatted = f"{rubles:,.0f}"
else:
formatted = f"{rubles:,.2f}"
return formatted.replace(",", " ")
def _format_auto_assign_line(texts, group: PromoGroup) -> str:
threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0
if threshold <= 0:
return texts.t(
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED",
"Автовыдача по суммарным тратам: отключена",
)
amount = _format_rubles(threshold)
return texts.t(
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE",
"Автовыдача по суммарным тратам: от {amount} ₽",
).format(amount=amount)
def _format_auto_assign_value(value_kopeks: Optional[int]) -> str:
if not value_kopeks or value_kopeks <= 0:
return "0"
rubles = Decimal(value_kopeks) / Decimal(100)
quantized = (
rubles.quantize(Decimal("1"))
if rubles == rubles.to_integral_value()
else rubles.quantize(Decimal("0.01"))
)
return str(quantized)
def _parse_auto_assign_threshold_input(value: str) -> int:
cleaned = (value or "").strip()
if not cleaned or cleaned in {"0", "-", "off", "нет"}:
return 0
normalized = cleaned.replace(" ", "").replace(",", ".")
try:
amount = Decimal(normalized)
except InvalidOperation:
raise ValueError
if amount < 0:
raise ValueError
kopeks = int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
return max(0, kopeks)
async def _prompt_for_auto_assign_threshold(
message: types.Message,
state: FSMContext,
prompt_key: str,
default_text: str,
*,
current_value: Optional[str] = None,
):
data = await state.get_data()
texts = get_texts(data.get("language", "ru"))
prompt_text = texts.t(prompt_key, default_text)
if current_value is not None:
try:
prompt_text = prompt_text.format(current=current_value)
except KeyError:
pass
await message.answer(prompt_text)
def _build_edit_menu_content(
texts,
group: PromoGroup,
language: str,
) -> Tuple[str, types.InlineKeyboardMarkup]:
header = texts.t(
"ADMIN_PROMO_GROUP_EDIT_MENU_TITLE",
"✏️ Настройки промогруппы «{name}»",
).format(name=group.name)
lines = [header]
lines.extend(_format_discount_lines(texts, group))
lines.append(_format_addon_discounts_line(texts, group))
lines.append(_format_auto_assign_line(texts, group))
period_lines = _format_period_discounts_lines(texts, group, language)
lines.extend(period_lines)
lines.append(
texts.t(
"ADMIN_PROMO_GROUP_EDIT_MENU_HINT",
"Выберите параметр для изменения:",
)
)
text = "\n".join(line for line in lines if line)
keyboard_rows = [
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PROMO_GROUP_EDIT_FIELD_NAME",
"✏️ Изменить название",
),
callback_data=f"promo_group_edit_field_{group.id}_name",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC",
"🌐 Скидка на трафик",
),
callback_data=f"promo_group_edit_field_{group.id}_traffic",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS",
"🖥 Скидка на серверы",
),
callback_data=f"promo_group_edit_field_{group.id}_servers",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES",
"📱 Скидка на устройства",
),
callback_data=f"promo_group_edit_field_{group.id}_devices",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS",
"⏳ Скидки по периодам",
),
callback_data=f"promo_group_edit_field_{group.id}_periods",
)
],
[
types.InlineKeyboardButton(
text=_get_addon_discounts_button_text(texts, group),
callback_data=f"promo_group_toggle_addons_{group.id}",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN",
"🤖 Автовыдача по тратам",
),
callback_data=f"promo_group_edit_field_{group.id}_auto",
)
],
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"promo_group_manage_{group.id}",
)
],
]
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
return text, keyboard
def _get_edit_prompt_keyboard(group_id: int, texts) -> types.InlineKeyboardMarkup:
return types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"promo_group_edit_{group_id}",
)
]
]
)
async def _send_edit_menu_after_update(
message: types.Message,
texts,
group: PromoGroup,
language: str,
success_message: Optional[str] = None,
):
menu_text, keyboard = _build_edit_menu_content(texts, group, language)
parts = [part for part in [success_message, menu_text] if part]
text = "\n\n".join(parts)
from_user = getattr(message, "from_user", None)
if getattr(from_user, "is_bot", False):
try:
await message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML",
)
return
except TelegramBadRequest:
pass
await message.answer(
text,
reply_markup=keyboard,
parse_mode="HTML",
)
@admin_required
@error_handler
async def show_promo_groups_menu(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
groups = await get_promo_groups_with_counts(db)
total_members = sum(count for _, count in groups)
header = texts.t("ADMIN_PROMO_GROUPS_TITLE", "💳 Промогруппы")
if groups:
summary = texts.t(
"ADMIN_PROMO_GROUPS_SUMMARY",
"Всего групп: {count}\nВсего участников: {members}",
).format(count=len(groups), members=total_members)
lines = [header, "", summary, ""]
keyboard_rows = []
for group, member_count in groups:
default_suffix = (
texts.t("ADMIN_PROMO_GROUPS_DEFAULT_LABEL", " (базовая)")
if group.is_default
else ""
)
group_lines = [
f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}",
]
group_lines.extend(_format_discount_lines(texts, group))
group_lines.append(_format_auto_assign_line(texts, group))
group_lines.append(
texts.t(
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
"Участников: {count}",
).format(count=member_count)
)
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
group_lines.extend(period_lines)
group_lines.append("")
lines.extend(group_lines)
keyboard_rows.append([
types.InlineKeyboardButton(
text=f"{'⭐' if group.is_default else '🎯'} {group.name}",
callback_data=f"promo_group_manage_{group.id}",
)
])
else:
lines = [header, "", texts.t("ADMIN_PROMO_GROUPS_EMPTY", "Промогруппы не найдены.")]
keyboard_rows = []
keyboard_rows.append(
[types.InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_group_create")]
)
keyboard_rows.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")]
)
await callback.message.edit_text(
"\n".join(line for line in lines if line is not None),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
parse_mode="HTML",
)
await callback.answer()
async def _get_group_or_alert(
callback: types.CallbackQuery,
db: AsyncSession,
) -> Optional[PromoGroup]:
group_id = int(callback.data.split("_")[-1])
group = await get_promo_group_by_id(db, group_id)
if not group:
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
return None
return group
@admin_required
@error_handler
async def show_promo_group_details(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
group = await _get_group_or_alert(callback, db)
if not group:
return
texts = get_texts(db_user.language)
member_count = await count_promo_group_members(db, group.id)
default_note = (
texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.")
if group.is_default
else ""
)
lines = [
texts.t(
"ADMIN_PROMO_GROUP_DETAILS_TITLE",
"💳 Промогруппа: {name}",
).format(name=group.name)
]
lines.extend(_format_discount_lines(texts, group))
lines.append(_format_auto_assign_line(texts, group))
lines.append(
texts.t(
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
"Участников: {count}",
).format(count=member_count)
)
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
lines.extend(period_lines)
if default_note:
lines.append(default_note)
text = "\n".join(line for line in lines if line)
keyboard_rows = []
if member_count > 0:
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PROMO_GROUP_MEMBERS_BUTTON", "👥 Участники"),
callback_data=f"promo_group_members_{group.id}_page_1",
)
]
)
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PROMO_GROUP_EDIT_BUTTON", "✏️ Изменить"),
callback_data=f"promo_group_edit_{group.id}",
)
]
)
if not group.is_default:
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PROMO_GROUP_DELETE_BUTTON", "🗑️ Удалить"),
callback_data=f"promo_group_delete_{group.id}",
)
]
)
keyboard_rows.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
)
await callback.message.edit_text(
text.strip(),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
parse_mode="HTML",
)
await callback.answer()
def _validate_percent(value: str) -> int:
percent = int(value)
if percent < 0 or percent > 100:
raise ValueError
return percent
async def _prompt_for_discount(
message: types.Message,
state: FSMContext,
prompt_key: str,
default_text: str,
):
data = await state.get_data()
texts = get_texts(data.get("language", "ru"))
await message.answer(texts.t(prompt_key, default_text))
@admin_required
@error_handler
async def start_create_promo_group(
callback: types.CallbackQuery,
db_user,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
await state.set_state(AdminStates.creating_promo_group_name)
await state.update_data(language=db_user.language)
await callback.message.edit_text(
texts.t("ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT", "Введите название новой промогруппы:"),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
]
),
)
await callback.answer()
async def process_create_group_name(message: types.Message, state: FSMContext):
name = message.text.strip()
if not name:
texts = get_texts((await state.get_data()).get("language", "ru"))
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым."))
return
await state.update_data(new_group_name=name)
await state.set_state(AdminStates.creating_promo_group_traffic_discount)
await _prompt_for_discount(
message,
state,
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT",
"Введите скидку на трафик (0-100):",
)
async def process_create_group_traffic(message: types.Message, state: FSMContext):
texts = get_texts((await state.get_data()).get("language", "ru"))
try:
value = _validate_percent(message.text)
except (ValueError, TypeError):
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
await state.update_data(new_group_traffic=value)
await state.set_state(AdminStates.creating_promo_group_server_discount)
await _prompt_for_discount(
message,
state,
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT",
"Введите скидку на серверы (0-100):",
)
async def process_create_group_servers(message: types.Message, state: FSMContext):
texts = get_texts((await state.get_data()).get("language", "ru"))
try:
value = _validate_percent(message.text)
except (ValueError, TypeError):
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
await state.update_data(new_group_servers=value)
await state.set_state(AdminStates.creating_promo_group_device_discount)
await _prompt_for_discount(
message,
state,
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT",
"Введите скидку на устройства (0-100):",
)
@admin_required
@error_handler
async def process_create_group_devices(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
devices_discount = _validate_percent(message.text)
except (ValueError, TypeError):
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
await state.update_data(new_group_devices=devices_discount)
await state.set_state(AdminStates.creating_promo_group_period_discount)
await _prompt_for_period_discounts(
message,
state,
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT",
"Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.",
)
@admin_required
@error_handler
async def process_create_group_period_discounts(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
period_discounts = _parse_period_discounts_input(message.text)
except ValueError:
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS",
"Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
)
)
return
await state.update_data(new_group_period_discounts=period_discounts)
await state.set_state(AdminStates.creating_promo_group_auto_assign)
await _prompt_for_auto_assign_threshold(
message,
state,
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT",
"Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.",
)
@admin_required
@error_handler
async def process_create_group_auto_assign(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
auto_assign_kopeks = _parse_auto_assign_threshold_input(message.text)
except ValueError:
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN",
"Введите неотрицательное число в рублях или 0 для отключения.",
)
)
return
try:
group = await create_promo_group(
db,
data["new_group_name"],
traffic_discount_percent=data["new_group_traffic"],
server_discount_percent=data["new_group_servers"],
device_discount_percent=data["new_group_devices"],
period_discounts=data.get("new_group_period_discounts"),
auto_assign_total_spent_kopeks=auto_assign_kopeks,
)
except Exception as e:
logger.error(f"Не удалось создать промогруппу: {e}")
await message.answer(texts.ERROR)
await state.clear()
return
await state.clear()
await message.answer(
texts.t("ADMIN_PROMO_GROUP_CREATED", "Промогруппа «{name}» создана.").format(
name=group.name
),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON",
"↩️ К промогруппам",
),
callback_data="admin_promo_groups",
)
]
]
),
)
@admin_required
@error_handler
async def start_edit_promo_group(
callback: types.CallbackQuery,
db_user,
state: FSMContext,
db: AsyncSession,
):
group = await _get_group_or_alert(callback, db)
if not group:
return
texts = get_texts(db_user.language)
await state.update_data(edit_group_id=group.id, language=db_user.language)
await state.set_state(AdminStates.editing_promo_group_menu)
text, keyboard = _build_edit_menu_content(texts, group, db_user.language)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def prompt_edit_promo_group_field(
callback: types.CallbackQuery,
db_user,
state: FSMContext,
db: AsyncSession,
):
parts = callback.data.split("_")
if len(parts) < 6:
await callback.answer("❌ Неверная команда", show_alert=True)
return
group_id = int(parts[4])
field = parts[5]
group = await get_promo_group_by_id(db, group_id)
if not group:
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
return
await state.update_data(edit_group_id=group.id, language=db_user.language)
texts = get_texts(db_user.language)
reply_markup = _get_edit_prompt_keyboard(group.id, texts)
if field == "name":
await state.set_state(AdminStates.editing_promo_group_name)
prompt = texts.t(
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
"Введите новое название промогруппы (текущее: {name}):",
).format(name=group.name)
elif field == "traffic":
await state.set_state(AdminStates.editing_promo_group_traffic_discount)
prompt = texts.t(
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT",
"Введите новую скидку на трафик (текущее значение: {current}%):",
).format(current=group.traffic_discount_percent)
elif field == "servers":
await state.set_state(AdminStates.editing_promo_group_server_discount)
prompt = texts.t(
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT",
"Введите новую скидку на серверы (текущее значение: {current}%):",
).format(current=group.server_discount_percent)
elif field == "devices":
await state.set_state(AdminStates.editing_promo_group_device_discount)
prompt = texts.t(
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT",
"Введите новую скидку на устройства (текущее значение: {current}%):",
).format(current=group.device_discount_percent)
elif field == "periods":
await state.set_state(AdminStates.editing_promo_group_period_discount)
current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None))
prompt = texts.t(
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT",
"Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
).format(current=_format_period_discounts_value(current_discounts))
elif field == "auto":
await state.set_state(AdminStates.editing_promo_group_auto_assign)
prompt = texts.t(
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT",
"Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.",
).format(current=_format_auto_assign_value(group.auto_assign_total_spent_kopeks))
else:
await callback.answer("❌ Неизвестный параметр", show_alert=True)
return
await callback.message.edit_text(prompt, reply_markup=reply_markup)
await callback.answer()
@admin_required
@error_handler
async def process_edit_group_name(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
name = message.text.strip()
if not name:
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым."))
return
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
if not group:
await message.answer("❌ Промогруппа не найдена")
await state.clear()
return
group = await update_promo_group(db, group, name=name)
await state.set_state(AdminStates.editing_promo_group_menu)
await _send_edit_menu_after_update(
message,
texts,
group,
data.get("language", db_user.language),
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
@admin_required
@error_handler
async def process_edit_group_traffic(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
value = _validate_percent(message.text)
except (ValueError, TypeError):
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
if not group:
await message.answer("❌ Промогруппа не найдена")
await state.clear()
return
group = await update_promo_group(db, group, traffic_discount_percent=value)
await state.set_state(AdminStates.editing_promo_group_menu)
await _send_edit_menu_after_update(
message,
texts,
group,
data.get("language", db_user.language),
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
@admin_required
@error_handler
async def process_edit_group_servers(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
value = _validate_percent(message.text)
except (ValueError, TypeError):
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
if not group:
await message.answer("❌ Промогруппа не найдена")
await state.clear()
return
group = await update_promo_group(db, group, server_discount_percent=value)
await state.set_state(AdminStates.editing_promo_group_menu)
await _send_edit_menu_after_update(
message,
texts,
group,
data.get("language", db_user.language),
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
@admin_required
@error_handler
async def process_edit_group_devices(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
devices_discount = _validate_percent(message.text)
except (ValueError, TypeError):
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
if not group:
await message.answer("❌ Промогруппа не найдена")
await state.clear()
return
group = await update_promo_group(db, group, device_discount_percent=devices_discount)
await state.set_state(AdminStates.editing_promo_group_menu)
await _send_edit_menu_after_update(
message,
texts,
group,
data.get("language", db_user.language),
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
@admin_required
@error_handler
async def process_edit_group_period_discounts(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
period_discounts = _parse_period_discounts_input(message.text)
except ValueError:
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS",
"Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
)
)
return
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
if not group:
await message.answer("❌ Промогруппа не найдена")
await state.clear()
return
group = await update_promo_group(db, group, period_discounts=period_discounts)
await state.set_state(AdminStates.editing_promo_group_menu)
await _send_edit_menu_after_update(
message,
texts,
group,
data.get("language", db_user.language),
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
@admin_required
@error_handler
async def process_edit_group_auto_assign(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
auto_assign_kopeks = _parse_auto_assign_threshold_input(message.text)
except ValueError:
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN",
"Введите неотрицательное число в рублях или 0 для отключения.",
)
)
return
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
if not group:
await message.answer("❌ Промогруппа не найдена")
await state.clear()
return
group = await update_promo_group(
db,
group,
auto_assign_total_spent_kopeks=auto_assign_kopeks,
)
await state.set_state(AdminStates.editing_promo_group_menu)
await _send_edit_menu_after_update(
message,
texts,
group,
data.get("language", db_user.language),
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
@admin_required
@error_handler
async def show_promo_group_members(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
parts = callback.data.split("_")
group_id = int(parts[3])
page = int(parts[-1])
limit = 10
offset = (page - 1) * limit
group = await get_promo_group_by_id(db, group_id)
if not group:
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
return
texts = get_texts(db_user.language)
members = await get_promo_group_members(db, group_id, offset=offset, limit=limit)
total_members = await count_promo_group_members(db, group_id)
total_pages = max(1, (total_members + limit - 1) // limit)
title = texts.t(
"ADMIN_PROMO_GROUP_MEMBERS_TITLE",
"👥 Участники группы {name}",
).format(name=group.name)
if not members:
body = texts.t("ADMIN_PROMO_GROUP_MEMBERS_EMPTY", "В этой группе пока нет участников.")
else:
lines = []
for index, user in enumerate(members, start=offset + 1):
username = f"@{user.username}" if user.username else "—"
lines.append(
f"{index}. {user.full_name} (ID {user.id}, {username}, TG {user.telegram_id})"
)
body = "\n".join(lines)
keyboard = []
if total_pages > 1:
pagination = get_admin_pagination_keyboard(
page,
total_pages,
f"promo_group_members_{group_id}",
f"promo_group_manage_{group_id}",
db_user.language,
)
keyboard.extend(pagination.inline_keyboard)
keyboard.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group_id}")]
)
await callback.message.edit_text(
f"{title}\n\n{body}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
@admin_required
@error_handler
async def request_delete_promo_group(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
group = await _get_group_or_alert(callback, db)
if not group:
return
texts = get_texts(db_user.language)
if group.is_default:
await callback.answer(
texts.t("ADMIN_PROMO_GROUP_DELETE_FORBIDDEN", "Базовую промогруппу нельзя удалить."),
show_alert=True,
)
return
confirm_text = texts.t(
"ADMIN_PROMO_GROUP_DELETE_CONFIRM",
"Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.",
).format(name=group.name)
await callback.message.edit_text(
confirm_text,
reply_markup=get_confirmation_keyboard(
confirm_action=f"promo_group_delete_confirm_{group.id}",
cancel_action=f"promo_group_manage_{group.id}",
language=db_user.language,
),
)
await callback.answer()
@admin_required
@error_handler
async def delete_promo_group_confirmed(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
group = await _get_group_or_alert(callback, db)
if not group:
return
texts = get_texts(db_user.language)
success = await delete_promo_group(db, group)
if not success:
await callback.answer(
texts.t("ADMIN_PROMO_GROUP_DELETE_FORBIDDEN", "Базовую промогруппу нельзя удалить."),
show_alert=True,
)
return
await callback.message.edit_text(
texts.t("ADMIN_PROMO_GROUP_DELETED", "Промогруппа «{name}» удалена.").format(name=group.name),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
]
),
)
await callback.answer()
@admin_required
@error_handler
async def toggle_promo_group_addon_discounts(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
group = await _get_group_or_alert(callback, db)
if not group:
return
texts = get_texts(db_user.language)
new_value = not getattr(group, "apply_discounts_to_addons", True)
group = await update_promo_group(
db,
group,
apply_discounts_to_addons=new_value,
)
status_text = texts.t(
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED"
if new_value
else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED",
"🧩 Скидки на докупку доп. услуг {status}.",
).format(status="включены" if new_value else "отключены")
await _send_edit_menu_after_update(
callback.message,
texts,
group,
db_user.language,
status_text,
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups")
dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_"))
dp.callback_query.register(start_create_promo_group, F.data == "admin_promo_group_create")
dp.callback_query.register(
prompt_edit_promo_group_field,
F.data.startswith("promo_group_edit_field_"),
)
dp.callback_query.register(
toggle_promo_group_addon_discounts,
F.data.startswith("promo_group_toggle_addons_"),
)
dp.callback_query.register(
start_edit_promo_group,
F.data.regexp(r"^promo_group_edit_\d+$"),
)
dp.callback_query.register(
request_delete_promo_group,
F.data.startswith("promo_group_delete_")
& ~F.data.startswith("promo_group_delete_confirm_"),
)
dp.callback_query.register(
delete_promo_group_confirmed,
F.data.startswith("promo_group_delete_confirm_"),
)
dp.callback_query.register(
show_promo_group_members,
F.data.regexp(r"^promo_group_members_\d+_page_\d+$"),
)
dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name)
dp.message.register(
process_create_group_traffic,
AdminStates.creating_promo_group_traffic_discount,
)
dp.message.register(
process_create_group_servers,
AdminStates.creating_promo_group_server_discount,
)
dp.message.register(
process_create_group_devices,
AdminStates.creating_promo_group_device_discount,
)
dp.message.register(
process_create_group_period_discounts,
AdminStates.creating_promo_group_period_discount,
)
dp.message.register(
process_create_group_auto_assign,
AdminStates.creating_promo_group_auto_assign,
)
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
dp.message.register(
process_edit_group_traffic,
AdminStates.editing_promo_group_traffic_discount,
)
dp.message.register(
process_edit_group_servers,
AdminStates.editing_promo_group_server_discount,
)
dp.message.register(
process_edit_group_devices,
AdminStates.editing_promo_group_device_discount,
)
dp.message.register(
process_edit_group_period_discounts,
AdminStates.editing_promo_group_period_discount,
)
dp.message.register(
process_edit_group_auto_assign,
AdminStates.editing_promo_group_auto_assign,
)