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