"""Админские обработчики для управления счетами и верификации""" from src.filters.numeric_callback import NumericCallback from src.utils.account_input import parse_account_records from src.utils.text_output import answer_plain_pages, edit_plain_pages from src.utils.input_validation import numeric_id from src.filters.dialog_input import NotCommand from src.utils.errors import public_error from aiogram import Router, F, Bot from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup from src.filters.case_insensitive import CaseInsensitiveCommand as Command from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy import select, and_ from src.filters.case_insensitive import CaseInsensitiveCommand from src.core.database import async_session_maker from src.core.registration_services import AccountService, WinnerNotificationService, RegistrationService from src.core.services import UserService, LotteryService, ParticipationService from src.core.models import User, Winner, Account, Participation from src.core.config import ADMIN_IDS from src.core.access import staff_only router = Router() class AddAccountStates(StatesGroup): waiting_for_data = State() choosing_lottery = State() @router.message(CaseInsensitiveCommand("cancel")) @staff_only async def cancel_command(message: Message, state: FSMContext): """Отменить текущую операцию и сбросить состояние (регистронезависимо)""" await state.clear() await message.answer("✅ Состояние сброшено. Все операции отменены.") @router.message(CaseInsensitiveCommand("add_account")) @staff_only async def add_account_command(message: Message, state: FSMContext): """ Добавить счет пользователю по клубной карте (регистронезависимо) Формат: /add_account Или: /add_account (затем вводить данные построчно) """ parts = message.text.split(maxsplit=2) # Если данные указаны в команде if len(parts) == 3: club_card = parts[1] account_number = parts[2] await process_single_account(message, club_card, account_number, state) else: # Запрашиваем данные await state.set_state(AddAccountStates.waiting_for_data) await message.answer( "💳 **Добавление счетов**\n\n" "📋 **Формат 1 (однострочный):**\n" "`карта счет`\n" "Пример: `2223 11-22-33-44-55-66-77`\n\n" "📋 **Формат 2 (многострочный из таблицы):**\n" "Скопируйте столбцы со счетами и картами - система сама распознает\n\n" "**Для нескольких счетов:**\n" "`2223 11-22-33-44-55-66-77`\n" "`2223 88-99-00-11-22-33-44`\n" "`3334 12-34-56-78-90-12-34`\n\n" "❌ Отправьте /cancel для отмены", parse_mode="Markdown" ) async def process_single_account(message: Message, club_card: str, account_number: str, state: FSMContext): """Обработка одного счета""" try: async with async_session_maker() as session: # Создаем счет account = await AccountService.create_account( session, club_card_number=club_card, account_number=account_number ) # Получаем владельца owner = await AccountService.get_account_owner(session, account_number) # Сохраняем данные счета в state для добавления в розыгрыш await state.update_data( accounts=[{ 'club_card': club_card, 'account_number': account_number, 'account_id': account.id }] ) text = f"✅ Счет успешно добавлен!\n\n" text += f"🎫 Клубная карта: {club_card}\n" text += f"💳 Счет: {account_number}\n" if owner: text += f"👤 Владелец: {owner.first_name}\n\n" # Отправляем уведомление владельцу с форматированием try: await message.bot.send_message( owner.telegram_id, f"✅ К вашему профилю добавлен счет:\n\n" f"💳 `{account_number}`\n\n" f"Теперь вы можете участвовать в розыгрышах!", parse_mode="Markdown" ) text += "📨 Владельцу отправлено уведомление\n\n" except Exception as e: text += f"⚠️ Не удалось отправить уведомление: {public_error(e)}\n\n" # Предлагаем добавить в розыгрыш await show_lottery_selection(message, text, state) except ValueError as e: await message.answer(f"❌ Ошибка: {str(e)}") await state.clear() except Exception as e: await message.answer(f"❌ Произошла ошибка: {public_error(e)}") await state.clear() @router.message(AddAccountStates.waiting_for_data, F.text, NotCommand()) async def process_accounts_data(message: Message, state: FSMContext): """Обработка данных счетов (один или несколько)""" if message.text.strip().lower() == '/cancel': await state.clear() await message.answer("❌ Операция отменена") return lines = message.text.strip().split('\n') # Ограничение: максимум 1000 счетов за раз MAX_ACCOUNTS = 1000 if len(lines) > MAX_ACCOUNTS: await message.answer( f"⚠️ Слишком много счетов!\n\n" f"Максимум за раз: {MAX_ACCOUNTS}\n" f"Вы отправили: {len(lines)} строк\n\n" f"Разделите данные на несколько частей." ) return # Отправляем начальное уведомление progress_msg = await message.answer( f"⏳ Обработка {len(lines)} строк...\n" f"Пожалуйста, подождите..." ) accounts_data = [] entries, errors = parse_account_records(message.text) BATCH_SIZE = 100 # Обрабатываем по 100 счетов за раз # Универсальный парсер: поддержка однострочного и многострочного формата for entry in entries: parts = entry.split() if len(parts) != 2: errors.append("Для счёта не указана клубная карта: " + entry) continue club_card, account_number = parts try: async with async_session_maker() as session: account = await AccountService.create_account( session, club_card_number=club_card, account_number=account_number ) owner = await AccountService.get_account_owner(session, account_number) accounts_data.append({ 'club_card': club_card, 'account_number': account_number, 'account_id': account.id, 'owner': owner, 'owner_id': owner.telegram_id if owner else None }) # Обновляем progress каждые 50 счетов if len(accounts_data) % 50 == 0: try: await progress_msg.edit_text( f"⏳ Обработано: {len(accounts_data)} / ~{len(lines)}\n" f"❌ Ошибок: {len(errors)}" ) except: pass # Игнорируем ошибки редактирования except ValueError as e: errors.append(f"Счет {account_number} (карта {club_card}): {str(e)}") except Exception as e: errors.append(f"Счет {account_number}: {public_error(e)}") # Удаляем progress сообщение try: await progress_msg.delete() except: pass # Группируем счета по владельцам и отправляем групповые уведомления if accounts_data: from collections import defaultdict accounts_by_owner = defaultdict(list) for acc in accounts_data: if acc['owner_id']: accounts_by_owner[acc['owner_id']].append(acc['account_number']) # Отправляем групповые уведомления for owner_id, account_numbers in accounts_by_owner.items(): try: if len(account_numbers) == 1: # Одиночное уведомление notification_text = ( "✅ К вашему профилю добавлен счет:\n\n" f"💳 `{account_numbers[0]}`\n\n" "Теперь вы можете участвовать в розыгрышах!" ) await message.bot.send_message( owner_id, notification_text, parse_mode="Markdown" ) elif len(account_numbers) <= 50: # Групповое уведомление (до 50 счетов) notification_text = ( f"✅ К вашему профилю добавлено счетов: *{len(account_numbers)}*\n\n" "💳 *Ваши счета:*\n" ) for acc_num in account_numbers: notification_text += f"• `{acc_num}`\n" notification_text += "\nТеперь вы можете участвовать в розыгрышах!" await message.bot.send_message( owner_id, notification_text, parse_mode="Markdown" ) else: # Много счетов - показываем первые 10 и кнопку notification_text = ( f"✅ К вашему профилю добавлено счетов: *{len(account_numbers)}*\n\n" "💳 *Первые 10 счетов:*\n" ) for acc_num in account_numbers[:10]: notification_text += f"• `{acc_num}`\n" notification_text += f"\n_...и ещё {len(account_numbers) - 10} счетов_\n\n" notification_text += "Теперь вы можете участвовать в розыгрышах!" # Кнопка для просмотра всех счетов keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( text="📋 Просмотреть все счета", callback_data="view_my_accounts" )] ]) await message.bot.send_message( owner_id, notification_text, parse_mode="Markdown", reply_markup=keyboard ) except Exception as e: pass # Игнорируем ошибки отправки уведомлений # Формируем отчет text = f"📊 **Результаты добавления счетов**\n\n" if accounts_data: text += f"✅ **Успешно добавлено: {len(accounts_data)}**\n\n" for acc in accounts_data: text += f"• {acc['club_card']} → {acc['account_number']}\n" if acc['owner']: text += f" 👤 {acc['owner'].first_name}\n" text += "\n" if errors: text += f"❌ **Ошибки: {len(errors)}**\n\n" for error in errors[:5]: # Показываем максимум 5 ошибок text += f"• {error}\n" if len(errors) > 5: text += f"\n... и еще {len(errors) - 5} ошибок\n" if not accounts_data: await answer_plain_pages(message, text) return # Сохраняем данные и предлагаем добавить в розыгрыш await state.update_data(accounts=[{key: value for key, value in account.items() if key != "owner"} for account in accounts_data]) await show_lottery_selection(message, text, state) async def show_lottery_selection(message: Message, prev_text: str, state: FSMContext): """Показать выбор розыгрыша для добавления счетов""" async with async_session_maker() as session: lotteries = await LotteryService.get_active_lotteries(session) if not lotteries: await answer_plain_pages(message, prev_text + "ℹ️ Нет активных розыгрышей для добавления счетов" ) await state.clear() return await state.set_state(AddAccountStates.choosing_lottery) buttons = [] for lottery in lotteries[:10]: # Максимум 10 розыгрышей buttons.append([ InlineKeyboardButton( text=f"🎯 {lottery.title}", callback_data=f"add_to_lottery_{lottery.id}" ) ]) buttons.append([ InlineKeyboardButton(text="❌ Пропустить", callback_data="skip_lottery_add") ]) await answer_plain_pages(message, prev_text + "➕ **Добавить счета в розыгрыш?**\n\n" "Выберите розыгрыш из списка:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons) ) @router.callback_query(NumericCallback('add_to_lottery_')) async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext): """Добавить счета в выбранный розыгрыш""" lottery_id = int(callback.data.split("_")[-1]) data = await state.get_data() accounts = data.get('accounts', []) if not accounts: await callback.answer("❌ Нет данных о счетах", show_alert=True) await state.clear() return success_count = 0 errors = [] async with async_session_maker() as session: lottery = await LotteryService.get_lottery(session, lottery_id) if not lottery: await callback.answer("❌ Розыгрыш не найден", show_alert=True) await state.clear() return lottery_title = lottery.title from .account_services import AccountParticipationService entries = [f"{a.get('club_card') or ''} {a['account_number']}".strip() for a in accounts] result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, entries, require_card=True) success_count = result['added'] errors = result['errors'] await state.clear() text = f"📊 Добавление в розыгрыш '{lottery_title}'\n\n" if success_count: text += f"✅ Добавлено счетов: {success_count}\n\n" if result['linked']: text += f"✅ Привязано существующих участий: {result['linked']}\n\n" if errors: text += f"⚠️ Ошибки: {len(errors)}\n" for error in errors[:3]: text += f"• {error}\n" await edit_plain_pages(callback.message, text) await callback.answer("Готово") @router.callback_query(F.data == "skip_lottery_add") async def skip_lottery_add(callback: CallbackQuery, state: FSMContext): """Пропустить добавление в розыгрыш""" await callback.message.edit_text("✅ Счета добавлены без участия в розыгрышах") await state.clear() @router.message(CaseInsensitiveCommand("remove_account")) @staff_only async def remove_account_command(message: Message): """ Деактивировать счет(а) (регистронезависимо) Формат: /remove_account [account_number2] [account_number3] ... Можно указать несколько счетов через пробел для массового удаления """ parts = message.text.split() if len(parts) < 2: await message.answer( "❌ Неверный формат команды\n\n" "Используйте: /remove_account [account_number2] ...\n\n" "Примеры:\n" "• /remove_account 12-34-56-78-90-12-34\n" "• /remove_account 12-34-56-78-90-12-34 98-76-54-32-10-98-76" ) return account_numbers = parts[1:] # Все аргументы после команды try: results = { 'success': [], 'not_found': [], 'errors': [] } async with async_session_maker() as session: for account_number in account_numbers: try: success = await AccountService.deactivate_account(session, account_number) if success: results['success'].append(account_number) else: results['not_found'].append(account_number) except Exception as e: results['errors'].append((account_number, str(e))) # Формируем отчёт response_parts = [] if results['success']: response_parts.append( f"✅ *Деактивировано счетов: {len(results['success'])}*\n" + "\n".join(f"• `{acc}`" for acc in results['success']) ) if results['not_found']: response_parts.append( f"❌ *Не найдено счетов: {len(results['not_found'])}*\n" + "\n".join(f"• `{acc}`" for acc in results['not_found']) ) if results['errors']: response_parts.append( f"⚠️ *Ошибки при обработке: {len(results['errors'])}*\n" + "\n".join(f"• `{acc}`: {err}" for acc, err in results['errors']) ) if not response_parts: await message.answer("❌ Не удалось обработать ни один счет") else: await message.answer("\n\n".join(response_parts), parse_mode="Markdown") except Exception as e: await message.answer(f"❌ Критическая ошибка: {public_error(e)}") @router.message(CaseInsensitiveCommand("verify_winner")) @staff_only async def verify_winner_command(message: Message): """ Подтвердить выигрыш по коду верификации (регистронезависимо) Формат: /verify_winner Пример: /verify_winner AB12CD34 1 """ parts = message.text.split() if len(parts) != 3: await message.answer( "❌ Неверный формат команды\n\n" "Используйте:\n" "/verify_winner \n\n" "Пример:\n" "/verify_winner AB12CD34 1" ) return verification_code = parts[1].upper() try: lottery_id = numeric_id(parts[2], maximum=2**31 - 1) except ValueError: await message.answer("❌ lottery_id должен быть числом") return try: async with async_session_maker() as session: # Проверяем существование розыгрыша lottery = await LotteryService.get_lottery(session, lottery_id) if not lottery: await message.answer(f"❌ Розыгрыш #{lottery_id} не найден") return # Подтверждаем выигрыш winner = await WinnerNotificationService.verify_winner( session, verification_code=verification_code, lottery_id=lottery_id ) if not winner: await message.answer( f"❌ Выигрыш не найден\n\n" f"Возможные причины:\n" f"• Неверный код верификации\n" f"• Пользователь не является победителем в розыгрыше #{lottery_id}\n" f"• Выигрыш уже был подтвержден" ) return # Получаем пользователя user = await RegistrationService.get_user_by_verification_code(session, verification_code) text = "✅ Выигрыш подтвержден!\n\n" text += f"🎯 Розыгрыш: {lottery.title}\n" text += f"🏆 Место: {winner.place}\n" text += f"🎁 Приз: {winner.prize}\n\n" if user: text += f"👤 Победитель: {user.first_name}\n" text += f"🎫 Клубная карта: {user.club_card_number}\n" if user.phone: text += f"📱 Телефон: {user.phone}\n" # Отправляем уведомление победителю try: bot = message.bot await bot.send_message( user.telegram_id, f"✅ Ваш выигрыш подтвержден!\n\n" f"🎯 Розыгрыш: {lottery.title}\n" f"🏆 Место: {winner.place}\n" f"🎁 Приз: {winner.prize}\n\n" f"Администратор свяжется с вами для получения приза." ) text += "\n📨 Победителю отправлено уведомление" except Exception as e: text += f"\n⚠️ Не удалось отправить уведомление: {public_error(e)}" if winner.account_number: text += f"💳 Счет: {winner.account_number}\n" await message.answer(text) except Exception as e: await message.answer(f"❌ Ошибка: {public_error(e)}") @router.message(CaseInsensitiveCommand("winner_status")) @staff_only async def winner_status_command(message: Message): """ Показать статус всех победителей розыгрыша (регистронезависимо) Формат: /winner_status """ parts = message.text.split() if len(parts) != 2: await message.answer( "❌ Неверный формат команды\n\n" "Используйте: /winner_status " ) return try: lottery_id = numeric_id(parts[1], maximum=2**31 - 1) except ValueError: await message.answer("❌ lottery_id должен быть числом") return try: async with async_session_maker() as session: lottery = await LotteryService.get_lottery(session, lottery_id) if not lottery: await message.answer(f"❌ Розыгрыш #{lottery_id} не найден") return winners = await LotteryService.get_winners(session, lottery_id) if not winners: await message.answer(f"В розыгрыше '{lottery.title}' пока нет победителей") return text = f"🏆 Победители розыгрыша '{lottery.title}':\n\n" for winner in winners: status_icon = "✅" if winner.is_claimed else "⏳" notified_icon = "📨" if winner.is_notified else "📭" text += f"{status_icon} {winner.place} место - {winner.prize}\n" # Получаем информацию о победителе async with async_session_maker() as session: if winner.user_id: user_result = await session.execute( select(User).where(User.id == winner.user_id) ) user = user_result.scalar_one_or_none() if user: text += f" 👤 {user.first_name}" if user.club_card_number: text += f" (КК: {user.club_card_number})" text += "\n" if winner.account_number: text += f" 💳 {winner.account_number}\n" # Статус подтверждения if winner.is_claimed: text += f" ✅ Подтвержден\n" else: text += f" ⏳ Ожидает подтверждения\n" text += "\n" await message.answer(text) except Exception as e: await message.answer(f"❌ Ошибка: {public_error(e)}") @router.message(CaseInsensitiveCommand("user_info")) @staff_only async def user_info_command(message: Message): """ Показать информацию о пользователе (регистронезависимо) Формат: /user_info """ parts = message.text.split() if len(parts) != 2: await message.answer( "❌ Неверный формат команды\n\n" "Используйте: /user_info " ) return club_card = parts[1] try: async with async_session_maker() as session: user = await RegistrationService.get_user_by_club_card(session, club_card) if not user: await message.answer(f"❌ Пользователь с клубной картой {club_card} не найден") return # Получаем счета accounts = await AccountService.get_user_accounts(session, user.id) # Получаем выигрыши winners_result = await session.execute( select(Winner).where(Winner.user_id == user.id) ) winners = winners_result.scalars().all() text = f"👤 Информация о пользователе\n\n" text += f"🎫 Клубная карта: {user.club_card_number}\n" text += f"👤 Имя: {user.first_name}" if user.last_name: text += f" {user.last_name}" text += "\n" if user.username: text += f"📱 Telegram: @{user.username}\n" if user.phone: text += f"📞 Телефон: {user.phone}\n" text += f"🔑 Код верификации: {user.verification_code}\n" text += f"📅 Зарегистрирован: {user.created_at.strftime('%d.%m.%Y')}\n\n" # Счета text += f"💳 Счета ({len(accounts)}):\n" if accounts: for acc in accounts: status = "✅" if acc.is_active else "❌" text += f" {status} {acc.account_number}\n" else: text += " Нет счетов\n" # Выигрыши text += f"\n🏆 Выигрыши ({len(winners)}):\n" if winners: for w in winners: status = "✅" if w.is_claimed else "⏳" text += f" {status} {w.place} место - {w.prize}\n" else: text += " Нет выигрышей\n" await message.answer(text) except Exception as e: await message.answer(f"❌ Ошибка: {public_error(e)}") @router.callback_query(F.data == "view_my_accounts") async def view_my_accounts_callback(callback: CallbackQuery): """Показать все счета пользователя""" import asyncio try: async with async_session_maker() as session: # Получаем пользователя user_result = await session.execute( select(User).where(User.telegram_id == callback.from_user.id) ) user = user_result.scalar_one_or_none() if not user: await callback.answer("❌ Пользователь не найден", show_alert=True) return # Получаем все счета accounts = await AccountService.get_user_accounts(session, user.id) if not accounts: await callback.answer("У вас нет счетов", show_alert=True) return # Отвечаем на callback сразу, чтобы не было timeout await callback.answer("⏳ Загружаю ваши счета...") # Если счетов много - предупреждаем о задержке batches_count = (len(accounts) + 49) // 50 # Округление вверх if batches_count > 5: await callback.message.answer( f"📊 Найдено счетов: *{len(accounts)}*\n" f"📤 Отправка {batches_count} сообщений с задержкой (~{batches_count//2} сек)\n\n" f"⏳ _Пожалуйста, подождите. Бот не завис._", parse_mode="Markdown" ) # Формируем сообщение с пагинацией (по 50 счетов на сообщение) BATCH_SIZE = 50 for i in range(0, len(accounts), BATCH_SIZE): batch = accounts[i:i+BATCH_SIZE] text = f"💳 *Ваши счета ({i+1}-{min(i+BATCH_SIZE, len(accounts))} из {len(accounts)}):*\n\n" for acc in batch: status = "✅" if acc.is_active else "❌" text += f"{status} `{acc.account_number}`\n" try: await callback.message.answer(text, parse_mode="Markdown") # Задержка между сообщениями для избежания flood control if i + BATCH_SIZE < len(accounts): await asyncio.sleep(0.5) # 500ms между сообщениями except Exception as send_error: # Если flood control - ждём дольше if "Flood control" in str(send_error) or "Too Many Requests" in str(send_error): await asyncio.sleep(2) await callback.message.answer(text, parse_mode="Markdown") else: raise except Exception as e: # Не используем callback.answer в except - может быть timeout try: await callback.message.answer(f"❌ Ошибка: {public_error(e)}") except: pass # Игнорируем если не получилось отправить