import logging import re from typing import List from aiogram import Bot, Dispatcher, types, F from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.campaign import ( create_campaign, delete_campaign, get_campaign_by_id, get_campaign_by_start_parameter, get_campaign_statistics, get_campaigns_count, get_campaigns_list, get_campaigns_overview, update_campaign, ) from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_id from app.database.models import User from app.keyboards.admin import ( get_admin_campaigns_keyboard, get_admin_pagination_keyboard, get_campaign_bonus_type_keyboard, get_campaign_edit_keyboard, get_campaign_management_keyboard, get_confirmation_keyboard, ) from app.localization.texts import get_texts from app.states import AdminStates from app.utils.decorators import admin_required, error_handler logger = logging.getLogger(__name__) _CAMPAIGN_PARAM_REGEX = re.compile(r"^[A-Za-z0-9_-]{3,32}$") _CAMPAIGNS_PAGE_SIZE = 5 def _format_campaign_summary(campaign, texts) -> str: status = "🟢 Активна" if campaign.is_active else "⚪️ Выключена" if campaign.is_balance_bonus: bonus_text = texts.format_price(campaign.balance_bonus_kopeks) bonus_info = f"💰 Бонус на баланс: {bonus_text}" else: traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0) bonus_info = ( "📱 Подписка: {days} д.\n" "🌐 Трафик: {traffic}\n" "📱 Устройства: {devices}" ).format( days=campaign.subscription_duration_days or 0, traffic=traffic_text, devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT, ) return ( f"{campaign.name}\n" f"Стартовый параметр: {campaign.start_parameter}\n" f"Статус: {status}\n" f"{bonus_info}\n" ) async def _get_bot_deep_link( callback: types.CallbackQuery, start_parameter: str ) -> str: bot = await callback.bot.get_me() return f"https://t.me/{bot.username}?start={start_parameter}" async def _get_bot_deep_link_from_message( message: types.Message, start_parameter: str ) -> str: bot = await message.bot.get_me() return f"https://t.me/{bot.username}?start={start_parameter}" def _build_campaign_servers_keyboard( servers, selected_uuids: List[str], *, toggle_prefix: str = "campaign_toggle_server_", save_callback: str = "campaign_servers_save", back_callback: str = "admin_campaigns", ) -> types.InlineKeyboardMarkup: keyboard: List[List[types.InlineKeyboardButton]] = [] for server in servers[:20]: is_selected = server.squad_uuid in selected_uuids emoji = "✅" if is_selected else ("⚪" if server.is_available else "🔒") text = f"{emoji} {server.display_name}" keyboard.append( [ types.InlineKeyboardButton( text=text, callback_data=f"{toggle_prefix}{server.id}" ) ] ) keyboard.append( [ types.InlineKeyboardButton( text="✅ Сохранить", callback_data=save_callback ), types.InlineKeyboardButton( text="⬅️ Назад", callback_data=back_callback ), ] ) return types.InlineKeyboardMarkup(inline_keyboard=keyboard) async def _render_campaign_edit_menu( bot: Bot, chat_id: int, message_id: int, campaign, language: str, *, use_caption: bool = False, ): texts = get_texts(language) text = ( "✏️ Редактирование кампании\n\n" f"{_format_campaign_summary(campaign, texts)}\n" "Выберите, что изменить:" ) edit_kwargs = dict( chat_id=chat_id, message_id=message_id, reply_markup=get_campaign_edit_keyboard( campaign.id, is_balance_bonus=campaign.is_balance_bonus, language=language, ), parse_mode="HTML", ) if use_caption: await bot.edit_message_caption( caption=text, **edit_kwargs, ) else: await bot.edit_message_text( text=text, **edit_kwargs, ) @admin_required @error_handler async def show_campaigns_menu( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): texts = get_texts(db_user.language) overview = await get_campaigns_overview(db) text = ( "📣 Рекламные кампании\n\n" f"Всего кампаний: {overview['total']}\n" f"Активных: {overview['active']} | Выключены: {overview['inactive']}\n" f"Регистраций: {overview['registrations']}\n" f"Выдано баланса: {texts.format_price(overview['balance_total'])}\n" f"Выдано подписок: {overview['subscription_total']}" ) await callback.message.edit_text( text, reply_markup=get_admin_campaigns_keyboard(db_user.language), ) await callback.answer() @admin_required @error_handler async def show_campaigns_overall_stats( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): texts = get_texts(db_user.language) overview = await get_campaigns_overview(db) text = ["📊 Общая статистика кампаний\n"] text.append(f"Всего кампаний: {overview['total']}") text.append( f"Активны: {overview['active']}, выключены: {overview['inactive']}" ) text.append(f"Всего регистраций: {overview['registrations']}") text.append( f"Суммарно выдано баланса: {texts.format_price(overview['balance_total'])}" ) text.append(f"Выдано подписок: {overview['subscription_total']}") await callback.message.edit_text( "\n".join(text), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text="⬅️ Назад", callback_data="admin_campaigns" ) ] ] ), ) await callback.answer() @admin_required @error_handler async def show_campaigns_list( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): texts = get_texts(db_user.language) page = 1 if callback.data.startswith("admin_campaigns_list_page_"): try: page = int(callback.data.split("_")[-1]) except ValueError: page = 1 offset = (page - 1) * _CAMPAIGNS_PAGE_SIZE campaigns = await get_campaigns_list( db, offset=offset, limit=_CAMPAIGNS_PAGE_SIZE, ) total = await get_campaigns_count(db) total_pages = max(1, (total + _CAMPAIGNS_PAGE_SIZE - 1) // _CAMPAIGNS_PAGE_SIZE) if not campaigns: await callback.message.edit_text( "❌ Рекламные кампании не найдены.", reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text="➕ Создать", callback_data="admin_campaigns_create" ) ], [ types.InlineKeyboardButton( text="⬅️ Назад", callback_data="admin_campaigns" ) ], ] ), ) await callback.answer() return text_lines = ["📋 Список кампаний\n"] for campaign in campaigns: registrations = len(campaign.registrations or []) total_balance = sum( r.balance_bonus_kopeks or 0 for r in campaign.registrations or [] ) status = "🟢" if campaign.is_active else "⚪" line = ( f"{status} {campaign.name}{campaign.start_parameter}\n" f" Регистраций: {registrations}, баланс: {texts.format_price(total_balance)}" ) if campaign.is_subscription_bonus: line += f", подписка: {campaign.subscription_duration_days or 0} д." else: line += ", бонус: баланс" text_lines.append(line) keyboard_rows = [ [ types.InlineKeyboardButton( text=f"🔍 {campaign.name}", callback_data=f"admin_campaign_manage_{campaign.id}", ) ] for campaign in campaigns ] pagination = get_admin_pagination_keyboard( current_page=page, total_pages=total_pages, callback_prefix="admin_campaigns_list", back_callback="admin_campaigns", language=db_user.language, ) keyboard_rows.extend(pagination.inline_keyboard) await callback.message.edit_text( "\n".join(text_lines), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), ) await callback.answer() @admin_required @error_handler async def show_campaign_detail( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): campaign_id = int(callback.data.split("_")[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await callback.answer("❌ Кампания не найдена", show_alert=True) return texts = get_texts(db_user.language) stats = await get_campaign_statistics(db, campaign_id) deep_link = await _get_bot_deep_link(callback, campaign.start_parameter) text = ["📣 Управление кампанией\n"] text.append(_format_campaign_summary(campaign, texts)) text.append(f"🔗 Ссылка: {deep_link}") text.append("\n📊 Статистика") text.append(f"• Регистраций: {stats['registrations']}") text.append( f"• Выдано баланса: {texts.format_price(stats['balance_issued'])}" ) text.append(f"• Выдано подписок: {stats['subscription_issued']}") text.append( f"• Доход: {texts.format_price(stats['total_revenue_kopeks'])}" ) text.append( "• Получили триал: " f"{stats['trial_users_count']}" f" (активно: {stats['active_trials_count']})" ) text.append( "• Конверсий в оплату: " f"{stats['conversion_count']}" f" / пользователей с оплатой: {stats['paid_users_count']}" ) text.append( "• Конверсия в оплату: " f"{stats['conversion_rate']:.1f}%" ) text.append( "• Конверсия триала: " f"{stats['trial_conversion_rate']:.1f}%" ) text.append( "• Средний доход на пользователя: " f"{texts.format_price(stats['avg_revenue_per_user_kopeks'])}" ) text.append( "• Средний первый платеж: " f"{texts.format_price(stats['avg_first_payment_kopeks'])}" ) if stats["last_registration"]: text.append( f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" ) await callback.message.edit_text( "\n".join(text), reply_markup=get_campaign_management_keyboard( campaign.id, campaign.is_active, db_user.language ), ) await callback.answer() @admin_required @error_handler async def show_campaign_edit_menu( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): campaign_id = int(callback.data.split("_")[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await state.clear() await callback.answer("❌ Кампания не найдена", show_alert=True) return await state.clear() use_caption = bool(callback.message.caption) and not bool(callback.message.text) await _render_campaign_edit_menu( callback.bot, callback.message.chat.id, callback.message.message_id, campaign, db_user.language, use_caption=use_caption, ) await callback.answer() @admin_required @error_handler async def start_edit_campaign_name( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): campaign_id = int(callback.data.split("_")[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await callback.answer("❌ Кампания не найдена", show_alert=True) return await state.clear() await state.set_state(AdminStates.editing_campaign_name) is_caption = bool(callback.message.caption) and not bool(callback.message.text) await state.update_data( editing_campaign_id=campaign_id, campaign_edit_message_id=callback.message.message_id, campaign_edit_message_is_caption=is_caption, ) await callback.message.edit_text( ( "✏️ Изменение названия кампании\n\n" f"Текущее название: {campaign.name}\n" "Введите новое название (3-100 символов):" ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text="❌ Отмена", callback_data=f"admin_campaign_edit_{campaign_id}", ) ] ] ), ) await callback.answer() @admin_required @error_handler async def process_edit_campaign_name( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession, ): data = await state.get_data() campaign_id = data.get("editing_campaign_id") if not campaign_id: await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") await state.clear() return new_name = message.text.strip() if len(new_name) < 3 or len(new_name) > 100: await message.answer( "❌ Название должно содержать от 3 до 100 символов. Попробуйте снова." ) return campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await message.answer("❌ Кампания не найдена") await state.clear() return await update_campaign(db, campaign, name=new_name) await state.clear() await message.answer("✅ Название обновлено.") edit_message_id = data.get("campaign_edit_message_id") edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) if edit_message_id: await _render_campaign_edit_menu( message.bot, message.chat.id, edit_message_id, campaign, db_user.language, use_caption=edit_message_is_caption, ) @admin_required @error_handler async def start_edit_campaign_start_parameter( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): campaign_id = int(callback.data.split("_")[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await callback.answer("❌ Кампания не найдена", show_alert=True) return await state.clear() await state.set_state(AdminStates.editing_campaign_start) is_caption = bool(callback.message.caption) and not bool(callback.message.text) await state.update_data( editing_campaign_id=campaign_id, campaign_edit_message_id=callback.message.message_id, campaign_edit_message_is_caption=is_caption, ) await callback.message.edit_text( ( "🔗 Изменение стартового параметра\n\n" f"Текущий параметр: {campaign.start_parameter}\n" "Введите новый параметр (латинские буквы, цифры, - или _, 3-32 символа):" ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text="❌ Отмена", callback_data=f"admin_campaign_edit_{campaign_id}", ) ] ] ), ) await callback.answer() @admin_required @error_handler async def process_edit_campaign_start_parameter( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession, ): data = await state.get_data() campaign_id = data.get("editing_campaign_id") if not campaign_id: await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") await state.clear() return new_param = message.text.strip() if not _CAMPAIGN_PARAM_REGEX.match(new_param): await message.answer( "❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа." ) return campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await message.answer("❌ Кампания не найдена") await state.clear() return existing = await get_campaign_by_start_parameter(db, new_param) if existing and existing.id != campaign_id: await message.answer("❌ Такой параметр уже используется. Введите другой вариант.") return await update_campaign(db, campaign, start_parameter=new_param) await state.clear() await message.answer("✅ Стартовый параметр обновлен.") edit_message_id = data.get("campaign_edit_message_id") edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) if edit_message_id: await _render_campaign_edit_menu( message.bot, message.chat.id, edit_message_id, campaign, db_user.language, use_caption=edit_message_is_caption, ) @admin_required @error_handler async def start_edit_campaign_balance_bonus( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): campaign_id = int(callback.data.split("_")[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await callback.answer("❌ Кампания не найдена", show_alert=True) return if not campaign.is_balance_bonus: await callback.answer("❌ У кампании другой тип бонуса", show_alert=True) return await state.clear() await state.set_state(AdminStates.editing_campaign_balance) is_caption = bool(callback.message.caption) and not bool(callback.message.text) await state.update_data( editing_campaign_id=campaign_id, campaign_edit_message_id=callback.message.message_id, campaign_edit_message_is_caption=is_caption, ) await callback.message.edit_text( ( "💰 Изменение бонуса на баланс\n\n" f"Текущий бонус: {get_texts(db_user.language).format_price(campaign.balance_bonus_kopeks)}\n" "Введите новую сумму в рублях (например, 100 или 99.5):" ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text="❌ Отмена", callback_data=f"admin_campaign_edit_{campaign_id}", ) ] ] ), ) await callback.answer() @admin_required @error_handler async def process_edit_campaign_balance_bonus( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession, ): data = await state.get_data() campaign_id = data.get("editing_campaign_id") if not campaign_id: await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") await state.clear() return try: amount_rubles = float(message.text.replace(",", ".")) except ValueError: await message.answer("❌ Введите корректную сумму (например, 100 или 99.5)") return if amount_rubles <= 0: await message.answer("❌ Сумма должна быть больше нуля") return amount_kopeks = int(round(amount_rubles * 100)) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await message.answer("❌ Кампания не найдена") await state.clear() return if not campaign.is_balance_bonus: await message.answer("❌ У кампании другой тип бонуса") await state.clear() return await update_campaign(db, campaign, balance_bonus_kopeks=amount_kopeks) await state.clear() await message.answer("✅ Бонус обновлен.") edit_message_id = data.get("campaign_edit_message_id") edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) if edit_message_id: await _render_campaign_edit_menu( message.bot, message.chat.id, edit_message_id, campaign, db_user.language, use_caption=edit_message_is_caption, ) async def _ensure_subscription_campaign(message_or_callback, campaign) -> bool: if campaign.is_balance_bonus: if isinstance(message_or_callback, types.CallbackQuery): await message_or_callback.answer( "❌ Для этой кампании доступен только бонус на баланс", show_alert=True, ) else: await message_or_callback.answer( "❌ Для этой кампании нельзя изменить параметры подписки" ) return False return True @admin_required @error_handler async def start_edit_campaign_subscription_days( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): campaign_id = int(callback.data.split("_")[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await callback.answer("❌ Кампания не найдена", show_alert=True) return if not await _ensure_subscription_campaign(callback, campaign): return await state.clear() await state.set_state(AdminStates.editing_campaign_subscription_days) is_caption = bool(callback.message.caption) and not bool(callback.message.text) await state.update_data( editing_campaign_id=campaign_id, campaign_edit_message_id=callback.message.message_id, campaign_edit_message_is_caption=is_caption, ) await callback.message.edit_text( ( "📅 Изменение длительности подписки\n\n" f"Текущее значение: {campaign.subscription_duration_days or 0} д.\n" "Введите новое количество дней (1-730):" ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text="❌ Отмена", callback_data=f"admin_campaign_edit_{campaign_id}", ) ] ] ), ) await callback.answer() @admin_required @error_handler async def process_edit_campaign_subscription_days( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession, ): data = await state.get_data() campaign_id = data.get("editing_campaign_id") if not campaign_id: await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") await state.clear() return try: days = int(message.text.strip()) except ValueError: await message.answer("❌ Введите число дней (1-730)") return if days <= 0 or days > 730: await message.answer("❌ Длительность должна быть от 1 до 730 дней") return campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await message.answer("❌ Кампания не найдена") await state.clear() return if not await _ensure_subscription_campaign(message, campaign): await state.clear() return await update_campaign(db, campaign, subscription_duration_days=days) await state.clear() await message.answer("✅ Длительность подписки обновлена.") edit_message_id = data.get("campaign_edit_message_id") edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) if edit_message_id: await _render_campaign_edit_menu( message.bot, message.chat.id, edit_message_id, campaign, db_user.language, use_caption=edit_message_is_caption, ) @admin_required @error_handler async def start_edit_campaign_subscription_traffic( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): campaign_id = int(callback.data.split("_")[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await callback.answer("❌ Кампания не найдена", show_alert=True) return if not await _ensure_subscription_campaign(callback, campaign): return await state.clear() await state.set_state(AdminStates.editing_campaign_subscription_traffic) is_caption = bool(callback.message.caption) and not bool(callback.message.text) await state.update_data( editing_campaign_id=campaign_id, campaign_edit_message_id=callback.message.message_id, campaign_edit_message_is_caption=is_caption, ) current_traffic = campaign.subscription_traffic_gb or 0 traffic_text = "безлимит" if current_traffic == 0 else f"{current_traffic} ГБ" await callback.message.edit_text( ( "🌐 Изменение лимита трафика\n\n" f"Текущее значение: {traffic_text}\n" "Введите новый лимит в ГБ (0 = безлимит, максимум 10000):" ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text="❌ Отмена", callback_data=f"admin_campaign_edit_{campaign_id}", ) ] ] ), ) await callback.answer() @admin_required @error_handler async def process_edit_campaign_subscription_traffic( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession, ): data = await state.get_data() campaign_id = data.get("editing_campaign_id") if not campaign_id: await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") await state.clear() return try: traffic = int(message.text.strip()) except ValueError: await message.answer("❌ Введите целое число (0 или больше)") return if traffic < 0 or traffic > 10000: await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ") return campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await message.answer("❌ Кампания не найдена") await state.clear() return if not await _ensure_subscription_campaign(message, campaign): await state.clear() return await update_campaign(db, campaign, subscription_traffic_gb=traffic) await state.clear() await message.answer("✅ Лимит трафика обновлен.") edit_message_id = data.get("campaign_edit_message_id") edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) if edit_message_id: await _render_campaign_edit_menu( message.bot, message.chat.id, edit_message_id, campaign, db_user.language, use_caption=edit_message_is_caption, ) @admin_required @error_handler async def start_edit_campaign_subscription_devices( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): campaign_id = int(callback.data.split("_")[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await callback.answer("❌ Кампания не найдена", show_alert=True) return if not await _ensure_subscription_campaign(callback, campaign): return await state.clear() await state.set_state(AdminStates.editing_campaign_subscription_devices) is_caption = bool(callback.message.caption) and not bool(callback.message.text) await state.update_data( editing_campaign_id=campaign_id, campaign_edit_message_id=callback.message.message_id, campaign_edit_message_is_caption=is_caption, ) current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT await callback.message.edit_text( ( "📱 Изменение лимита устройств\n\n" f"Текущее значение: {current_devices}\n" f"Введите новое количество (1-{settings.MAX_DEVICES_LIMIT}):" ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text="❌ Отмена", callback_data=f"admin_campaign_edit_{campaign_id}", ) ] ] ), ) await callback.answer() @admin_required @error_handler async def process_edit_campaign_subscription_devices( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession, ): data = await state.get_data() campaign_id = data.get("editing_campaign_id") if not campaign_id: await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") await state.clear() return try: devices = int(message.text.strip()) except ValueError: await message.answer("❌ Введите целое число устройств") return if devices < 1 or devices > settings.MAX_DEVICES_LIMIT: await message.answer( f"❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}" ) return campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await message.answer("❌ Кампания не найдена") await state.clear() return if not await _ensure_subscription_campaign(message, campaign): await state.clear() return await update_campaign(db, campaign, subscription_device_limit=devices) await state.clear() await message.answer("✅ Лимит устройств обновлен.") edit_message_id = data.get("campaign_edit_message_id") edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) if edit_message_id: await _render_campaign_edit_menu( message.bot, message.chat.id, edit_message_id, campaign, db_user.language, use_caption=edit_message_is_caption, ) @admin_required @error_handler async def start_edit_campaign_subscription_servers( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): campaign_id = int(callback.data.split("_")[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await callback.answer("❌ Кампания не найдена", show_alert=True) return if not await _ensure_subscription_campaign(callback, campaign): return servers, _ = await get_all_server_squads(db, available_only=False) if not servers: await callback.answer( "❌ Не найдены доступные серверы. Добавьте серверы перед изменением.", show_alert=True, ) return selected = list(campaign.subscription_squads or []) await state.clear() await state.set_state(AdminStates.editing_campaign_subscription_servers) is_caption = bool(callback.message.caption) and not bool(callback.message.text) await state.update_data( editing_campaign_id=campaign_id, campaign_edit_message_id=callback.message.message_id, campaign_subscription_squads=selected, campaign_edit_message_is_caption=is_caption, ) keyboard = _build_campaign_servers_keyboard( servers, selected, toggle_prefix=f"campaign_edit_toggle_{campaign_id}_", save_callback=f"campaign_edit_servers_save_{campaign_id}", back_callback=f"admin_campaign_edit_{campaign_id}", ) await callback.message.edit_text( ( "🌍 Редактирование доступных серверов\n\n" "Нажмите на сервер, чтобы добавить или убрать его из кампании.\n" "После выбора нажмите \"✅ Сохранить\"." ), reply_markup=keyboard, ) await callback.answer() @admin_required @error_handler async def toggle_edit_campaign_server( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): parts = callback.data.split("_") try: server_id = int(parts[-1]) except (ValueError, IndexError): await callback.answer("❌ Не удалось определить сервер", show_alert=True) return data = await state.get_data() campaign_id = data.get("editing_campaign_id") if not campaign_id: await callback.answer("❌ Сессия редактирования устарела", show_alert=True) await state.clear() return server = await get_server_squad_by_id(db, server_id) if not server: await callback.answer("❌ Сервер не найден", show_alert=True) return selected = list(data.get("campaign_subscription_squads", [])) if server.squad_uuid in selected: selected.remove(server.squad_uuid) else: selected.append(server.squad_uuid) await state.update_data(campaign_subscription_squads=selected) servers, _ = await get_all_server_squads(db, available_only=False) keyboard = _build_campaign_servers_keyboard( servers, selected, toggle_prefix=f"campaign_edit_toggle_{campaign_id}_", save_callback=f"campaign_edit_servers_save_{campaign_id}", back_callback=f"admin_campaign_edit_{campaign_id}", ) await callback.message.edit_reply_markup(reply_markup=keyboard) await callback.answer() @admin_required @error_handler async def save_edit_campaign_subscription_servers( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): data = await state.get_data() campaign_id = data.get("editing_campaign_id") if not campaign_id: await callback.answer("❌ Сессия редактирования устарела", show_alert=True) await state.clear() return selected = list(data.get("campaign_subscription_squads", [])) if not selected: await callback.answer("❗ Выберите хотя бы один сервер", show_alert=True) return campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await state.clear() await callback.answer("❌ Кампания не найдена", show_alert=True) return if not await _ensure_subscription_campaign(callback, campaign): await state.clear() return await update_campaign(db, campaign, subscription_squads=selected) await state.clear() use_caption = bool(callback.message.caption) and not bool(callback.message.text) await _render_campaign_edit_menu( callback.bot, callback.message.chat.id, callback.message.message_id, campaign, db_user.language, use_caption=use_caption, ) await callback.answer("✅ Сохранено") @admin_required @error_handler async def toggle_campaign_status( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): campaign_id = int(callback.data.split("_")[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await callback.answer("❌ Кампания не найдена", show_alert=True) return new_status = not campaign.is_active await update_campaign(db, campaign, is_active=new_status) status_text = "включена" if new_status else "выключена" logger.info("🔄 Кампания %s переключена: %s", campaign_id, status_text) await show_campaign_detail(callback, db_user, db) @admin_required @error_handler async def show_campaign_stats( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): campaign_id = int(callback.data.split("_")[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await callback.answer("❌ Кампания не найдена", show_alert=True) return texts = get_texts(db_user.language) stats = await get_campaign_statistics(db, campaign_id) text = ["📊 Статистика кампании\n"] text.append(_format_campaign_summary(campaign, texts)) text.append(f"Регистраций: {stats['registrations']}") text.append(f"Выдано баланса: {texts.format_price(stats['balance_issued'])}") text.append(f"Выдано подписок: {stats['subscription_issued']}") if stats["last_registration"]: text.append( f"Последняя регистрация: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" ) await callback.message.edit_text( "\n".join(text), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text="⬅️ Назад", callback_data=f"admin_campaign_manage_{campaign_id}", ) ] ] ), ) await callback.answer() @admin_required @error_handler async def confirm_delete_campaign( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): campaign_id = int(callback.data.split("_")[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await callback.answer("❌ Кампания не найдена", show_alert=True) return text = ( "🗑️ Удаление кампании\n\n" f"Название: {campaign.name}\n" f"Параметр: {campaign.start_parameter}\n\n" "Вы уверены, что хотите удалить кампанию?" ) await callback.message.edit_text( text, reply_markup=get_confirmation_keyboard( confirm_action=f"admin_campaign_delete_confirm_{campaign_id}", cancel_action=f"admin_campaign_manage_{campaign_id}", ), ) await callback.answer() @admin_required @error_handler async def delete_campaign_confirmed( callback: types.CallbackQuery, db_user: User, db: AsyncSession, ): campaign_id = int(callback.data.split("_")[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await callback.answer("❌ Кампания не найдена", show_alert=True) return await delete_campaign(db, campaign) await callback.message.edit_text( "✅ Кампания удалена.", reply_markup=get_admin_campaigns_keyboard(db_user.language), ) await callback.answer("Удалено") @admin_required @error_handler async def start_campaign_creation( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): await state.clear() await callback.message.edit_text( "🆕 Создание рекламной кампании\n\nВведите название кампании:", reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text="⬅️ Назад", callback_data="admin_campaigns" ) ] ] ), ) await state.set_state(AdminStates.creating_campaign_name) await callback.answer() @admin_required @error_handler async def process_campaign_name( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession, ): name = message.text.strip() if len(name) < 3 or len(name) > 100: await message.answer( "❌ Название должно содержать от 3 до 100 символов. Попробуйте снова." ) return await state.update_data(campaign_name=name) await state.set_state(AdminStates.creating_campaign_start) await message.answer( "🔗 Теперь введите параметр старта (латинские буквы, цифры, - или _):", ) @admin_required @error_handler async def process_campaign_start_parameter( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession, ): start_param = message.text.strip() if not _CAMPAIGN_PARAM_REGEX.match(start_param): await message.answer( "❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа." ) return existing = await get_campaign_by_start_parameter(db, start_param) if existing: await message.answer( "❌ Кампания с таким параметром уже существует. Введите другой параметр." ) return await state.update_data(campaign_start_parameter=start_param) await state.set_state(AdminStates.creating_campaign_bonus) await message.answer( "🎯 Выберите тип бонуса для кампании:", reply_markup=get_campaign_bonus_type_keyboard(db_user.language), ) @admin_required @error_handler async def select_campaign_bonus_type( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): bonus_type = "balance" if callback.data.endswith("balance") else "subscription" await state.update_data(campaign_bonus_type=bonus_type) if bonus_type == "balance": await state.set_state(AdminStates.creating_campaign_balance) await callback.message.edit_text( "💰 Введите сумму бонуса на баланс (в рублях):", reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text="⬅️ Назад", callback_data="admin_campaigns" ) ] ] ), ) else: await state.set_state(AdminStates.creating_campaign_subscription_days) await callback.message.edit_text( "📅 Введите длительность подписки в днях (1-730):", reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text="⬅️ Назад", callback_data="admin_campaigns" ) ] ] ), ) await callback.answer() @admin_required @error_handler async def process_campaign_balance_value( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession, ): try: amount_rubles = float(message.text.replace(",", ".")) except ValueError: await message.answer("❌ Введите корректную сумму (например, 100 или 99.5)") return if amount_rubles <= 0: await message.answer("❌ Сумма должна быть больше нуля") return amount_kopeks = int(round(amount_rubles * 100)) data = await state.get_data() campaign = await create_campaign( db, name=data["campaign_name"], start_parameter=data["campaign_start_parameter"], bonus_type="balance", balance_bonus_kopeks=amount_kopeks, created_by=db_user.id, ) await state.clear() deep_link = await _get_bot_deep_link_from_message(message, campaign.start_parameter) texts = get_texts(db_user.language) summary = _format_campaign_summary(campaign, texts) text = ( "✅ Кампания создана!\n\n" f"{summary}\n" f"🔗 Ссылка: {deep_link}" ) await message.answer( text, reply_markup=get_campaign_management_keyboard( campaign.id, campaign.is_active, db_user.language ), ) @admin_required @error_handler async def process_campaign_subscription_days( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession, ): try: days = int(message.text.strip()) except ValueError: await message.answer("❌ Введите число дней (1-730)") return if days <= 0 or days > 730: await message.answer("❌ Длительность должна быть от 1 до 730 дней") return await state.update_data(campaign_subscription_days=days) await state.set_state(AdminStates.creating_campaign_subscription_traffic) await message.answer("🌐 Введите лимит трафика в ГБ (0 = безлимит):") @admin_required @error_handler async def process_campaign_subscription_traffic( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession, ): try: traffic = int(message.text.strip()) except ValueError: await message.answer("❌ Введите целое число (0 или больше)") return if traffic < 0 or traffic > 10000: await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ") return await state.update_data(campaign_subscription_traffic=traffic) await state.set_state(AdminStates.creating_campaign_subscription_devices) await message.answer( f"📱 Введите количество устройств (1-{settings.MAX_DEVICES_LIMIT}):" ) @admin_required @error_handler async def process_campaign_subscription_devices( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession, ): try: devices = int(message.text.strip()) except ValueError: await message.answer("❌ Введите целое число устройств") return if devices < 1 or devices > settings.MAX_DEVICES_LIMIT: await message.answer( f"❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}" ) return await state.update_data(campaign_subscription_devices=devices) await state.update_data(campaign_subscription_squads=[]) await state.set_state(AdminStates.creating_campaign_subscription_servers) servers, _ = await get_all_server_squads(db, available_only=False) if not servers: await message.answer( "❌ Не найдены доступные серверы. Добавьте сервера перед созданием кампании.", ) await state.clear() return keyboard = _build_campaign_servers_keyboard(servers, []) await message.answer( "🌍 Выберите серверы, которые будут доступны по подписке (максимум 20 отображаются).", reply_markup=keyboard, ) @admin_required @error_handler async def toggle_campaign_server( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): server_id = int(callback.data.split("_")[-1]) server = await get_server_squad_by_id(db, server_id) if not server: await callback.answer("❌ Сервер не найден", show_alert=True) return data = await state.get_data() selected = list(data.get("campaign_subscription_squads", [])) if server.squad_uuid in selected: selected.remove(server.squad_uuid) else: selected.append(server.squad_uuid) await state.update_data(campaign_subscription_squads=selected) servers, _ = await get_all_server_squads(db, available_only=False) keyboard = _build_campaign_servers_keyboard(servers, selected) await callback.message.edit_reply_markup(reply_markup=keyboard) await callback.answer() @admin_required @error_handler async def finalize_campaign_subscription( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession, ): data = await state.get_data() selected = data.get("campaign_subscription_squads", []) if not selected: await callback.answer("❗ Выберите хотя бы один сервер", show_alert=True) return campaign = await create_campaign( db, name=data["campaign_name"], start_parameter=data["campaign_start_parameter"], bonus_type="subscription", subscription_duration_days=data.get("campaign_subscription_days"), subscription_traffic_gb=data.get("campaign_subscription_traffic"), subscription_device_limit=data.get("campaign_subscription_devices"), subscription_squads=selected, created_by=db_user.id, ) await state.clear() deep_link = await _get_bot_deep_link(callback, campaign.start_parameter) texts = get_texts(db_user.language) summary = _format_campaign_summary(campaign, texts) text = ( "✅ Кампания создана!\n\n" f"{summary}\n" f"🔗 Ссылка: {deep_link}" ) await callback.message.edit_text( text, reply_markup=get_campaign_management_keyboard( campaign.id, campaign.is_active, db_user.language ), ) await callback.answer() def register_handlers(dp: Dispatcher): dp.callback_query.register(show_campaigns_menu, F.data == "admin_campaigns") dp.callback_query.register( show_campaigns_overall_stats, F.data == "admin_campaigns_stats" ) dp.callback_query.register(show_campaigns_list, F.data == "admin_campaigns_list") dp.callback_query.register( show_campaigns_list, F.data.startswith("admin_campaigns_list_page_") ) dp.callback_query.register( start_campaign_creation, F.data == "admin_campaigns_create" ) dp.callback_query.register( show_campaign_stats, F.data.startswith("admin_campaign_stats_") ) dp.callback_query.register( show_campaign_detail, F.data.startswith("admin_campaign_manage_") ) dp.callback_query.register( start_edit_campaign_name, F.data.startswith("admin_campaign_edit_name_") ) dp.callback_query.register( start_edit_campaign_start_parameter, F.data.startswith("admin_campaign_edit_start_"), ) dp.callback_query.register( start_edit_campaign_balance_bonus, F.data.startswith("admin_campaign_edit_balance_"), ) dp.callback_query.register( start_edit_campaign_subscription_days, F.data.startswith("admin_campaign_edit_sub_days_"), ) dp.callback_query.register( start_edit_campaign_subscription_traffic, F.data.startswith("admin_campaign_edit_sub_traffic_"), ) dp.callback_query.register( start_edit_campaign_subscription_devices, F.data.startswith("admin_campaign_edit_sub_devices_"), ) dp.callback_query.register( start_edit_campaign_subscription_servers, F.data.startswith("admin_campaign_edit_sub_servers_"), ) dp.callback_query.register( save_edit_campaign_subscription_servers, F.data.startswith("campaign_edit_servers_save_"), ) dp.callback_query.register( toggle_edit_campaign_server, F.data.startswith("campaign_edit_toggle_") ) dp.callback_query.register( show_campaign_edit_menu, F.data.startswith("admin_campaign_edit_") ) dp.callback_query.register( delete_campaign_confirmed, F.data.startswith("admin_campaign_delete_confirm_") ) dp.callback_query.register( confirm_delete_campaign, F.data.startswith("admin_campaign_delete_") ) dp.callback_query.register( toggle_campaign_status, F.data.startswith("admin_campaign_toggle_") ) dp.callback_query.register( finalize_campaign_subscription, F.data == "campaign_servers_save" ) dp.callback_query.register( toggle_campaign_server, F.data.startswith("campaign_toggle_server_") ) dp.callback_query.register( select_campaign_bonus_type, F.data.startswith("campaign_bonus_") ) dp.message.register(process_campaign_name, AdminStates.creating_campaign_name) dp.message.register( process_campaign_start_parameter, AdminStates.creating_campaign_start ) dp.message.register( process_campaign_balance_value, AdminStates.creating_campaign_balance ) dp.message.register( process_campaign_subscription_days, AdminStates.creating_campaign_subscription_days, ) dp.message.register( process_campaign_subscription_traffic, AdminStates.creating_campaign_subscription_traffic, ) dp.message.register( process_campaign_subscription_devices, AdminStates.creating_campaign_subscription_devices, ) dp.message.register( process_edit_campaign_name, AdminStates.editing_campaign_name ) dp.message.register( process_edit_campaign_start_parameter, AdminStates.editing_campaign_start, ) dp.message.register( process_edit_campaign_balance_bonus, AdminStates.editing_campaign_balance, ) dp.message.register( process_edit_campaign_subscription_days, AdminStates.editing_campaign_subscription_days, ) dp.message.register( process_edit_campaign_subscription_traffic, AdminStates.editing_campaign_subscription_traffic, ) dp.message.register( process_edit_campaign_subscription_devices, AdminStates.editing_campaign_subscription_devices, )