feat: Система автоматического подтверждения выигрышей с поддержкой множественных счетов
Some checks reported errors
continuous-integration/drone/push Build encountered an error
Some checks reported errors
continuous-integration/drone/push Build encountered an error
Основные изменения: ✨ Новые функции: - Система регистрации пользователей с множественными счетами - Автоматическое подтверждение выигрышей через inline-кнопки - Механизм переигровки для неподтвержденных выигрышей (24 часа) - Подтверждение на уровне счетов (каждый счет подтверждается отдельно) - Скрипт полной очистки базы данных 🔧 Технические улучшения: - Исправлена ошибка MissingGreenlet при lazy loading (добавлен joinedload/selectinload) - Добавлено поле claimed_at для отслеживания времени подтверждения - Пакетное добавление счетов с выбором розыгрыша - Проверка владения конкретным счетом при подтверждении 📚 Документация: - docs/AUTO_CONFIRM_SYSTEM.md - Полная документация системы подтверждения - docs/ACCOUNT_BASED_CONFIRMATION.md - Подтверждение на уровне счетов - docs/REGISTRATION_SYSTEM.md - Система регистрации - docs/ADMIN_COMMANDS.md - Команды администратора - docs/CLEAR_DATABASE.md - Очистка БД - docs/QUICK_GUIDE.md - Быстрое начало - docs/UPDATE_LOG.md - Журнал обновлений 🗄️ База данных: - Миграция 003: Таблицы accounts, winner_verifications - Миграция 004: Поле claimed_at в таблице winners - Скрипт scripts/clear_database.py для полной очистки 🎮 Новые команды: Админские: - /check_unclaimed <lottery_id> - Проверка неподтвержденных выигрышей - /redraw <lottery_id> - Повторный розыгрыш - /add_accounts - Пакетное добавление счетов - /list_accounts <telegram_id> - Список счетов пользователя Пользовательские: - /register - Регистрация с вводом данных - /my_account - Просмотр своих счетов - Callback confirm_win_{id} - Подтверждение выигрыша 🛠️ Makefile: - make clear-db - Очистка всех данных из БД (с подтверждением) 🔒 Безопасность: - Проверка владения счетом при подтверждении - Защита от подтверждения чужих счетов - Независимое подтверждение каждого выигрышного счета 📊 Логика работы: 1. Пользователь регистрируется и добавляет счета 2. Счета участвуют в розыгрыше 3. Победители получают уведомление с кнопкой подтверждения 4. Каждый счет подтверждается отдельно (24 часа на подтверждение) 5. Неподтвержденные выигрыши переигрываются через /redraw
This commit is contained in:
585
src/handlers/admin_account_handlers.py
Normal file
585
src/handlers/admin_account_handlers.py
Normal file
@@ -0,0 +1,585 @@
|
||||
"""Админские обработчики для управления счетами и верификации"""
|
||||
from aiogram import Router, F, Bot
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
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
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AddAccountStates(StatesGroup):
|
||||
waiting_for_data = State()
|
||||
choosing_lottery = State()
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка прав администратора"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
@router.message(Command("add_account"))
|
||||
async def add_account_command(message: Message, state: FSMContext):
|
||||
"""
|
||||
Добавить счет пользователю по клубной карте
|
||||
Формат: /add_account <club_card> <account_number>
|
||||
Или: /add_account (затем вводить данные построчно)
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
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"
|
||||
"Отправьте данные в формате:\n"
|
||||
"`клубная_карта номер_счета`\n\n"
|
||||
"**Для одного счета:**\n"
|
||||
"`2223 11-22-33-44-55-66-77`\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"Теперь вы можете участвовать в розыгрышах с этим счетом!"
|
||||
)
|
||||
text += "📨 Владельцу отправлено уведомление\n\n"
|
||||
except Exception as e:
|
||||
text += f"⚠️ Не удалось отправить уведомление: {str(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"❌ Произошла ошибка: {str(e)}")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(AddAccountStates.waiting_for_data)
|
||||
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')
|
||||
accounts_data = []
|
||||
errors = []
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
parts = line.strip().split()
|
||||
if len(parts) != 2:
|
||||
errors.append(f"Строка {i}: неверный формат (ожидается: клубная_карта номер_счета)")
|
||||
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
|
||||
})
|
||||
|
||||
# Отправляем уведомление владельцу
|
||||
if owner:
|
||||
try:
|
||||
await message.bot.send_message(
|
||||
owner.telegram_id,
|
||||
f"✅ К вашему профилю добавлен счет:\n\n"
|
||||
f"💳 {account_number}\n\n"
|
||||
f"Теперь вы можете участвовать в розыгрышах!"
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
except ValueError as e:
|
||||
errors.append(f"Строка {i} ({club_card} {account_number}): {str(e)}")
|
||||
except Exception as e:
|
||||
errors.append(f"Строка {i}: {str(e)}")
|
||||
|
||||
# Формируем отчет
|
||||
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 message.answer(text)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Сохраняем данные и предлагаем добавить в розыгрыш
|
||||
await state.update_data(accounts=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 message.answer(
|
||||
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 message.answer(
|
||||
prev_text + "➕ **Добавить счета в розыгрыш?**\n\n"
|
||||
"Выберите розыгрыш из списка:",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("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
|
||||
|
||||
for acc in accounts:
|
||||
try:
|
||||
# Добавляем участие через account_id
|
||||
# Проверяем, не участвует ли уже
|
||||
existing = await session.execute(
|
||||
select(Participation).where(
|
||||
and_(
|
||||
Participation.lottery_id == lottery_id,
|
||||
Participation.account_id == acc['account_id']
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if existing.scalar_one_or_none():
|
||||
errors.append(f"{acc['account_number']}: уже участвует")
|
||||
continue
|
||||
|
||||
# Создаем участие
|
||||
participation = Participation(
|
||||
lottery_id=lottery_id,
|
||||
account_id=acc['account_id'],
|
||||
account_number=acc['account_number']
|
||||
)
|
||||
session.add(participation)
|
||||
success_count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"{acc['account_number']}: {str(e)}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
text = f"📊 **Добавление в розыгрыш '{lottery.title}'**\n\n"
|
||||
|
||||
if success_count:
|
||||
text += f"✅ Добавлено счетов: {success_count}\n\n"
|
||||
|
||||
if errors:
|
||||
text += f"⚠️ Ошибки: {len(errors)}\n"
|
||||
for error in errors[:3]:
|
||||
text += f"• {error}\n"
|
||||
|
||||
await callback.message.edit_text(text)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@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(Command("remove_account"))
|
||||
async def remove_account_command(message: Message):
|
||||
"""
|
||||
Деактивировать счет
|
||||
Формат: /remove_account <account_number>
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
parts = message.text.split()
|
||||
if len(parts) != 2:
|
||||
await message.answer(
|
||||
"❌ Неверный формат команды\n\n"
|
||||
"Используйте: /remove_account <account_number>"
|
||||
)
|
||||
return
|
||||
|
||||
account_number = parts[1]
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
success = await AccountService.deactivate_account(session, account_number)
|
||||
|
||||
if success:
|
||||
await message.answer(f"✅ Счет {account_number} деактивирован")
|
||||
else:
|
||||
await message.answer(f"❌ Счет {account_number} не найден")
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(Command("verify_winner"))
|
||||
async def verify_winner_command(message: Message):
|
||||
"""
|
||||
Подтвердить выигрыш по коду верификации
|
||||
Формат: /verify_winner <verification_code> <lottery_id>
|
||||
Пример: /verify_winner AB12CD34 1
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
parts = message.text.split()
|
||||
if len(parts) != 3:
|
||||
await message.answer(
|
||||
"❌ Неверный формат команды\n\n"
|
||||
"Используйте:\n"
|
||||
"/verify_winner <verification_code> <lottery_id>\n\n"
|
||||
"Пример:\n"
|
||||
"/verify_winner AB12CD34 1"
|
||||
)
|
||||
return
|
||||
|
||||
verification_code = parts[1].upper()
|
||||
|
||||
try:
|
||||
lottery_id = int(parts[2])
|
||||
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⚠️ Не удалось отправить уведомление: {str(e)}"
|
||||
|
||||
if winner.account_number:
|
||||
text += f"💳 Счет: {winner.account_number}\n"
|
||||
|
||||
await message.answer(text)
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(Command("winner_status"))
|
||||
async def winner_status_command(message: Message):
|
||||
"""
|
||||
Показать статус всех победителей розыгрыша
|
||||
Формат: /winner_status <lottery_id>
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
parts = message.text.split()
|
||||
if len(parts) != 2:
|
||||
await message.answer(
|
||||
"❌ Неверный формат команды\n\n"
|
||||
"Используйте: /winner_status <lottery_id>"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
lottery_id = int(parts[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"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(Command("user_info"))
|
||||
async def user_info_command(message: Message):
|
||||
"""
|
||||
Показать информацию о пользователе
|
||||
Формат: /user_info <club_card>
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
parts = message.text.split()
|
||||
if len(parts) != 2:
|
||||
await message.answer(
|
||||
"❌ Неверный формат команды\n\n"
|
||||
"Используйте: /user_info <club_card>"
|
||||
)
|
||||
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"❌ Ошибка: {str(e)}")
|
||||
@@ -471,9 +471,13 @@ async def show_lottery_participants(callback: CallbackQuery):
|
||||
|
||||
for i, participation in enumerate(lottery.participations[:20], 1): # Показываем первых 20
|
||||
user = participation.user
|
||||
username = f"@{user.username}" if user.username else "Нет username"
|
||||
text += f"{i}. {user.first_name} {user.last_name or ''}\n"
|
||||
text += f" {username} | ID: {user.telegram_id}\n"
|
||||
if user:
|
||||
username = f"@{user.username}" if user.username else "Нет username"
|
||||
text += f"{i}. {user.first_name} {user.last_name or ''}\n"
|
||||
text += f" {username} | ID: {user.telegram_id}\n"
|
||||
else:
|
||||
# Если пользователя нет, показываем номер счета
|
||||
text += f"{i}. Счет: {participation.account_number or 'Не указан'}\n"
|
||||
text += f" Участвует с: {participation.created_at.strftime('%d.%m %H:%M')}\n\n"
|
||||
|
||||
if len(lottery.participations) > 20:
|
||||
@@ -1683,11 +1687,13 @@ async def redirect_to_edit_lottery(callback: CallbackQuery, state: FSMContext):
|
||||
parts = callback.data.split("_")
|
||||
if len(parts) == 3: # admin_edit_123
|
||||
lottery_id = int(parts[2])
|
||||
# Подменяем callback_data для обработки существующим хэндлером
|
||||
callback.data = f"admin_edit_lottery_select_{lottery_id}"
|
||||
# Напрямую вызываем обработчик вместо подмены callback_data
|
||||
await state.update_data(edit_lottery_id=lottery_id)
|
||||
await choose_edit_field(callback, state)
|
||||
else:
|
||||
# Если формат другой, то это уже правильный callback
|
||||
lottery_id = int(callback.data.split("_")[-1])
|
||||
await state.update_data(edit_lottery_id=lottery_id)
|
||||
await choose_edit_field(callback, state)
|
||||
|
||||
|
||||
@@ -2027,8 +2033,8 @@ async def handle_set_winner_from_lottery(callback: CallbackQuery, state: FSMCont
|
||||
|
||||
lottery_id = int(callback.data.split("_")[-1])
|
||||
|
||||
# Перенаправляем на стандартный обработчик
|
||||
callback.data = f"admin_choose_winner_lottery_{lottery_id}"
|
||||
# Напрямую вызываем обработчик вместо подмены callback_data
|
||||
await state.update_data(winner_lottery_id=lottery_id)
|
||||
await choose_winner_place(callback, state)
|
||||
|
||||
|
||||
@@ -2610,11 +2616,12 @@ async def conduct_lottery_draw(callback: CallbackQuery):
|
||||
await callback.answer("Нет участников для розыгрыша", show_alert=True)
|
||||
return
|
||||
|
||||
# Проводим розыгрыш
|
||||
from ..display.conduct_draw import conduct_draw
|
||||
winners = await conduct_draw(lottery_id)
|
||||
# Проводим розыгрыш через сервис
|
||||
winners_dict = await LotteryService.conduct_draw(session, lottery_id)
|
||||
|
||||
if winners:
|
||||
if winners_dict:
|
||||
# Получаем победителей из базы
|
||||
winners = await LotteryService.get_winners(session, lottery_id)
|
||||
text = f"🎉 Розыгрыш '{lottery.title}' завершён!\n\n"
|
||||
text += "🏆 Победители:\n"
|
||||
for winner in winners:
|
||||
|
||||
314
src/handlers/redraw_handlers.py
Normal file
314
src/handlers/redraw_handlers.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Команды для повторного розыгрыша неподтвержденных выигрышей"""
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.filters import Command
|
||||
from sqlalchemy import select, and_
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import random
|
||||
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.registration_services import AccountService, WinnerNotificationService
|
||||
from src.core.services import LotteryService
|
||||
from src.core.models import User, Winner
|
||||
from src.core.config import ADMIN_IDS
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка прав администратора"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
@router.message(Command("check_unclaimed"))
|
||||
async def check_unclaimed_winners(message: Message):
|
||||
"""
|
||||
Проверить неподтвержденные выигрыши (более 24 часов)
|
||||
Формат: /check_unclaimed <lottery_id>
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
parts = message.text.split()
|
||||
if len(parts) != 2:
|
||||
await message.answer(
|
||||
"❌ Неверный формат команды\n\n"
|
||||
"Используйте: /check_unclaimed <lottery_id>"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
lottery_id = int(parts[1])
|
||||
except ValueError:
|
||||
await message.answer("❌ lottery_id должен быть числом")
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy.orm import selectinload
|
||||
from src.core.models import Lottery
|
||||
|
||||
# Загружаем розыгрыш с участниками
|
||||
lottery_result = await session.execute(
|
||||
select(Lottery)
|
||||
.options(selectinload(Lottery.participations))
|
||||
.where(Lottery.id == lottery_id)
|
||||
)
|
||||
lottery = lottery_result.scalar_one_or_none()
|
||||
|
||||
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
|
||||
|
||||
# Находим неподтвержденные выигрыши старше 24 часов
|
||||
now = datetime.now(timezone.utc)
|
||||
unclaimed = []
|
||||
|
||||
for winner in winners:
|
||||
if not winner.is_claimed and winner.is_notified:
|
||||
# Проверяем, прошло ли 24 часа
|
||||
time_passed = now - winner.created_at
|
||||
if time_passed.total_seconds() > 24 * 3600: # 24 часа
|
||||
unclaimed.append({
|
||||
'winner': winner,
|
||||
'hours_passed': int(time_passed.total_seconds() / 3600)
|
||||
})
|
||||
|
||||
if not unclaimed:
|
||||
await message.answer(
|
||||
f"✅ Все победители розыгрыша '{lottery.title}' подтвердили выигрыш\n"
|
||||
f"или срок подтверждения еще не истек."
|
||||
)
|
||||
return
|
||||
|
||||
text = f"⚠️ **Неподтвержденные выигрыши в розыгрыше '{lottery.title}':**\n\n"
|
||||
|
||||
for item in unclaimed:
|
||||
winner = item['winner']
|
||||
hours = item['hours_passed']
|
||||
|
||||
text += f"🏆 {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"
|
||||
|
||||
text += f" ⏰ Прошло: {hours} часов\n\n"
|
||||
|
||||
text += f"\n📊 Всего неподтвержденных: {len(unclaimed)}\n\n"
|
||||
text += f"Используйте /redraw {lottery_id} для повторного розыгрыша"
|
||||
|
||||
await message.answer(text, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(Command("redraw"))
|
||||
async def redraw_lottery(message: Message):
|
||||
"""
|
||||
Переиграть розыгрыш для неподтвержденных выигрышей
|
||||
Формат: /redraw <lottery_id>
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ Недостаточно прав")
|
||||
return
|
||||
|
||||
parts = message.text.split()
|
||||
if len(parts) != 2:
|
||||
await message.answer(
|
||||
"❌ Неверный формат команды\n\n"
|
||||
"Используйте: /redraw <lottery_id>"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
lottery_id = int(parts[1])
|
||||
except ValueError:
|
||||
await message.answer("❌ lottery_id должен быть числом")
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy.orm import selectinload
|
||||
from src.core.models import Lottery
|
||||
|
||||
# Загружаем розыгрыш с участниками
|
||||
lottery_result = await session.execute(
|
||||
select(Lottery)
|
||||
.options(selectinload(Lottery.participations))
|
||||
.where(Lottery.id == lottery_id)
|
||||
)
|
||||
lottery = lottery_result.scalar_one_or_none()
|
||||
|
||||
if not lottery:
|
||||
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
|
||||
return
|
||||
|
||||
winners = await LotteryService.get_winners(session, lottery_id)
|
||||
|
||||
# Находим неподтвержденные выигрыши старше 24 часов
|
||||
now = datetime.now(timezone.utc)
|
||||
unclaimed_winners = []
|
||||
|
||||
for winner in winners:
|
||||
if not winner.is_claimed and winner.is_notified:
|
||||
time_passed = now - winner.created_at
|
||||
if time_passed.total_seconds() > 24 * 3600:
|
||||
unclaimed_winners.append(winner)
|
||||
|
||||
if not unclaimed_winners:
|
||||
await message.answer(
|
||||
"✅ Нет неподтвержденных выигрышей старше 24 часов.\n"
|
||||
"Повторный розыгрыш не требуется."
|
||||
)
|
||||
return
|
||||
|
||||
# Получаем всех участников, исключая текущих победителей
|
||||
all_participants = []
|
||||
current_winner_accounts = set()
|
||||
|
||||
for winner in winners:
|
||||
if winner.account_number:
|
||||
current_winner_accounts.add(winner.account_number)
|
||||
|
||||
for p in lottery.participations:
|
||||
if p.account_number and p.account_number not in current_winner_accounts:
|
||||
all_participants.append(p)
|
||||
|
||||
if not all_participants:
|
||||
await message.answer(
|
||||
"❌ Нет доступных участников для повторного розыгрыша.\n"
|
||||
"Все участники уже являются победителями."
|
||||
)
|
||||
return
|
||||
|
||||
# Переигрываем каждое неподтвержденное место
|
||||
redraw_results = []
|
||||
|
||||
for old_winner in unclaimed_winners:
|
||||
if not all_participants:
|
||||
break
|
||||
|
||||
# Выбираем нового победителя
|
||||
new_participant = random.choice(all_participants)
|
||||
all_participants.remove(new_participant)
|
||||
|
||||
# Удаляем старого победителя
|
||||
await session.delete(old_winner)
|
||||
|
||||
# Создаем нового победителя
|
||||
new_winner = Winner(
|
||||
lottery_id=lottery_id,
|
||||
user_id=None,
|
||||
account_number=new_participant.account_number,
|
||||
account_id=new_participant.account_id,
|
||||
place=old_winner.place,
|
||||
prize=old_winner.prize,
|
||||
is_manual=False,
|
||||
is_notified=False,
|
||||
is_claimed=False
|
||||
)
|
||||
session.add(new_winner)
|
||||
|
||||
redraw_results.append({
|
||||
'place': old_winner.place,
|
||||
'prize': old_winner.prize,
|
||||
'old_account': old_winner.account_number,
|
||||
'new_account': new_participant.account_number
|
||||
})
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Отправляем уведомления новым победителям
|
||||
for result in redraw_results:
|
||||
# Находим нового победителя
|
||||
new_winner_result = await session.execute(
|
||||
select(Winner).where(
|
||||
and_(
|
||||
Winner.lottery_id == lottery_id,
|
||||
Winner.place == result['place'],
|
||||
Winner.account_number == result['new_account']
|
||||
)
|
||||
)
|
||||
)
|
||||
new_winner = new_winner_result.scalar_one_or_none()
|
||||
|
||||
if new_winner:
|
||||
# Отправляем уведомление новому победителю
|
||||
owner = await AccountService.get_account_owner(session, new_winner.account_number)
|
||||
|
||||
if owner and owner.telegram_id:
|
||||
# Создаем токен верификации
|
||||
await WinnerNotificationService.create_verification_token(
|
||||
session,
|
||||
new_winner.id
|
||||
)
|
||||
|
||||
# Формируем сообщение
|
||||
notification_message = (
|
||||
f"🎉 Поздравляем! Ваш счет выиграл!\n\n"
|
||||
f"🎯 Розыгрыш: {lottery.title}\n"
|
||||
f"🏆 Место: {new_winner.place}\n"
|
||||
f"🎁 Приз: {new_winner.prize}\n"
|
||||
f"💳 Счет: {new_winner.account_number}\n\n"
|
||||
f"⏰ **У вас есть 24 часа для подтверждения!**\n\n"
|
||||
f"Нажмите кнопку ниже, чтобы подтвердить получение приза."
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="✅ Подтвердить получение приза",
|
||||
callback_data=f"confirm_win_{new_winner.id}"
|
||||
)]
|
||||
])
|
||||
|
||||
try:
|
||||
await message.bot.send_message(
|
||||
owner.telegram_id,
|
||||
notification_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
new_winner.is_notified = True
|
||||
await session.commit()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Формируем отчет для админа
|
||||
text = f"🔄 **Повторный розыгрыш завершен!**\n\n"
|
||||
text += f"🎯 Розыгрыш: {lottery.title}\n"
|
||||
text += f"📊 Переиграно мест: {len(redraw_results)}\n\n"
|
||||
|
||||
for result in redraw_results:
|
||||
text += f"🏆 {result['place']} место - {result['prize']}\n"
|
||||
text += f" ❌ Было: {result['old_account']}\n"
|
||||
text += f" ✅ Стало: {result['new_account']}\n\n"
|
||||
|
||||
text += "📨 Новым победителям отправлены уведомления"
|
||||
|
||||
await message.answer(text, parse_mode="Markdown")
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
150
src/handlers/registration_handlers.py
Normal file
150
src/handlers/registration_handlers.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Обработчики для регистрации пользователей"""
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.registration_services import RegistrationService, AccountService
|
||||
from src.core.services import UserService
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class RegistrationStates(StatesGroup):
|
||||
"""Состояния для процесса регистрации"""
|
||||
waiting_for_club_card = State()
|
||||
waiting_for_phone = State()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "start_registration")
|
||||
async def start_registration(callback: CallbackQuery, state: FSMContext):
|
||||
"""Начать процесс регистрации"""
|
||||
text = (
|
||||
"📝 Регистрация в системе\n\n"
|
||||
"Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
|
||||
"Введите номер вашей клубной карты:"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]
|
||||
])
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_club_card)
|
||||
|
||||
|
||||
@router.message(StateFilter(RegistrationStates.waiting_for_club_card))
|
||||
async def process_club_card(message: Message, state: FSMContext):
|
||||
"""Обработка номера клубной карты"""
|
||||
club_card_number = message.text.strip()
|
||||
|
||||
# Проверяем, не занята ли карта
|
||||
async with async_session_maker() as session:
|
||||
existing_user = await RegistrationService.get_user_by_club_card(session, club_card_number)
|
||||
|
||||
if existing_user:
|
||||
await message.answer(
|
||||
f"❌ Клубная карта {club_card_number} уже зарегистрирована.\n\n"
|
||||
"Если это ваша карта, обратитесь к администратору."
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.update_data(club_card_number=club_card_number)
|
||||
|
||||
await message.answer(
|
||||
"📱 Теперь введите ваш номер телефона\n"
|
||||
"(или отправьте '-' чтобы пропустить):"
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_phone)
|
||||
|
||||
|
||||
@router.message(StateFilter(RegistrationStates.waiting_for_phone))
|
||||
async def process_phone(message: Message, state: FSMContext):
|
||||
"""Обработка номера телефона"""
|
||||
phone = None if message.text.strip() == "-" else message.text.strip()
|
||||
|
||||
data = await state.get_data()
|
||||
club_card_number = data['club_card_number']
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
user = await RegistrationService.register_user(
|
||||
session,
|
||||
telegram_id=message.from_user.id,
|
||||
club_card_number=club_card_number,
|
||||
phone=phone
|
||||
)
|
||||
|
||||
text = (
|
||||
"✅ Регистрация завершена!\n\n"
|
||||
f"🎫 Клубная карта: {user.club_card_number}\n"
|
||||
f"🔑 Ваш код верификации: **{user.verification_code}**\n\n"
|
||||
"⚠️ Сохраните этот код! Он понадобится для подтверждения выигрыша.\n\n"
|
||||
"Теперь вы можете участвовать в розыгрышах!"
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="Markdown")
|
||||
await state.clear()
|
||||
|
||||
except ValueError as e:
|
||||
await message.answer(f"❌ Ошибка регистрации: {str(e)}")
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Произошла ошибка: {str(e)}")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(Command("my_code"))
|
||||
async def show_verification_code(message: Message):
|
||||
"""Показать код верификации пользователя"""
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
if not user or not user.is_registered:
|
||||
await message.answer(
|
||||
"❌ Вы не зарегистрированы в системе.\n\n"
|
||||
"Для регистрации отправьте /start и выберите 'Регистрация'"
|
||||
)
|
||||
return
|
||||
|
||||
text = (
|
||||
"🔑 Ваш код верификации:\n\n"
|
||||
f"**{user.verification_code}**\n\n"
|
||||
"Этот код используется для подтверждения выигрыша.\n"
|
||||
"Сообщите его администратору при получении приза."
|
||||
)
|
||||
|
||||
await message.answer(text, parse_mode="Markdown")
|
||||
|
||||
|
||||
@router.message(Command("my_accounts"))
|
||||
async def show_user_accounts(message: Message):
|
||||
"""Показать счета пользователя"""
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
if not user or not user.is_registered:
|
||||
await message.answer("❌ Вы не зарегистрированы в системе")
|
||||
return
|
||||
|
||||
accounts = await AccountService.get_user_accounts(session, user.id)
|
||||
|
||||
if not accounts:
|
||||
await message.answer(
|
||||
"У вас пока нет привязанных счетов.\n\n"
|
||||
"Счета добавляются администратором."
|
||||
)
|
||||
return
|
||||
|
||||
text = f"💳 Ваши счета (Клубная карта: {user.club_card_number}):\n\n"
|
||||
|
||||
for i, account in enumerate(accounts, 1):
|
||||
status = "✅" if account.is_active else "❌"
|
||||
text += f"{i}. {status} {account.account_number}\n"
|
||||
|
||||
await message.answer(text)
|
||||
Reference in New Issue
Block a user