import logging import asyncio from datetime import datetime, timedelta from typing import Optional from aiogram import Dispatcher, types, F from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, and_, or_ from app.config import settings from app.states import AdminStates from app.database.models import ( User, UserStatus, Subscription, SubscriptionStatus, BroadcastHistory, ) from app.keyboards.admin import ( get_admin_messages_keyboard, get_broadcast_target_keyboard, get_custom_criteria_keyboard, get_broadcast_history_keyboard, get_admin_pagination_keyboard, get_broadcast_media_keyboard, get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media, BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS, get_broadcast_button_config, get_broadcast_button_labels ) from app.localization.texts import get_texts from app.database.crud.user import get_users_list from app.database.crud.subscription import get_expiring_subscriptions from app.utils.decorators import admin_required, error_handler logger = logging.getLogger(__name__) BUTTON_ROWS = BROADCAST_BUTTON_ROWS DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup: return get_updated_message_buttons_selector_keyboard(list(DEFAULT_SELECTED_BUTTONS), language) def get_updated_message_buttons_selector_keyboard(selected_buttons: list, language: str = "ru") -> types.InlineKeyboardMarkup: return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language) def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> Optional[types.InlineKeyboardMarkup]: selected_buttons = selected_buttons or [] keyboard: list[list[types.InlineKeyboardButton]] = [] button_config_map = get_broadcast_button_config(language) for row in BUTTON_ROWS: row_buttons: list[types.InlineKeyboardButton] = [] for button_key in row: if button_key not in selected_buttons: continue button_config = button_config_map[button_key] row_buttons.append( types.InlineKeyboardButton( text=button_config["text"], callback_data=button_config["callback"] ) ) if row_buttons: keyboard.append(row_buttons) if not keyboard: return None return types.InlineKeyboardMarkup(inline_keyboard=keyboard) @admin_required @error_handler async def show_messages_menu( callback: types.CallbackQuery, db_user: User, db: AsyncSession ): text = """ 📨 Управление рассылками Выберите тип рассылки: - Всем пользователям - рассылка всем активным пользователям - По подпискам - фильтрация по типу подписки - По критериям - настраиваемые фильтры - История - просмотр предыдущих рассылок ⚠️ Будьте осторожны с массовыми рассылками! """ await callback.message.edit_text( text, reply_markup=get_admin_messages_keyboard(db_user.language), parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def show_broadcast_targets( callback: types.CallbackQuery, db_user: User, state: FSMContext ): await callback.message.edit_text( "🎯 Выбор целевой аудитории\n\n" "Выберите категорию пользователей для рассылки:", reply_markup=get_broadcast_target_keyboard(db_user.language), parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def show_messages_history( callback: types.CallbackQuery, db_user: User, db: AsyncSession ): page = 1 if '_page_' in callback.data: page = int(callback.data.split('_page_')[1]) limit = 10 offset = (page - 1) * limit stmt = select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit) result = await db.execute(stmt) broadcasts = result.scalars().all() count_stmt = select(func.count(BroadcastHistory.id)) count_result = await db.execute(count_stmt) total_count = count_result.scalar() or 0 total_pages = (total_count + limit - 1) // limit if not broadcasts: text = """ 📋 История рассылок ❌ История рассылок пуста. Отправьте первую рассылку, чтобы увидеть её здесь. """ keyboard = [[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")]] else: text = f"📋 История рассылок (страница {page}/{total_pages})\n\n" for broadcast in broadcasts: status_emoji = "✅" if broadcast.status == "completed" else "❌" if broadcast.status == "failed" else "⏳" success_rate = round((broadcast.sent_count / broadcast.total_count * 100), 1) if broadcast.total_count > 0 else 0 message_preview = broadcast.message_text[:100] + "..." if len(broadcast.message_text) > 100 else broadcast.message_text import html message_preview = html.escape(message_preview) text += f""" {status_emoji} {broadcast.created_at.strftime('%d.%m.%Y %H:%M')} 📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%) 🎯 Аудитория: {get_target_name(broadcast.target_type)} 👤 Админ: {broadcast.admin_name} 📝 Сообщение: {message_preview} ━━━━━━━━━━━━━━━━━━━━━━━ """ keyboard = get_broadcast_history_keyboard(page, total_pages, db_user.language).inline_keyboard await callback.message.edit_text( text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def show_custom_broadcast( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession ): stats = await get_users_statistics(db) text = f""" 📝 Рассылка по критериям 📊 Доступные фильтры: 👥 По регистрации: • Сегодня: {stats['today']} чел. • За неделю: {stats['week']} чел. • За месяц: {stats['month']} чел. 💼 По активности: • Активные сегодня: {stats['active_today']} чел. • Неактивные 7+ дней: {stats['inactive_week']} чел. • Неактивные 30+ дней: {stats['inactive_month']} чел. 🔗 По источнику: • Через рефералов: {stats['referrals']} чел. • Прямая регистрация: {stats['direct']} чел. Выберите критерий для фильтрации: """ await callback.message.edit_text( text, reply_markup=get_custom_criteria_keyboard(db_user.language), parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def select_custom_criteria( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession ): criteria = callback.data.replace('criteria_', '') criteria_names = { "today": "Зарегистрированные сегодня", "week": "Зарегистрированные за неделю", "month": "Зарегистрированные за месяц", "active_today": "Активные сегодня", "inactive_week": "Неактивные 7+ дней", "inactive_month": "Неактивные 30+ дней", "referrals": "Пришедшие через рефералов", "direct": "Прямая регистрация" } user_count = await get_custom_users_count(db, criteria) await state.update_data(broadcast_target=f"custom_{criteria}") await callback.message.edit_text( f"📨 Создание рассылки\n\n" f"🎯 Критерий: {criteria_names.get(criteria, criteria)}\n" f"👥 Получателей: {user_count}\n\n" f"Введите текст сообщения для рассылки:\n\n" f"Поддерживается HTML разметка", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")] ]), parse_mode="HTML" ) await state.set_state(AdminStates.waiting_for_broadcast_message) await callback.answer() @admin_required @error_handler async def select_broadcast_target( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession ): raw_target = callback.data[len("broadcast_"):] target_aliases = { "no_sub": "no", } target = target_aliases.get(raw_target, raw_target) target_names = { "all": "Всем пользователям", "active": "С активной подпиской", "trial": "С триальной подпиской", "no": "Без подписки", "expiring": "С истекающей подпиской", "expired": "С истекшей подпиской", "active_zero": "Активная подписка, трафик 0 ГБ", "trial_zero": "Триальная подписка, трафик 0 ГБ", } user_count = await get_target_users_count(db, target) await state.update_data(broadcast_target=target) await callback.message.edit_text( f"📨 Создание рассылки\n\n" f"🎯 Аудитория: {target_names.get(target, target)}\n" f"👥 Получателей: {user_count}\n\n" f"Введите текст сообщения для рассылки:\n\n" f"Поддерживается HTML разметка", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")] ]), parse_mode="HTML" ) await state.set_state(AdminStates.waiting_for_broadcast_message) await callback.answer() @admin_required @error_handler async def process_broadcast_message( message: types.Message, db_user: User, state: FSMContext, db: AsyncSession ): broadcast_text = message.text if len(broadcast_text) > 4000: await message.answer("❌ Сообщение слишком длинное (максимум 4000 символов)") return await state.update_data(broadcast_message=broadcast_text) await message.answer( "🖼️ Добавление медиафайла\n\n" "Вы можете добавить к сообщению фото, видео или документ.\n" "Или пропустить этот шаг.\n\n" "Выберите тип медиа:", reply_markup=get_broadcast_media_keyboard(db_user.language), parse_mode="HTML" ) @admin_required @error_handler async def handle_media_selection( callback: types.CallbackQuery, db_user: User, state: FSMContext ): if callback.data == "skip_media": await state.update_data(has_media=False) await show_button_selector_callback(callback, db_user, state) return media_type = callback.data.replace('add_media_', '') media_instructions = { "photo": "📷 Отправьте фотографию для рассылки:", "video": "🎥 Отправьте видео для рассылки:", "document": "📄 Отправьте документ для рассылки:" } await state.update_data( media_type=media_type, waiting_for_media=True ) await callback.message.edit_text( f"{media_instructions.get(media_type, 'Отправьте медиафайл:')}\n\n" f"Размер файла не должен превышать 50 МБ", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")] ]), parse_mode="HTML" ) await state.set_state(AdminStates.waiting_for_broadcast_media) await callback.answer() @admin_required @error_handler async def process_broadcast_media( message: types.Message, db_user: User, state: FSMContext ): data = await state.get_data() expected_type = data.get('media_type') media_file_id = None media_type = None if message.photo and expected_type == "photo": media_file_id = message.photo[-1].file_id media_type = "photo" elif message.video and expected_type == "video": media_file_id = message.video.file_id media_type = "video" elif message.document and expected_type == "document": media_file_id = message.document.file_id media_type = "document" else: await message.answer( f"❌ Пожалуйста, отправьте {expected_type} как указано в инструкции." ) return await state.update_data( has_media=True, media_file_id=media_file_id, media_type=media_type, media_caption=message.caption ) await show_media_preview(message, db_user, state) async def show_media_preview( message: types.Message, db_user: User, state: FSMContext ): data = await state.get_data() media_type = data.get('media_type') media_file_id = data.get('media_file_id') preview_text = f"🖼️ Медиафайл добавлен\n\n" \ f"📎 Тип: {media_type}\n" \ f"✅ Файл сохранен и готов к отправке\n\n" \ f"Что делать дальше?" # Для предпросмотра рассылки используем оригинальный метод без патчинга логотипа # чтобы показать именно загруженное фото from app.utils.message_patch import _original_answer if media_type == "photo" and media_file_id: # Показываем предпросмотр с загруженным фото await message.bot.send_photo( chat_id=message.chat.id, photo=media_file_id, caption=preview_text, reply_markup=get_media_confirm_keyboard(db_user.language), parse_mode="HTML" ) else: # Для других типов медиа или если нет фото, используем обычное сообщение await _original_answer(message, preview_text, reply_markup=get_media_confirm_keyboard(db_user.language), parse_mode="HTML") @admin_required @error_handler async def handle_media_confirmation( callback: types.CallbackQuery, db_user: User, state: FSMContext ): action = callback.data if action == "confirm_media": await show_button_selector_callback(callback, db_user, state) elif action == "replace_media": data = await state.get_data() media_type = data.get('media_type', 'photo') await handle_media_selection(callback, db_user, state) elif action == "skip_media": await state.update_data( has_media=False, media_file_id=None, media_type=None, media_caption=None ) await show_button_selector_callback(callback, db_user, state) @admin_required @error_handler async def handle_change_media( callback: types.CallbackQuery, db_user: User, state: FSMContext ): await callback.message.edit_text( "🖼️ Изменение медиафайла\n\n" "Выберите новый тип медиа:", reply_markup=get_broadcast_media_keyboard(db_user.language), parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def show_button_selector_callback( callback: types.CallbackQuery, db_user: User, state: FSMContext ): data = await state.get_data() has_media = data.get('has_media', False) selected_buttons = data.get('selected_buttons') if selected_buttons is None: selected_buttons = list(DEFAULT_SELECTED_BUTTONS) await state.update_data(selected_buttons=selected_buttons) media_info = "" if has_media: media_type = data.get('media_type', 'файл') media_info = f"\n🖼️ Медиафайл: {media_type} добавлен" text = f""" 📘 Выбор дополнительных кнопок Выберите кнопки, которые будут добавлены к сообщению рассылки: 💰 Пополнить баланс — откроет методы пополнения 🤝 Партнерка — откроет реферальную программу 🎫 Промокод — откроет форму ввода промокода 🔗 Подключиться — поможет подключить приложение 📱 Подписка — покажет состояние подписки 🛠️ Техподдержка — свяжет с поддержкой 🏠 Кнопка "На главную" включена по умолчанию, но вы можете отключить её при необходимости.{media_info} Выберите нужные кнопки и нажмите "Продолжить": """ keyboard = get_updated_message_buttons_selector_keyboard_with_media( selected_buttons, has_media, db_user.language ) await callback.message.edit_text( text, reply_markup=keyboard, parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def show_button_selector( message: types.Message, db_user: User, state: FSMContext ): data = await state.get_data() selected_buttons = data.get('selected_buttons') if selected_buttons is None: selected_buttons = list(DEFAULT_SELECTED_BUTTONS) await state.update_data(selected_buttons=selected_buttons) has_media = data.get('has_media', False) text = """ 📘 Выбор дополнительных кнопок Выберите кнопки, которые будут добавлены к сообщению рассылки: 💰 Пополнить баланс — откроет методы пополнения 🤝 Партнерка — откроет реферальную программу 🎫 Промокод — откроет форму ввода промокода 🔗 Подключиться — поможет подключить приложение 📱 Подписка — покажет состояние подписки 🛠️ Техподдержка — свяжет с поддержкой 🏠 Кнопка "На главную" включена по умолчанию, но вы можете отключить её при необходимости. Выберите нужные кнопки и нажмите "Продолжить": """ keyboard = get_updated_message_buttons_selector_keyboard_with_media( selected_buttons, has_media, db_user.language ) await message.answer( text, reply_markup=keyboard, parse_mode="HTML" ) @admin_required @error_handler async def toggle_button_selection( callback: types.CallbackQuery, db_user: User, state: FSMContext ): button_type = callback.data.replace('btn_', '') data = await state.get_data() selected_buttons = data.get('selected_buttons') if selected_buttons is None: selected_buttons = list(DEFAULT_SELECTED_BUTTONS) else: selected_buttons = list(selected_buttons) if button_type in selected_buttons: selected_buttons.remove(button_type) else: selected_buttons.append(button_type) await state.update_data(selected_buttons=selected_buttons) has_media = data.get('has_media', False) keyboard = get_updated_message_buttons_selector_keyboard_with_media( selected_buttons, has_media, db_user.language ) await callback.message.edit_reply_markup(reply_markup=keyboard) await callback.answer() @admin_required @error_handler async def confirm_button_selection( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession ): data = await state.get_data() target = data.get('broadcast_target') message_text = data.get('broadcast_message') selected_buttons = data.get('selected_buttons') if selected_buttons is None: selected_buttons = list(DEFAULT_SELECTED_BUTTONS) await state.update_data(selected_buttons=selected_buttons) has_media = data.get('has_media', False) media_type = data.get('media_type') user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', '')) target_display = get_target_display_name(target) media_info = "" if has_media: media_type_names = { "photo": "Фотография", "video": "Видео", "document": "Документ" } media_info = f"\n🖼️ Медиафайл: {media_type_names.get(media_type, media_type)}" ordered_keys = [button_key for row in BUTTON_ROWS for button_key in row] button_labels = get_broadcast_button_labels(db_user.language) selected_names = [button_labels[key] for key in ordered_keys if key in selected_buttons] if selected_names: buttons_info = f"\n📘 Кнопки: {', '.join(selected_names)}" else: buttons_info = "\n📘 Кнопки: отсутствуют" preview_text = f""" 📨 Предварительный просмотр рассылки 🎯 Аудитория: {target_display} 👥 Получателей: {user_count} 📝 Сообщение: {message_text}{media_info} {buttons_info} Подтвердить отправку? """ keyboard = [ [ types.InlineKeyboardButton(text="✅ Отправить", callback_data="admin_confirm_broadcast"), types.InlineKeyboardButton(text="📘 Изменить кнопки", callback_data="edit_buttons") ] ] if has_media: keyboard.append([ types.InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media") ]) keyboard.append([ types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages") ]) # Если есть медиа, показываем его с загруженным фото, иначе обычное текстовое сообщение if has_media and media_type == "photo": media_file_id = data.get('media_file_id') if media_file_id: # Удаляем текущее сообщение и отправляем новое с фото await callback.message.delete() await callback.bot.send_photo( chat_id=callback.message.chat.id, photo=media_file_id, caption=preview_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) else: # Если нет file_id, используем обычное редактирование await callback.message.edit_text( preview_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) else: # Для текстовых сообщений или других типов медиа используем обычное редактирование await callback.message.edit_text( preview_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) await callback.answer() @admin_required @error_handler async def confirm_broadcast( callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession ): data = await state.get_data() target = data.get('broadcast_target') message_text = data.get('broadcast_message') selected_buttons = data.get('selected_buttons') if selected_buttons is None: selected_buttons = list(DEFAULT_SELECTED_BUTTONS) has_media = data.get('has_media', False) media_type = data.get('media_type') media_file_id = data.get('media_file_id') media_caption = data.get('media_caption') await callback.message.edit_text( "📨 Начинаю рассылку...\n\n" "⏳ Это может занять несколько минут.", reply_markup=None, parse_mode="HTML" ) if target.startswith('custom_'): users = await get_custom_users(db, target.replace('custom_', '')) else: users = await get_target_users(db, target) broadcast_history = BroadcastHistory( target_type=target, message_text=message_text, has_media=has_media, media_type=media_type, media_file_id=media_file_id, media_caption=media_caption, total_count=len(users), sent_count=0, failed_count=0, admin_id=db_user.id, admin_name=db_user.full_name, status="in_progress" ) db.add(broadcast_history) await db.commit() await db.refresh(broadcast_history) sent_count = 0 failed_count = 0 broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language) for user in users: try: if has_media and media_file_id: if media_type == "photo": await callback.bot.send_photo( chat_id=user.telegram_id, photo=media_file_id, caption=message_text, parse_mode="HTML", reply_markup=broadcast_keyboard ) elif media_type == "video": await callback.bot.send_video( chat_id=user.telegram_id, video=media_file_id, caption=message_text, parse_mode="HTML", reply_markup=broadcast_keyboard ) elif media_type == "document": await callback.bot.send_document( chat_id=user.telegram_id, document=media_file_id, caption=message_text, parse_mode="HTML", reply_markup=broadcast_keyboard ) else: await callback.bot.send_message( chat_id=user.telegram_id, text=message_text, parse_mode="HTML", reply_markup=broadcast_keyboard ) sent_count += 1 if sent_count % 20 == 0: await asyncio.sleep(1) except Exception as e: failed_count += 1 logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}") broadcast_history.sent_count = sent_count broadcast_history.failed_count = failed_count broadcast_history.status = "completed" if failed_count == 0 else "partial" broadcast_history.completed_at = datetime.utcnow() await db.commit() media_info = "" if has_media: media_info = f"\n🖼️ Медиафайл: {media_type}" result_text = f""" ✅ Рассылка завершена! 📊 Результат: - Отправлено: {sent_count} - Не доставлено: {failed_count} - Всего пользователей: {len(users)} - Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%{media_info} Администратор: {db_user.full_name} """ await callback.message.edit_text( result_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")] ]), parse_mode="HTML" ) await state.clear() logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})") async def get_target_users_count(db: AsyncSession, target: str) -> int: users = await get_target_users(db, target) return len(users) async def get_target_users(db: AsyncSession, target: str) -> list: users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) if target == "all": return users if target == "active": return [ user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial ] if target == "trial": return [ user for user in users if user.subscription and user.subscription.is_trial ] if target == "no": return [ user for user in users if not user.subscription or not user.subscription.is_active ] if target == "expiring": expiring_subs = await get_expiring_subscriptions(db, 3) return [sub.user for sub in expiring_subs if sub.user] if target == "expired": now = datetime.utcnow() expired_statuses = { SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value, } expired_users = [] for user in users: subscription = user.subscription if subscription: if subscription.status in expired_statuses: expired_users.append(user) continue if subscription.end_date <= now and not subscription.is_active: expired_users.append(user) continue elif user.has_had_paid_subscription: expired_users.append(user) return expired_users if target == "active_zero": return [ user for user in users if user.subscription and not user.subscription.is_trial and user.subscription.is_active and (user.subscription.traffic_used_gb or 0) <= 0 ] if target == "trial_zero": return [ user for user in users if user.subscription and user.subscription.is_trial and user.subscription.is_active and (user.subscription.traffic_used_gb or 0) <= 0 ] if target == "zero": return [ user for user in users if user.subscription and user.subscription.is_active and (user.subscription.traffic_used_gb or 0) <= 0 ] return [] async def get_custom_users_count(db: AsyncSession, criteria: str) -> int: users = await get_custom_users(db, criteria) return len(users) async def get_custom_users(db: AsyncSession, criteria: str) -> list: now = datetime.utcnow() today = now.replace(hour=0, minute=0, second=0, microsecond=0) week_ago = now - timedelta(days=7) month_ago = now - timedelta(days=30) if criteria == "today": stmt = select(User).where( and_(User.status == "active", User.created_at >= today) ) elif criteria == "week": stmt = select(User).where( and_(User.status == "active", User.created_at >= week_ago) ) elif criteria == "month": stmt = select(User).where( and_(User.status == "active", User.created_at >= month_ago) ) elif criteria == "active_today": stmt = select(User).where( and_(User.status == "active", User.last_activity >= today) ) elif criteria == "inactive_week": stmt = select(User).where( and_(User.status == "active", User.last_activity < week_ago) ) elif criteria == "inactive_month": stmt = select(User).where( and_(User.status == "active", User.last_activity < month_ago) ) elif criteria == "referrals": stmt = select(User).where( and_(User.status == "active", User.referred_by_id.isnot(None)) ) elif criteria == "direct": stmt = select(User).where( and_( User.status == "active", User.referred_by_id.is_(None) ) ) else: return [] result = await db.execute(stmt) return result.scalars().all() async def get_users_statistics(db: AsyncSession) -> dict: now = datetime.utcnow() today = now.replace(hour=0, minute=0, second=0, microsecond=0) week_ago = now - timedelta(days=7) month_ago = now - timedelta(days=30) stats = {} stats['today'] = await db.scalar( select(func.count(User.id)).where( and_(User.status == "active", User.created_at >= today) ) ) or 0 stats['week'] = await db.scalar( select(func.count(User.id)).where( and_(User.status == "active", User.created_at >= week_ago) ) ) or 0 stats['month'] = await db.scalar( select(func.count(User.id)).where( and_(User.status == "active", User.created_at >= month_ago) ) ) or 0 stats['active_today'] = await db.scalar( select(func.count(User.id)).where( and_(User.status == "active", User.last_activity >= today) ) ) or 0 stats['inactive_week'] = await db.scalar( select(func.count(User.id)).where( and_(User.status == "active", User.last_activity < week_ago) ) ) or 0 stats['inactive_month'] = await db.scalar( select(func.count(User.id)).where( and_(User.status == "active", User.last_activity < month_ago) ) ) or 0 stats['referrals'] = await db.scalar( select(func.count(User.id)).where( and_(User.status == "active", User.referred_by_id.isnot(None)) ) ) or 0 stats['direct'] = await db.scalar( select(func.count(User.id)).where( and_( User.status == "active", User.referred_by_id.is_(None) ) ) ) or 0 return stats def get_target_name(target_type: str) -> str: names = { "all": "Всем пользователям", "active": "С активной подпиской", "trial": "С триальной подпиской", "no": "Без подписки", "sub": "Без подписки", "expiring": "С истекающей подпиской", "expired": "С истекшей подпиской", "active_zero": "Активная подписка, трафик 0 ГБ", "trial_zero": "Триальная подписка, трафик 0 ГБ", "zero": "Подписка, трафик 0 ГБ", "custom_today": "Зарегистрированные сегодня", "custom_week": "Зарегистрированные за неделю", "custom_month": "Зарегистрированные за месяц", "custom_active_today": "Активные сегодня", "custom_inactive_week": "Неактивные 7+ дней", "custom_inactive_month": "Неактивные 30+ дней", "custom_referrals": "Через рефералов", "custom_direct": "Прямая регистрация" } return names.get(target_type, target_type) def get_target_display_name(target: str) -> str: return get_target_name(target) def register_handlers(dp: Dispatcher): dp.callback_query.register(show_messages_menu, F.data == "admin_messages") dp.callback_query.register(show_broadcast_targets, F.data.in_(["admin_msg_all", "admin_msg_by_sub"])) dp.callback_query.register(select_broadcast_target, F.data.startswith("broadcast_")) dp.callback_query.register(confirm_broadcast, F.data == "admin_confirm_broadcast") dp.callback_query.register(show_messages_history, F.data.startswith("admin_msg_history")) dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom") dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_")) dp.callback_query.register(toggle_button_selection, F.data.startswith("btn_")) dp.callback_query.register(confirm_button_selection, F.data == "buttons_confirm") dp.callback_query.register(show_button_selector_callback, F.data == "edit_buttons") dp.callback_query.register(handle_media_selection, F.data.startswith("add_media_")) dp.callback_query.register(handle_media_selection, F.data == "skip_media") dp.callback_query.register(handle_media_confirmation, F.data.in_(["confirm_media", "replace_media"])) dp.callback_query.register(handle_change_media, F.data == "change_media") dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message) dp.message.register(process_broadcast_media, AdminStates.waiting_for_broadcast_media)