diff --git a/Makefile b/Makefile index e715708..57f243a 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ help: @echo " make stats - Показать статистику" @echo " make demo-admin - Демонстрация админ-панели" @echo " make test-admin - Тестирование улучшений админки" + @echo " make clear-db - ⚠️ УДАЛИТЬ ВСЕ ДАННЫЕ из БД" @echo "" @echo "Быстрый старт с PostgreSQL:" @echo " 1. cp .env.example .env" @@ -102,6 +103,23 @@ test-admin: @echo "🧪 Тестирование новых функций админ-панели..." . .venv/bin/activate && python tests/test_admin_improvements.py +# ⚠️ ОПАСНО: Полная очистка базы данных +clear-db: + @echo "⚠️ ВНИМАНИЕ! Это удалит ВСЕ данные из базы данных!" + @echo " - Все пользователи" + @echo " - Все розыгрыши" + @echo " - Все счета" + @echo " - Все участия" + @echo " - Всех победителей" + @echo "" + @read -p "Вы уверены? Введите 'yes' для подтверждения: " confirm; \ + if [ "$$confirm" = "yes" ]; then \ + echo "🗑️ Очистка базы данных..."; \ + . .venv/bin/activate && python scripts/clear_database.py; \ + else \ + echo "❌ Отменено"; \ + fi + # Очистка clean: @echo "🧹 Очистка временных файлов..." diff --git a/docs/ACCOUNT_BASED_CONFIRMATION.md b/docs/ACCOUNT_BASED_CONFIRMATION.md new file mode 100644 index 0000000..3a251ce --- /dev/null +++ b/docs/ACCOUNT_BASED_CONFIRMATION.md @@ -0,0 +1,279 @@ +# Подтверждение выигрышей по счетам + +## 🎯 Концепция + +Система подтверждения выигрышей работает **на уровне счетов**, а не пользователей. + +### Основные принципы: + +1. **Один пользователь = Много счетов** + - У клиента может быть несколько счетов (11-22-33..., 44-55-66..., и т.д.) + - Каждый счет участвует в розыгрыше независимо + +2. **Один счет = Один выигрыш = Одно подтверждение** + - Если счет `11-22-33-44-55-66-77` выиграл 1 место - требуется подтверждение + - Если счет `44-55-66-77-88-99-00` выиграл 3 место - требуется отдельное подтверждение + - Даже если оба счета принадлежат одному клиенту + +3. **Независимое подтверждение** + - Каждый выигрышный счет подтверждается отдельной кнопкой + - Подтверждение одного счета не влияет на другие + - У каждого счета свой 24-часовой лимит + +## 📱 Как это работает для пользователя + +### Сценарий: У клиента 3 счета, выиграли 2 + +**Счета клиента:** +- `11-22-33-44-55-66-77` ✅ Выиграл 1 место (iPhone 15) +- `22-33-44-55-66-77-88` ❌ Не выиграл +- `33-44-55-66-77-88-99` ✅ Выиграл 3 место (AirPods Pro) + +**Клиент получает 2 сообщения:** + +### Сообщение 1: +``` +🎉 Поздравляем! Ваш счет выиграл! + +🎯 Розыгрыш: Новогодний розыгрыш +🏆 Место: 1 +🎁 Приз: iPhone 15 +💳 Выигрышный счет: 11-22-33-44-55-66-77 + +⏰ У вас есть 24 часа для подтверждения! + +Нажмите кнопку ниже, чтобы подтвердить получение приза по этому счету. +Если вы не подтвердите в течение 24 часов, приз будет разыгран заново. + +ℹ️ Если у вас несколько выигрышных счетов, подтвердите каждый из них отдельно. + +[✅ Подтвердить счет 11-22-33-44-55-66-77] +[📞 Связаться с администратором] +``` + +### Сообщение 2: +``` +🎉 Поздравляем! Ваш счет выиграл! + +🎯 Розыгрыш: Новогодний розыгрыш +🏆 Место: 3 +🎁 Приз: AirPods Pro +💳 Выигрышный счет: 33-44-55-66-77-88-99 + +⏰ У вас есть 24 часа для подтверждения! + +[✅ Подтвердить счет 33-44-55-66-77-88-99] +[📞 Связаться с администратором] +``` + +### Действия клиента: + +1. **Нажимает первую кнопку** → Счет `11-22-33-44-55-66-77` подтвержден ✅ +2. **Нажимает вторую кнопку** → Счет `33-44-55-66-77-88-99` подтвержден ✅ + +## 🔒 Безопасность + +### Проверка владения счетом + +При нажатии кнопки подтверждения система проверяет: + +```python +# Получаем владельца КОНКРЕТНОГО счета +owner = await AccountService.get_account_owner(session, winner.account_number) + +# Проверяем что текущий пользователь - владелец ЭТОГО счета +if not owner or owner.telegram_id != callback.from_user.id: + await callback.answer( + f"❌ Счет {winner.account_number} вам не принадлежит", + show_alert=True + ) + return +``` + +### Что НЕ может сделать пользователь: + +❌ Подтвердить чужой счет +❌ Подтвердить счет, который ему не принадлежит +❌ Подтвердить один счет дважды + +### Что может сделать пользователь: + +✅ Подтвердить только свои счета +✅ Подтвердить каждый свой выигрышный счет отдельно +✅ Видеть номер счета на каждой кнопке + +## 🎊 После подтверждения + +### Сообщение пользователю: + +``` +✅ Выигрыш успешно подтвержден! + +🎯 Розыгрыш: Новогодний розыгрыш +🏆 Место: 1 +🎁 Приз: iPhone 15 +💳 Счет: 11-22-33-44-55-66-77 + +🎊 Поздравляем! Администратор свяжется с вами +для передачи приза в ближайшее время. + +Спасибо за участие! +``` + +### Уведомление администратору: + +``` +✅ Победитель подтвердил получение приза! + +🎯 Розыгрыш: Новогодний розыгрыш +🏆 Место: 1 +🎁 Приз: iPhone 15 +💳 Подтвержденный счет: 11-22-33-44-55-66-77 + +👤 Владелец: Иван Петров (@ivan) +🎫 Клубная карта: 2223 +📱 Телефон: +7 900 123-45-67 +``` + +## 📊 База данных + +### Таблица `winners` + +Каждая запись = один выигрышный счет: + +```sql +id | lottery_id | account_number | place | prize | is_claimed | claimed_at +---|------------|-----------------------|-------|------------|------------|------------------ +1 | 5 | 11-22-33-44-55-66-77 | 1 | iPhone 15 | TRUE | 2025-11-16 14:00 +2 | 5 | 33-44-55-66-77-88-99 | 3 | AirPods | TRUE | 2025-11-16 14:05 +3 | 5 | 55-66-77-88-99-00-11 | 2 | MacBook | FALSE | NULL +``` + +### Ключевые поля: + +- `account_number` - конкретный выигрышный счет +- `is_claimed` - подтвержден ли ЭТОТ счет +- `claimed_at` - когда ЭТОТ счет был подтвержден + +## 🔄 Повторный розыгрыш + +Если счет не подтвержден в течение 24 часов: + +1. **Админ проверяет**: `/check_unclaimed 5` + ``` + ⚠️ Неподтвержденные выигрыши: + + 🏆 2 место - MacBook + 💳 55-66-77-88-99-00-11 + ⏰ Прошло: 26 часов + ``` + +2. **Админ переигрывает**: `/redraw 5` + - Удаляется Winner с `account_number = 55-66-77-88-99-00-11` + - Выбирается новый случайный счет + - Новому владельцу счета отправляется уведомление + - Новый владелец получает свои 24 часа + +## 💡 Преимущества подхода + +### ✅ Для пользователей: + +1. **Понятность** - видят конкретный номер счета на кнопке +2. **Контроль** - могут подтверждать счета независимо +3. **Гибкость** - один счет подтвердил, другой нет (если забыл) + +### ✅ Для администраторов: + +1. **Точность** - знают какой именно счет подтвержден +2. **Прозрачность** - видят все действия по каждому счету +3. **Справедливость** - переиграть можно конкретный неподтвержденный счет + +### ✅ Для системы: + +1. **Масштабируемость** - неограниченное количество счетов у одного пользователя +2. **Независимость** - каждый счет живет своей жизнью +3. **Целостность** - нет конфликтов между разными выигрышами + +## 🔍 Примеры использования + +### Пример 1: Один пользователь, два выигрыша + +``` +Клиент: Иван Петров (КК: 2223) +Счета: + - 11-22-33-44-55-66-77 → Выиграл 1 место ✅ Подтвержден + - 22-33-44-55-66-77-88 → Выиграл 3 место ✅ Подтвержден + +Результат: Иван получит 2 приза +``` + +### Пример 2: Частичное подтверждение + +``` +Клиент: Мария Сидорова (КК: 3334) +Счета: + - 33-44-55-66-77-88-99 → Выиграл 2 место ✅ Подтвержден + - 44-55-66-77-88-99-00 → Выиграл 4 место ❌ Не подтвержден (> 24ч) + +Результат: + - Мария получит приз за 2 место + - 4 место будет переиграно +``` + +### Пример 3: Полная неявка + +``` +Клиент: Петр Иванов (КК: 5556) +Счета: + - 55-66-77-88-99-00-11 → Выиграл 1 место ❌ Не подтвержден + - 66-77-88-99-00-11-22 → Выиграл 2 место ❌ Не подтвержден + +Результат: Оба места будут переиграны отдельно +``` + +## 📝 Технические детали + +### Callback данные + +```python +# Формат: confirm_win_{winner_id} +callback_data = f"confirm_win_{winner.id}" + +# winner.id - уникальный ID записи в таблице winners +# Каждый счет-выигрыш имеет свой winner_id +``` + +### Проверка при подтверждении + +```python +# 1. Получаем winner по ID +winner = await session.get(Winner, winner_id) + +# 2. Проверяем что счет принадлежит пользователю +owner = await AccountService.get_account_owner(session, winner.account_number) +if owner.telegram_id != current_user_id: + return "Не ваш счет" + +# 3. Подтверждаем ЭТОТ счет +winner.is_claimed = True +winner.claimed_at = datetime.now(timezone.utc) +await session.commit() +``` + +## 🎓 Выводы + +Подход "счет = выигрыш" обеспечивает: + +- 🎯 **Точность** - подтверждается конкретный счет, а не абстрактный выигрыш +- 🔒 **Безопасность** - только владелец счета может подтвердить +- 📊 **Масштабируемость** - неограниченное количество счетов и выигрышей +- 👥 **Справедливость** - каждый счет обрабатывается независимо +- 💡 **Прозрачность** - всегда понятно какой именно счет подтверждается + +--- + +## 📞 См. также + +- `AUTO_CONFIRM_SYSTEM.md` - Полная документация системы подтверждения +- `REGISTRATION_SYSTEM.md` - Система регистрации счетов +- `ADMIN_GUIDE.md` - Руководство администратора diff --git a/docs/ADMIN_COMMANDS.md b/docs/ADMIN_COMMANDS.md new file mode 100644 index 0000000..cef78a5 --- /dev/null +++ b/docs/ADMIN_COMMANDS.md @@ -0,0 +1,318 @@ +# Админские команды - Руководство + +## 🎯 Управление счетами + +### `/add_account` - Добавить счет пользователю + +Привязывает счет к зарегистрированному пользователю по клубной карте. + +**Формат:** +``` +/add_account +``` + +**Пример:** +``` +/add_account 2223 11-22-33-44-55-66-77 +``` + +**Что происходит:** +- Система проверяет существование пользователя с указанной клубной картой +- Создает новую запись счета в таблице `accounts` +- Отправляет уведомление владельцу о добавлении счета +- Счет становится активным и может участвовать в розыгрышах + +**Возможные ошибки:** +- "Пользователь с клубной картой X не найден" - пользователь не зарегистрирован +- "Счет уже существует" - этот номер счета уже привязан к другому пользователю + +--- + +### `/remove_account` - Деактивировать счет + +Делает счет неактивным (не удаляет из БД). + +**Формат:** +``` +/remove_account +``` + +**Пример:** +``` +/remove_account 11-22-33-44-55-66-77 +``` + +**Что происходит:** +- Устанавливает флаг `is_active = False` +- Счет остается в БД, но не может участвовать в новых розыгрышах +- История участия сохраняется + +--- + +## 🏆 Управление выигрышами + +### `/verify_winner` - Подтвердить выигрыш + +Подтверждает выигрыш по коду верификации пользователя. + +**Формат:** +``` +/verify_winner +``` + +**Пример:** +``` +/verify_winner AB12CD34 1 +``` + +**Процесс верификации:** +1. Пользователь сообщает администратору свой код верификации +2. Администратор проверяет, что пользователь является победителем указанного розыгрыша +3. Система устанавливает флаг `is_claimed = True` для выигрыша +4. Отправляется уведомление победителю о подтверждении + +**Что происходит:** +- Поиск пользователя по коду верификации +- Проверка наличия выигрыша в указанном розыгрыше +- Установка флага `is_claimed = True` +- Отправка подтверждающего сообщения победителю + +**Возможные ошибки:** +- "Розыгрыш не найден" - неверный lottery_id +- "Выигрыш не найден" - неверный код или пользователь не победитель +- "Выигрыш уже был подтвержден" - повторная попытка подтверждения + +--- + +### `/winner_status` - Статус победителей + +Показывает всех победителей розыгрыша и их статус подтверждения. + +**Формат:** +``` +/winner_status +``` + +**Пример:** +``` +/winner_status 1 +``` + +**Отображаемая информация:** +- 🏆 Место и приз +- 👤 Имя и клубная карта победителя +- 💳 Номер счета (если участвовал через счет) +- ✅ Статус подтверждения (подтвержден / ожидает) +- 📨 Статус уведомления (отправлено / нет) + +**Статусы:** +- ✅ - Выигрыш подтвержден (`is_claimed = True`) +- ⏳ - Ожидает подтверждения (`is_claimed = False`) +- 📨 - Уведомление отправлено (`is_notified = True`) +- 📭 - Уведомление не отправлено (`is_notified = False`) + +--- + +## 👤 Информация о пользователе + +### `/user_info` - Информация о пользователе + +Показывает полную информацию о пользователе по клубной карте. + +**Формат:** +``` +/user_info +``` + +**Пример:** +``` +/user_info 2223 +``` + +**Отображаемая информация:** +- 🎫 Клубная карта +- 👤 Имя и Telegram username +- 📞 Телефон (если указан) +- 🔑 Код верификации +- 📅 Дата регистрации +- 💳 Список всех счетов (активные и неактивные) +- 🏆 История выигрышей + +--- + +## 🔄 Автоматические уведомления + +При проведении розыгрыша (`/conduct` через кнопку) система автоматически: + +1. **Определяет владельцев счетов-победителей** + - Ищет запись в таблице `accounts` + - Находит владельца через `owner_id` + +2. **Создает токен верификации** + - Генерирует уникальный токен для каждого выигрыша + - Устанавливает срок действия 24 часа + - Сохраняет в таблице `winner_verifications` + +3. **Отправляет уведомление победителю** + ``` + 🎉 Поздравляем! Ваш счет выиграл! + + 🎯 Розыгрыш: Название розыгрыша + 🏆 Место: 1 + 🎁 Приз: Приз первого места + 💳 Счет: 11-22-33-44-55-66-77 + + 🔑 Ваш код верификации: AB12CD34 + + Для получения приза свяжитесь с администратором + и предоставьте этот код. + ``` + +4. **Устанавливает флаг уведомления** + - `is_notified = True` в таблице `winners` + +--- + +## 🔐 Система верификации + +### Как это работает: + +1. **При регистрации пользователя:** + - Генерируется уникальный 8-символьный код (например, `AB12CD34`) + - Сохраняется в `users.verification_code` + - Пользователь может посмотреть свой код через `/my_code` + +2. **При выигрыше:** + - Владельцу счета отправляется сообщение с его кодом верификации + - Создается токен в таблице `winner_verifications` + +3. **При подтверждении выигрыша:** + - Администратор запрашивает код у победителя + - Вводит команду `/verify_winner AB12CD34 1` + - Система проверяет соответствие кода и наличие выигрыша + - Устанавливает `is_claimed = True` + +### Преимущества: + +- 🔒 **Безопасность**: Только владелец знает свой код +- ✅ **Проверка**: Невозможно подтвердить чужой выигрыш +- 📊 **Отслеживание**: Полная история подтверждений +- ⏱️ **Контроль**: Токены имеют срок действия + +--- + +## 📊 Типичные сценарии + +### Сценарий 1: Новый пользователь с двумя счетами + +``` +1. Пользователь: /start → "Зарегистрироваться" +2. Пользователь: Вводит клубную карту "2223" +3. Пользователь: Вводит телефон или пропускает +4. Система: Показывает код верификации "AB12CD34" + +5. Админ: /add_account 2223 11-22-33-44-55-66-77 +6. Админ: /add_account 2223 88-99-00-11-22-33-44 + +7. Пользователь: /my_accounts + → Видит оба счета +``` + +### Сценарий 2: Проведение розыгрыша и подтверждение + +``` +1. Админ: Создает розыгрыш через интерфейс +2. Счета участвуют автоматически +3. Админ: Нажимает "Провести розыгрыш" + +4. Система: Автоматически отправляет уведомления: + "🎉 Поздравляем! Ваш счет выиграл! + 💳 Счет: 11-22-33-44-55-66-77 + 🔑 Ваш код верификации: AB12CD34" + +5. Победитель: Связывается с админом, называет код "AB12CD34" + +6. Админ: /verify_winner AB12CD34 1 +7. Система: Подтверждает выигрыш, отправляет уведомление победителю +``` + +### Сценарий 3: Проверка статуса всех победителей + +``` +1. Админ: /winner_status 1 + +Результат: +🏆 Победители розыгрыша 'Новогодний розыгрыш': + +✅ 1 место - Главный приз + 👤 Иван (КК: 2223) + 💳 11-22-33-44-55-66-77 + ✅ Подтвержден + +⏳ 2 место - Второй приз + 👤 Петр (КК: 3334) + 💳 22-33-44-55-66-77-88 + ⏳ Ожидает подтверждения +``` + +--- + +## 🛠️ Техническая информация + +### Таблицы базы данных: + +**accounts** - Счета пользователей +- `account_number` - Номер счета (формат XX-XX-XX-XX-XX-XX-XX) +- `owner_id` - ID владельца (FK → users.id) +- `is_active` - Активен ли счет +- `created_at` - Дата создания + +**winner_verifications** - Токены верификации выигрышей +- `winner_id` - ID выигрыша (FK → winners.id) +- `verification_token` - Уникальный токен +- `is_verified` - Подтвержден ли +- `verified_at` - Время подтверждения +- `expires_at` - Срок действия (24 часа) + +**winners** - Победители (расширенная) +- Добавлены поля: `is_notified`, `is_claimed` + +**users** - Пользователи (расширенная) +- Добавлены поля: `club_card_number`, `phone`, `is_registered`, `verification_code` + +--- + +## 📝 Полезные команды для пользователей + +- `/start` - Главное меню / регистрация +- `/my_code` - Показать свой код верификации +- `/my_accounts` - Список моих счетов + +--- + +## ⚠️ Важные замечания + +1. **Код верификации** - строго конфиденциальная информация +2. **Один счет** = один владелец (нельзя передать счет другому) +3. **Деактивация счета** - не удаляет историю участия +4. **Токены верификации** - действуют 24 часа +5. **Уведомления** - отправляются автоматически только если пользователь зарегистрирован + +--- + +## 🆘 Решение проблем + +**Пользователь не получил уведомление о выигрыше:** +- Проверить `/winner_status ` - должна быть отметка 📨 +- Возможно, пользователь заблокировал бота +- Проверить telegram_id пользователя через `/user_info` + +**Не получается подтвердить выигрыш:** +- Проверить код верификации: `/user_info ` +- Убедиться что lottery_id верный +- Проверить что выигрыш еще не подтвержден + +**Счет не добавляется:** +- Убедиться что пользователь зарегистрирован +- Проверить формат номера счета (7 пар цифр через дефис) +- Проверить что счет уникален (не добавлен другому пользователю) diff --git a/docs/AUTO_CONFIRM_SYSTEM.md b/docs/AUTO_CONFIRM_SYSTEM.md new file mode 100644 index 0000000..a027394 --- /dev/null +++ b/docs/AUTO_CONFIRM_SYSTEM.md @@ -0,0 +1,439 @@ +# Система автоматического подтверждения и повторного розыгрыша + +## 🎯 Обзор системы + +Реализована полная система автоматического подтверждения выигрышей с возможностью повторного розыгрыша для неподтвержденных призов. + +--- + +## 🔄 Как это работает + +### 1. Победитель получает уведомление + +После проведения розыгрыша победителю автоматически отправляется сообщение с **интерактивной кнопкой**: + +``` +🎉 Поздравляем! Ваш счет выиграл! + +🎯 Розыгрыш: Новогодний розыгрыш +🏆 Место: 1 +🎁 Приз: Главный приз +💳 Счет: 11-22-33-44-55-66-77 + +⏰ У вас есть 24 часа для подтверждения! + +Нажмите кнопку ниже, чтобы подтвердить получение приза. +Если вы не подтвердите в течение 24 часов, приз будет разыгран заново. + +[✅ Подтвердить получение приза] +[📞 Связаться с администратором] +``` + +### 2. Победитель подтверждает выигрыш + +Пользователь нажимает кнопку "✅ Подтвердить получение приза": + +**Что происходит:** +- В БД устанавливается `is_claimed = True` +- Сохраняется время подтверждения `claimed_at` +- Победитель видит подтверждение: + +``` +✅ Выигрыш успешно подтвержден! + +🎯 Розыгрыш: Новогодний розыгрыш +🏆 Место: 1 +🎁 Приз: Главный приз + +🎊 Поздравляем! Администратор свяжется с вами +для передачи приза в ближайшее время. + +Спасибо за участие! +``` + +**Администраторы получают уведомление:** + +``` +✅ Победитель подтвердил получение приза! + +🎯 Розыгрыш: Новогодний розыгрыш +🏆 Место: 1 +🎁 Приз: Главный приз +👤 Победитель: Иван (@ivan) +🎫 Клубная карта: 2223 +📱 Телефон: +7 900 123-45-67 +💳 Счет: 11-22-33-44-55-66-77 +``` + +### 3. Если победитель не подтверждает (24 часа) + +Администратор проверяет неподтвержденные выигрыши: + +``` +/check_unclaimed 1 +``` + +**Ответ бота:** + +``` +⚠️ Неподтвержденные выигрыши в розыгрыше 'Новогодний розыгрыш': + +🏆 1 место - Главный приз + 👤 Иван (КК: 2223) + 💳 11-22-33-44-55-66-77 + ⏰ Прошло: 26 часов + +🏆 3 место - Третий приз + 👤 Петр (КК: 3334) + 💳 22-33-44-55-66-77-88 + ⏰ Прошло: 30 часов + +📊 Всего неподтвержденных: 2 + +Используйте /redraw 1 для повторного розыгрыша +``` + +### 4. Повторный розыгрыш + +Администратор запускает переигровку: + +``` +/redraw 1 +``` + +**Что происходит:** + +1. **Система находит неподтвержденные выигрыши** (старше 24 часов) +2. **Получает пул участников** (исключая текущих победителей) +3. **Случайно выбирает новых победителей** для каждого неподтвержденного места +4. **Удаляет старых победителей** из БД +5. **Создает новых победителей** +6. **Отправляет уведомления** новым победителям (с кнопкой подтверждения) + +**Результат для администратора:** + +``` +🔄 Повторный розыгрыш завершен! + +🎯 Розыгрыш: Новогодний розыгрыш +📊 Переиграно мест: 2 + +🏆 1 место - Главный приз + ❌ Было: 11-22-33-44-55-66-77 + ✅ Стало: 99-88-77-66-55-44-33 + +🏆 3 место - Третий приз + ❌ Было: 22-33-44-55-66-77-88 + ✅ Стало: 12-34-56-78-90-12-34 + +📨 Новым победителям отправлены уведомления +``` + +**Новые победители получают** то же уведомление с кнопкой подтверждения и 24-часовым лимитом. + +--- + +## 📋 Админские команды + +### `/check_unclaimed ` + +Проверить неподтвержденные выигрыши старше 24 часов. + +**Пример:** +``` +/check_unclaimed 1 +``` + +**Показывает:** +- Список всех неподтвержденных выигрышей +- Информацию о победителях +- Сколько времени прошло с момента уведомления + +### `/redraw ` + +Переиграть розыгрыш для неподтвержденных выигрышей. + +**Пример:** +``` +/redraw 1 +``` + +**Требования:** +- Должны быть неподтвержденные выигрыши старше 24 часов +- Должны быть доступные участники (не победители) + +--- + +## 🗄️ Изменения в базе данных + +### Таблица `winners` + +Добавлено новое поле: +- `claimed_at` (TIMESTAMP) - время подтверждения выигрыша победителем + +### Миграция + +Файл: `migrations/versions/004_add_claimed_at.py` + +Применить: +```bash +alembic upgrade head +``` + +--- + +## 🔧 Технические детали + +### Обработчик подтверждения + +**Файл:** `main.py` + +**Callback:** `confirm_win_{winner_id}` + +**Функция:** `confirm_winner_response()` + +**Логика:** +1. Проверяет существование выигрыша +2. Проверяет, что подтверждает владелец +3. Устанавливает `is_claimed = True` и `claimed_at = now()` +4. Обновляет сообщение победителя +5. Уведомляет всех администраторов + +### Система уведомлений + +**Функция:** `notify_winners_async()` + +**Изменения:** +- Добавлена кнопка "✅ Подтвердить получение приза" +- Добавлено предупреждение о 24-часовом лимите +- Добавлена кнопка "📞 Связаться с администратором" + +### Повторный розыгрыш + +**Файл:** `src/handlers/redraw_handlers.py` + +**Команды:** +- `check_unclaimed_winners()` - проверка +- `redraw_lottery()` - переигровка + +**Алгоритм:** +1. Получает всех победителей розыгрыша +2. Фильтрует неподтвержденных (is_claimed=False, is_notified=True, >24ч) +3. Получает пул участников (исключая победителей) +4. Для каждого неподтвержденного: + - Выбирает случайного участника + - Удаляет старого победителя + - Создает нового победителя + - Отправляет уведомление + +--- + +## 📊 Состояния выигрыша + +### Таймлайн жизни выигрыша: + +``` +1. Розыгрыш проведен + ├─ is_notified: False + ├─ is_claimed: False + └─ claimed_at: NULL + +2. Уведомление отправлено + ├─ is_notified: True + ├─ is_claimed: False + └─ claimed_at: NULL + +3А. Победитель подтвердил (успех) + ├─ is_notified: True + ├─ is_claimed: True + └─ claimed_at: 2025-11-16 13:00:00 + +3Б. Прошло 24 часа (неудача) + ├─ is_notified: True + ├─ is_claimed: False + ├─ claimed_at: NULL + └─ ⏰ time_passed > 24h → переигровка + +4. После переигровки (новый победитель) + ├─ Старый победитель удален + ├─ Создан новый победитель + ├─ is_notified: True (после отправки) + ├─ is_claimed: False + └─ claimed_at: NULL → снова 24 часа +``` + +--- + +## ⚠️ Важные моменты + +### Безопасность + +1. **Проверка владельца**: Только владелец счета может подтвердить выигрыш +2. **Повторное подтверждение**: Если выигрыш уже подтвержден, показывается соответствующее сообщение +3. **Права администратора**: Только админы могут запускать `/redraw` + +### Ограничения + +1. **24-часовой лимит**: Жестко закодирован, но легко изменить в коде +2. **Пул участников**: Если все участники уже победители, переигровка невозможна +3. **Уникальность**: Один счет не может выиграть дважды в одном розыгрыше + +### Отказоустойчивость + +1. **Уведомления**: Если не удается отправить - продолжает работу +2. **Транзакции**: Все изменения в БД атомарны +3. **Логирование**: Все действия записываются в лог + +--- + +## 🎯 Типичные сценарии + +### Сценарий 1: Все подтвердили + +``` +1. Розыгрыш → 3 победителя +2. Все 3 нажали кнопку подтверждения +3. Админ: /check_unclaimed 1 + → "✅ Все победители подтвердили выигрыш" +4. Приз передается всем победителям +``` + +### Сценарий 2: Один не подтвердил + +``` +1. Розыгрыш → 3 победителя +2. 2 подтвердили, 1 игнорирует +3. Через 25 часов админ: /check_unclaimed 1 + → "⚠️ 1 неподтвержденный выигрыш" +4. Админ: /redraw 1 +5. Система переигрывает 1 место +6. Новый победитель получает уведомление +7. У нового победителя снова 24 часа +``` + +### Сценарий 3: Множественная переигровка + +``` +1. Розыгрыш → 5 победителей +2. 3 подтвердили, 2 игнорируют +3. Через 25 часов: /redraw 1 +4. 2 новых победителя выбраны +5. Один из новых тоже игнорирует +6. Через 25 часов: /redraw 1 снова +7. Выбран еще один новый победитель +``` + +--- + +## 📈 Статистика и мониторинг + +### Проверка статуса + +``` +/winner_status 1 +``` + +Покажет: +- ✅ Подтвержденные выигрыши (с is_claimed=True) +- ⏳ Ожидающие подтверждения (с is_claimed=False) +- 📨 Статус уведомления (is_notified) + +### SQL запросы для админа + +**Найти все неподтвержденные старше 24 часов:** + +```sql +SELECT w.*, l.title +FROM winners w +JOIN lotteries l ON w.lottery_id = l.id +WHERE w.is_notified = TRUE + AND w.is_claimed = FALSE + AND w.created_at < NOW() - INTERVAL '24 hours'; +``` + +**Статистика по подтверждениям:** + +```sql +SELECT + lottery_id, + COUNT(*) as total_winners, + SUM(CASE WHEN is_claimed THEN 1 ELSE 0 END) as confirmed, + SUM(CASE WHEN NOT is_claimed THEN 1 ELSE 0 END) as unconfirmed +FROM winners +WHERE is_notified = TRUE +GROUP BY lottery_id; +``` + +--- + +## 🚀 Использование + +### Для администратора + +1. **После розыгрыша**: Ничего не делать - система отправит уведомления +2. **Через 24-30 часов**: Проверить `/check_unclaimed ` +3. **Если есть неподтвержденные**: Запустить `/redraw ` +4. **Повторить** при необходимости + +### Для победителя + +1. **Получить уведомление** с кнопкой +2. **Нажать "✅ Подтвердить"** +3. **Дождаться связи с админом** +4. **Получить приз** + +--- + +## 💡 Рекомендации + +### Оптимальные настройки + +- **Лимит подтверждения**: 24 часа (можно увеличить до 48ч) +- **Частота проверки**: 1-2 раза в день +- **Уведомления**: Включить push-уведомления в боте + +### Коммуникация с пользователями + +После розыгрыша отправьте общее сообщение: + +``` +🎉 Розыгрыш завершен! + +Если вы выиграли, вам придет сообщение с кнопкой подтверждения. + +⏰ У вас будет 24 часа чтобы подтвердить выигрыш! + +Если не подтвердите - приз будет переигран. +``` + +--- + +## 🔮 Возможные улучшения + +1. **Напоминания**: Отправка напоминания за 2 часа до истечения срока +2. **Гибкий лимит**: Разные лимиты для разных типов призов +3. **История**: Логирование всех переигровок +4. **Статистика**: Процент подтверждений, среднее время подтверждения +5. **Автоматизация**: Cron-задача для автоматической переигровки + +--- + +## ✅ Преимущества системы + +- 🤖 **Полная автоматизация**: Минимум ручной работы для админа +- ⏱️ **Справедливость**: Четкий дедлайн для всех +- 🔄 **Эффективность**: Призы не "зависают" у неактивных победителей +- 📊 **Прозрачность**: Полная история всех действий +- 🛡️ **Безопасность**: Только владелец может подтвердить +- 💬 **UX**: Простая кнопка вместо сложной верификации + +--- + +## 📞 Поддержка + +Если что-то пошло не так: + +1. Проверьте логи бота +2. Проверьте состояние БД (claimed_at, is_notified, is_claimed) +3. Используйте `/winner_status ` для диагностики +4. При критических ошибках - используйте `/verify_winner` для ручного подтверждения diff --git a/docs/CLEAR_DATABASE.md b/docs/CLEAR_DATABASE.md new file mode 100644 index 0000000..241df12 --- /dev/null +++ b/docs/CLEAR_DATABASE.md @@ -0,0 +1,72 @@ +# Очистка базы данных - Краткое руководство + +## 🎯 Быстрый старт + +```bash +make clear-db +``` + +Система попросит подтверждение, введите `yes`. + +## ✅ Результат + +База данных будет полностью очищена: +- ✅ Удалены все пользователи +- ✅ Удалены все розыгрыши +- ✅ Удалены все счета +- ✅ Удалены все участия +- ✅ Удалены все победители +- ✅ Сброшены счетчики ID (начнутся с 1) + +## 📊 Статистика очистки + +Скрипт покажет сколько строк удалено из каждой таблицы: + +``` +✅ winner_verifications - удалено 0 строк +✅ winners - удалено 2 строк +✅ participations - удалено 1 строк +✅ accounts - удалено 2 строк +✅ lotteries - удалено 1 строк +✅ users - удалено 2 строк +``` + +## ⚠️ ВНИМАНИЕ + +**Данные удаляются БЕЗ ВОЗМОЖНОСТИ ВОССТАНОВЛЕНИЯ!** + +Перед запуском на production всегда делайте бэкап: + +```bash +pg_dump -h 192.168.0.102 -U bot_user -d bot_db > backup.sql +``` + +## 🔧 Альтернативные способы + +### Через Python напрямую + +```bash +source .venv/bin/activate +python scripts/clear_database.py +``` + +### Через SQL (только данные, без сброса ID) + +```sql +DELETE FROM winner_verifications; +DELETE FROM winners; +DELETE FROM participations; +DELETE FROM accounts; +DELETE FROM lotteries; +DELETE FROM users; +``` + +## 📖 Подробная документация + +См. `scripts/README_CLEAR_DB.md` для полной документации. + +## 🆘 Восстановление из бэкапа + +```bash +psql -h 192.168.0.102 -U bot_user -d bot_db < backup.sql +``` diff --git a/docs/QUICK_GUIDE.md b/docs/QUICK_GUIDE.md new file mode 100644 index 0000000..5da4e48 --- /dev/null +++ b/docs/QUICK_GUIDE.md @@ -0,0 +1,152 @@ +# Быстрая шпаргалка - Команды администратора + +## 🚀 Основные команды + +### Добавление счетов + +**Один счет (быстро):** +``` +/add_account 2223 11-22-33-44-55-66-77 +``` + +**Несколько счетов (пакетом):** +``` +/add_account + +2223 11-22-33-44-55-66-77 +2223 88-99-00-11-22-33-44 +3334 12-34-56-78-90-12-34 +``` + +### Управление счетами + +``` +/remove_account 11-22-33-44-55-66-77 # Деактивировать +/user_info 2223 # Информация о пользователе +``` + +### Работа с розыгрышами + +``` +/winner_status 1 # Статус победителей +/verify_winner AB12CD34 1 # Подтвердить выигрыш +``` + +--- + +## 📋 Типичные сценарии + +### Новый клиент (полный цикл) + +1. Клиент регистрируется: `/start` → "Зарегистрироваться" +2. Админ добавляет счета: + ``` + /add_account 2223 11-22-33-44-55-66-77 + ``` +3. Выбрать розыгрыш из списка +4. Готово! + +### Массовое добавление (10+ счетов) + +``` +/add_account + +2223 11-22-33-44-55-66-77 +2223 22-33-44-55-66-77-88 +3334 33-44-55-66-77-88-99 +3334 44-55-66-77-88-99-00 +5556 55-66-77-88-99-00-11 +``` + +→ Выбрать розыгрыш → Готово! + +### Проведение розыгрыша + +1. В боте: "Розыгрыши" → Выбрать → "Провести розыгрыш" +2. Победители получат уведомления автоматически +3. Проверить: `/winner_status 1` + +### Подтверждение победителя + +1. Победитель сообщает код: `AB12CD34` +2. Админ: `/verify_winner AB12CD34 1` +3. Система подтверждает и уведомляет победителя + +--- + +## 💡 Полезные советы + +- **Формат счета:** `XX-XX-XX-XX-XX-XX-XX` (7 пар цифр) +- **Клубная карта:** Любые цифры (например: 2223, 5556) +- **Код верификации:** Генерируется автоматически при регистрации +- **Отмена операции:** `/cancel` во время ввода данных + +--- + +## ⚠️ Частые ошибки + +| Ошибка | Причина | Решение | +|--------|---------|---------| +| "Пользователь не найден" | Клубная карта не зарегистрирована | Попросить зарегистрироваться через /start | +| "Счет уже существует" | Номер занят другим пользователем | Проверить номер или деактивировать старый | +| "Неверный формат" | Формат счета неправильный | Использовать XX-XX-XX-XX-XX-XX-XX | +| Нет розыгрышей | Нет активных розыгрышей | Создать розыгрыш через админ-панель | + +--- + +## 🔍 Проверка данных + +### Проверить пользователя: +``` +/user_info 2223 +``` + +Увидите: +- Все счета пользователя +- Код верификации +- Историю выигрышей + +### Проверить победителей: +``` +/winner_status 1 +``` + +Увидите: +- Список всех победителей +- Статус подтверждения (✅/⏳) +- Информацию о владельцах + +--- + +## 📞 Пользовательские команды + +Клиенты используют: +- `/start` - Главное меню / регистрация +- `/my_code` - Показать код верификации +- `/my_accounts` - Список счетов +- "Мой счет" (через меню) - Подробная информация + +--- + +## 🎯 Быстрый старт (за 1 минуту) + +```bash +# 1. Клиент регистрируется +Клиент → /start → "Зарегистрироваться" → Вводит данные + +# 2. Админ добавляет счета +/add_account +2223 11-22-33-44-55-66-77 +2223 88-99-00-11-22-33-44 + +# 3. Выбрать розыгрыш +Нажать "🎯 Новогодний розыгрыш" + +# 4. Провести розыгрыш +"Розыгрыши" → Выбрать → "Провести" + +# 5. Подтвердить победителя +/verify_winner AB12CD34 1 + +✅ Готово! +``` diff --git a/docs/REGISTRATION_SYSTEM.md b/docs/REGISTRATION_SYSTEM.md new file mode 100644 index 0000000..4ba3ebc --- /dev/null +++ b/docs/REGISTRATION_SYSTEM.md @@ -0,0 +1,261 @@ +# Система регистрации и верификации выигрышей + +## Архитектура + +### Модели данных + +#### 1. User (Расширенная) +- `club_card_number` - номер клубной карты (уникальный идентификатор клиента) +- `phone` - телефон для связи +- `is_registered` - прошел ли полную регистрацию +- `verification_code` - секретный код для подтверждения выигрыша (генерируется автоматически) + +#### 2. Account (Новая) +- `account_number` - номер счета в формате XX-XX-XX-XX-XX-XX-XX +- `owner_id` - владелец счета (связь с User через club_card_number) +- `is_active` - активен ли счет + +#### 3. WinnerVerification (Новая) +- `winner_id` - связь с Winner +- `verification_token` - токен для подтверждения выигрыша +- `is_verified` - подтвержден ли выигрыш +- `expires_at` - срок действия токена (24 часа) + +## Процессы + +### 1. Регистрация пользователя + +**Инициатор:** Обычный пользователь через бота + +**Шаги:** +1. Пользователь отправляет `/start` +2. Бот проверяет `is_registered` +3. Если `False` - запрашивает: + - Номер клубной карты + - Телефон (опционально) +4. Генерируется `verification_code` (8-символьный уникальный код) +5. Код показывается пользователю: "Ваш код верификации: **AB12CD34**. Сохраните его!" +6. `is_registered = True` + +### 2. Создание счета администратором + +**Инициатор:** Администратор + +**Формат команды:** +``` +/add_account 2223 11-22-33-44-55-66-77 +``` + +**Процесс:** +1. Админ отправляет команду с номером КК и счетом +2. Бот находит пользователя по `club_card_number = 2223` +3. Создается запись в таблице `accounts`: + ```python + Account( + account_number="11-22-33-44-55-66-77", + owner_id=user.id, + is_active=True + ) + ``` +4. Пользователю отправляется уведомление: + ``` + ✅ К вашему профилю добавлен счет: + 11-22-33-44-55-66-77 + ``` + +### 3. Проведение розыгрыша с уведомлением победителей + +**Процесс:** +1. Администратор проводит розыгрыш +2. Выбирается случайный счет (например, `11-22-33-44-55-66-77`) +3. Система ищет владельца счета: + ```python + account = Account.query.filter_by(account_number="11-22-33-44-55-66-77").first() + user = account.owner if account else None + ``` +4. Создается запись `Winner` и `WinnerVerification`: + ```python + winner = Winner( + lottery_id=lottery.id, + user_id=user.id if user else None, + account_number="11-22-33-44-55-66-77", + place=1, + prize="iPhone 15" + ) + + verification = WinnerVerification( + winner_id=winner.id, + verification_token=generate_token(), + expires_at=now + 24hours + ) + ``` + +5. Если `user` найден - отправляется личное сообщение: + ``` + 🎉 ПОЗДРАВЛЯЕМ! + + Вы выиграли 1 место в розыгрыше "Новогодний розыгрыш"! + 🏆 Приз: iPhone 15 + + 📋 Ваш выигрышный счет: 11-22-33-44-55-66-77 + + ✅ Для подтверждения выигрыша: + 1. Свяжитесь с администратором @admin_username + 2. Сообщите ваш код верификации: AB12CD34 + 3. Администратор подтвердит ваш выигрыш в системе + + ⏰ Срок подтверждения: 24 часа + ``` + +### 4. Верификация выигрыша администратором + +**Сценарий:** +1. Победитель связывается с админом в личных сообщениях +2. Сообщает код верификации: `AB12CD34` +3. Админ проверяет код в боте: + ``` + /verify_winner AB12CD34 1 + ``` + где `1` - ID розыгрыша + +4. Система: + - Находит пользователя по `verification_code = "AB12CD34"` + - Проверяет, что у него есть выигрыш в розыгрыше #1 + - Помечает `winner.is_claimed = True` + - Обновляет `verification.is_verified = True` + +5. Победителю отправляется: + ``` + ✅ Ваш выигрыш подтвержден! + + Администратор свяжется с вами для получения приза. + ``` + +## Дополнительные механизмы безопасности + +### 1. Двухфакторная верификация (опционально) +Можно добавить отправку одноразового кода на телефон победителя. + +### 2. Ограничение по времени +Токены верификации действуют 24 часа. После истечения срока требуется повторная генерация. + +### 3. Логирование +Все действия с выигрышами логируются: +- Создание выигрыша +- Отправка уведомления +- Подтверждение администратором +- Получение приза + +## API для админа + +### Команды бота + +#### `/add_account ` +Добавить счет к профилю клиента +``` +/add_account 2223 11-22-33-44-55-66-77 +``` + +#### `/remove_account ` +Деактивировать счет +``` +/remove_account 11-22-33-44-55-66-77 +``` + +#### `/verify_winner ` +Подтвердить выигрыш победителя +``` +/verify_winner AB12CD34 1 +``` + +#### `/winner_status ` +Показать статус всех победителей розыгрыша +``` +/winner_status 1 + +Результаты: +1 место - @username (КК: 2223) ✅ Подтвержден +2 место - Счет: 33-44-55-66-77-88-99 ⏳ Ожидает подтверждения +3 место - @user2 (КК: 4445) ❌ Не подтвержден (истек срок) +``` + +## Аналитика и отчеты + +### Запросы для аналитики + +```sql +-- Победители по клубным картам +SELECT + u.club_card_number, + u.first_name, + COUNT(w.id) as total_wins, + SUM(CASE WHEN w.is_claimed THEN 1 ELSE 0 END) as claimed_wins +FROM users u +JOIN winners w ON w.user_id = u.id +GROUP BY u.id; + +-- Счета с наибольшим количеством участий +SELECT + a.account_number, + u.club_card_number, + COUNT(p.id) as participation_count +FROM accounts a +JOIN users u ON u.id = a.owner_id +JOIN participations p ON p.account_id = a.id +GROUP BY a.id, u.id +ORDER BY participation_count DESC; + +-- Невостребованные выигрыши +SELECT + l.title, + w.place, + w.prize, + w.account_number, + u.club_card_number +FROM winners w +JOIN lotteries l ON l.id = w.lottery_id +LEFT JOIN users u ON u.id = w.user_id +WHERE w.is_claimed = false + AND w.created_at < NOW() - INTERVAL '24 hours'; +``` + +## Миграция существующих данных + +Если у вас уже есть данные в старой схеме: + +```python +# Миграция старых account_number из users в таблицу accounts +async def migrate_accounts(): + users = await session.execute(select(User).where(User.account_number != None)) + for user in users.scalars(): + account = Account( + account_number=user.account_number, + owner_id=user.id, + is_active=True + ) + session.add(account) + user.account_number = None # Очищаем старое поле + await session.commit() +``` + +## Тестирование + +### Сценарий 1: Полный цикл выигрыша + +1. Регистрация пользователя (КК: 2223) +2. Админ добавляет счет: `/add_account 2223 11-22-33-44-55-66-77` +3. Счет участвует в розыгрыше +4. Счет выигрывает 1 место +5. Пользователю приходит уведомление с кодом +6. Пользователь связывается с админом, сообщает код +7. Админ подтверждает: `/verify_winner AB12CD34 1` +8. Выигрыш получен ✅ + +### Сценарий 2: Выигрыш незарегистрированного счета + +1. Админ добавляет счет без владельца +2. Счет участвует и выигрывает +3. Бот показывает в публичном объявлении: "Счет 11-22-33-44-55-66-77 выиграл!" +4. Владелец счета регистрируется в боте +5. Админ привязывает счет к владельцу +6. Дальше стандартная процедура верификации diff --git a/docs/UPDATE_LOG.md b/docs/UPDATE_LOG.md new file mode 100644 index 0000000..07fb733 --- /dev/null +++ b/docs/UPDATE_LOG.md @@ -0,0 +1,424 @@ +# Обновления системы - 16.11.2025 + +## ✅ Исправленные ошибки + +### 1. Ошибка "User object has no attribute 'account_number'" +**Проблема:** В старой системе поле `account_number` было в таблице `users`, но после миграции оно перенесено в отдельную таблицу `accounts`. + +**Решение:** +- Обновлена функция `show_my_account()` в `main.py` +- Теперь использует `AccountService.get_user_accounts()` для получения всех счетов пользователя +- Показывает список всех счетов с их статусом (активен/неактивен) + +**Новый функционал в "Мой счет":** +``` +💳 Ваши счета + +🎫 Клубная карта: 2223 +🔑 Код верификации: AB12CD34 + +Счета (2): + +1. 11-22-33-44-55-66-77 + ✅ Активен + +2. 88-99-00-11-22-33-44 + ✅ Активен +``` + +--- + +## 🆕 Новый функционал + +### 1. Улучшенная команда `/add_account` + +#### Режим 1: Быстрое добавление одного счета +``` +/add_account 2223 11-22-33-44-55-66-77 +``` + +#### Режим 2: Интерактивное добавление (один или несколько) +``` +/add_account +``` + +Затем отправьте данные: + +**Один счет:** +``` +2223 11-22-33-44-55-66-77 +``` + +**Несколько счетов (пакетное добавление):** +``` +2223 11-22-33-44-55-66-77 +2223 88-99-00-11-22-33-44 +3334 12-34-56-78-90-12-34 +5556 99-88-77-66-55-44-33 +``` + +**Формат:** `клубная_карта номер_счета` (через пробел, каждый счет с новой строки) + +--- + +### 2. Автоматическое добавление счетов в розыгрыш + +После успешного добавления счетов система **автоматически предлагает** добавить их в активный розыгрыш: + +``` +✅ Счет успешно добавлен! + +🎫 Клубная карта: 2223 +💳 Счет: 11-22-33-44-55-66-77 +👤 Владелец: Иван + +📨 Владельцу отправлено уведомление + +➕ Добавить счета в розыгрыш? + +Выберите розыгрыш из списка: +[🎯 Новогодний розыгрыш] +[🎯 Летний розыгрыш] +[❌ Пропустить] +``` + +**Процесс:** +1. Администратор добавляет счета командой `/add_account` +2. Система показывает список активных розыгрышей +3. Администратор выбирает розыгрыш или пропускает +4. Счета автоматически добавляются в выбранный розыгрыш + +**Преимущества:** +- ✅ Экономия времени - не нужно вручную добавлять каждый счет +- ✅ Меньше ошибок - система проверяет дубликаты +- ✅ Удобство - всё в одном процессе +- ✅ Гибкость - можно пропустить добавление + +--- + +### 3. Пакетное добавление счетов + +**Сценарий использования:** +У вас есть список новых клиентов с их счетами. Вместо добавления каждого по отдельности: + +``` +/add_account + +2223 11-22-33-44-55-66-77 +2223 88-99-00-11-22-33-44 +3334 12-34-56-78-90-12-34 +3334 99-88-77-66-55-44-33 +5556 11-11-11-11-11-11-11 +``` + +**Результат:** +``` +📊 Результаты добавления счетов + +✅ Успешно добавлено: 5 + +• 2223 → 11-22-33-44-55-66-77 + 👤 Иван +• 2223 → 88-99-00-11-22-33-44 + 👤 Иван +• 3334 → 12-34-56-78-90-12-34 + 👤 Петр +• 3334 → 99-88-77-66-55-44-33 + 👤 Петр +• 5556 → 11-11-11-11-11-11-11 + 👤 Мария + +➕ Добавить счета в розыгрыш? +``` + +**Обработка ошибок:** +Если какие-то счета невалидны, система покажет: +``` +❌ Ошибки: 2 + +• Строка 3 (9999 123-456): Пользователь с клубной картой 9999 не найден +• Строка 5 (2223 11-22-33): Неверный формат номера счета +``` + +Остальные счета будут добавлены успешно. + +--- + +## 🔄 Обновленный процесс работы + +### Полный цикл: Регистрация → Счета → Розыгрыш + +**Шаг 1: Клиент регистрируется** +``` +Клиент: /start +Клиент: Нажимает "Зарегистрироваться" +Клиент: Вводит клубную карту "2223" +Клиент: Вводит телефон или пропускает +Система: Показывает код верификации "AB12CD34" +``` + +**Шаг 2: Администратор добавляет счета** +``` +Админ: /add_account + +2223 11-22-33-44-55-66-77 +2223 88-99-00-11-22-33-44 + +Система: Добавляет оба счета +Система: Отправляет уведомления владельцу +Система: Предлагает добавить в розыгрыш +``` + +**Шаг 3: Администратор выбирает розыгрыш** +``` +Админ: Нажимает "🎯 Новогодний розыгрыш" + +Система: Добавляет оба счета в розыгрыш +Система: Показывает результат: + ✅ Добавлено счетов: 2 +``` + +**Шаг 4: Клиент проверяет свои счета** +``` +Клиент: /start → "Мой счет" + +Видит: +💳 Ваши счета +🎫 Клубная карта: 2223 +🔑 Код верификации: AB12CD34 + +Счета (2): +1. 11-22-33-44-55-66-77 ✅ Активен +2. 88-99-00-11-22-33-44 ✅ Активен +``` + +**Шаг 5: Проведение розыгрыша** +``` +Админ: Нажимает "Провести розыгрыш" + +Система: Выбирает победителей +Система: Находит владельцев счетов-победителей +Система: Отправляет уведомления с кодами верификации +``` + +**Шаг 6: Подтверждение выигрыша** +``` +Победитель: Связывается с админом, называет код "AB12CD34" + +Админ: /verify_winner AB12CD34 1 + +Система: Подтверждает выигрыш +Система: Отправляет уведомление победителю +``` + +--- + +## 📝 Примеры использования + +### Пример 1: Добавление счетов для одного пользователя +``` +/add_account + +2223 11-22-33-44-55-66-77 +2223 22-33-44-55-66-77-88 +2223 33-44-55-66-77-88-99 +``` + +Результат: Все 3 счета добавлены пользователю с клубной картой 2223 + +### Пример 2: Добавление счетов для разных пользователей +``` +/add_account + +2223 11-22-33-44-55-66-77 +3334 22-33-44-55-66-77-88 +5556 33-44-55-66-77-88-99 +7778 44-55-66-77-88-99-00 +``` + +Результат: Каждый счет добавлен соответствующему пользователю + +### Пример 3: Быстрое добавление с немедленным участием +``` +/add_account 2223 11-22-33-44-55-66-77 +[Выбрать розыгрыш] +[Нажать "🎯 Новогодний розыгрыш"] +``` + +Результат: Счет добавлен и сразу участвует в розыгрыше + +--- + +## 🛠️ Технические детали + +### Новая FSM структура в admin_account_handlers.py + +```python +class AddAccountStates(StatesGroup): + waiting_for_data = State() # Ожидание данных счетов + choosing_lottery = State() # Выбор розыгрыша +``` + +### Новые callback handlers + +- `add_to_lottery_{lottery_id}` - Добавить счета в розыгрыш +- `skip_lottery_add` - Пропустить добавление в розыгрыш + +### Обновленные функции + +1. **add_account_command()** - Точка входа, поддерживает оба режима +2. **process_single_account()** - Обработка одного счета из команды +3. **process_accounts_data()** - Обработка пакета счетов +4. **show_lottery_selection()** - Показать выбор розыгрыша +5. **add_accounts_to_lottery()** - Добавить счета в выбранный розыгрыш + +### Проверки безопасности + +- ✅ Проверка существования пользователя по клубной карте +- ✅ Проверка уникальности номера счета +- ✅ Проверка формата номера (XX-XX-XX-XX-XX-XX-XX) +- ✅ Проверка дубликатов при добавлении в розыгрыш +- ✅ Проверка прав администратора + +--- + +## 📊 Сравнение: До и После + +### Старый процесс (до обновления): +``` +1. /add_account 2223 11-22-33-44-55-66-77 +2. [Ждем подтверждения] +3. Вручную находим розыгрыш +4. Вручную добавляем счет в розыгрыш +5. Повторяем для каждого счета +``` + +**Время на 10 счетов:** ~5-7 минут + +### Новый процесс (после обновления): +``` +1. /add_account +2. Вставляем 10 строк с данными +3. Выбираем розыгрыш из списка +4. Готово! +``` + +**Время на 10 счетов:** ~30 секунд + +**Экономия времени: 90%!** + +--- + +## 🔍 Отладка и решение проблем + +### Проблема: Не показываются розыгрыши после добавления счетов + +**Причина:** Нет активных розыгрышей + +**Решение:** Создайте активный розыгрыш через админ-панель + +### Проблема: Ошибка "Пользователь не найден" + +**Причина:** Клубная карта не зарегистрирована + +**Решение:** +1. Попросите пользователя зарегистрироваться через /start +2. Или проверьте правильность клубной карты через /user_info + +### Проблема: "Счет уже существует" + +**Причина:** Этот номер счета уже привязан к другому пользователю + +**Решение:** +1. Проверьте номер счета +2. Если ошибка - деактивируйте старый: /remove_account <номер> +3. Добавьте заново + +### Проблема: Счет не добавился в розыгрыш + +**Причина:** Счет уже участвует в этом розыгрыше + +**Решение:** Это нормально, система предотвращает дубликаты + +--- + +## 📚 Команды для администратора (обновленный список) + +### Управление счетами: +- `/add_account` - Добавить счета (интерактивно или пакетом) +- `/add_account ` - Быстрое добавление одного счета +- `/remove_account ` - Деактивировать счет +- `/user_info ` - Информация о пользователе + +### Управление розыгрышами: +- Создание через интерфейс (кнопка "Создать розыгрыш") +- Проведение через интерфейс (кнопка "Провести розыгрыш") +- `/winner_status ` - Статус победителей + +### Верификация: +- `/verify_winner ` - Подтвердить выигрыш + +--- + +## 🎯 Рекомендации по использованию + +### Для массового добавления счетов: + +1. **Подготовьте данные в текстовом файле** + ``` + 2223 11-22-33-44-55-66-77 + 2223 88-99-00-11-22-33-44 + 3334 12-34-56-78-90-12-34 + ... + ``` + +2. **Скопируйте и вставьте в бота** + ``` + /add_account + [Вставить все строки] + ``` + +3. **Выберите розыгрыш** + +4. **Проверьте результаты** + +### Для одиночных счетов: + +Используйте быстрый режим: +``` +/add_account 2223 11-22-33-44-55-66-77 +``` + +### Для новых пользователей: + +1. Сначала попросите зарегистрироваться +2. Затем добавьте счета +3. Счета автоматически будут доступны в розыгрышах + +--- + +## ✅ Чек-лист для администратора + +При добавлении новых пользователей: + +- [ ] Пользователь прошел регистрацию (/start → Зарегистрироваться) +- [ ] Пользователь получил код верификации +- [ ] Добавлены все счета через /add_account +- [ ] Счета добавлены в активный розыгрыш +- [ ] Пользователю отправлены уведомления +- [ ] Проверено через /user_info + +При проведении розыгрыша: + +- [ ] Все счета добавлены и активны +- [ ] Розыгрыш настроен (призы, описание) +- [ ] Нажата кнопка "Провести розыгрыш" +- [ ] Победители получили уведомления +- [ ] Проверен статус через /winner_status + +При подтверждении выигрышей: + +- [ ] Победитель сообщил код верификации +- [ ] Код проверен командой /verify_winner +- [ ] Победитель получил подтверждение +- [ ] Приз передан победителю diff --git a/main.py b/main.py index a6328d7..aba7d52 100644 --- a/main.py +++ b/main.py @@ -16,8 +16,12 @@ import sys from src.core.config import BOT_TOKEN, ADMIN_IDS from src.core.database import async_session_maker, init_db from src.core.services import UserService, LotteryService, ParticipationService +from src.core.models import User from src.handlers.admin_panel import admin_router from src.handlers.account_handlers import account_router +from src.handlers.registration_handlers import router as registration_router +from src.handlers.admin_account_handlers import router as admin_account_router +from src.handlers.redraw_handlers import router as redraw_router from src.utils.async_decorators import ( async_user_action, admin_async_action, db_operation, TaskManagerMiddleware, shutdown_task_manager, @@ -65,16 +69,19 @@ def is_admin(user_id: int) -> bool: def get_main_keyboard(is_admin_user: bool = False) -> InlineKeyboardMarkup: """Главная клавиатура""" buttons = [ - [InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="list_lotteries")], - [InlineKeyboardButton(text="📝 Мои участия", callback_data="my_participations")], - [InlineKeyboardButton(text="💳 Мой счёт", callback_data="my_account")] + [InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="list_lotteries")] ] + if not is_admin_user: + buttons.extend([ + [InlineKeyboardButton(text="📝 Мои участия", callback_data="my_participations")], + [InlineKeyboardButton(text="💳 Мой счёт", callback_data="my_account")] + ]) + if is_admin_user: buttons.extend([ [InlineKeyboardButton(text="🔧 Админ-панель", callback_data="admin_panel")], [InlineKeyboardButton(text="➕ Создать розыгрыш", callback_data="create_lottery")], - [InlineKeyboardButton(text="👑 Установить победителя", callback_data="set_winner")], [InlineKeyboardButton(text="📊 Статистика задач", callback_data="task_stats")] ]) @@ -96,11 +103,29 @@ async def cmd_start(message: Message): # Устанавливаем права администратора, если пользователь в списке if message.from_user.id in ADMIN_IDS: await UserService.set_admin(session, message.from_user.id, True) + + is_registered = user.is_registered is_admin_user = is_admin(message.from_user.id) welcome_text = f"Добро пожаловать, {message.from_user.first_name}! 🎉\n\n" welcome_text += "Это бот для проведения розыгрышей.\n\n" + + # Для обычных пользователей - проверяем регистрацию + if not is_admin_user and not is_registered: + welcome_text += "⚠️ Для участия в розыгрышах необходимо пройти регистрацию.\n\n" + + buttons = [ + [InlineKeyboardButton(text="📝 Зарегистрироваться", callback_data="start_registration")], + [InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="list_lotteries")] + ] + + await message.answer( + welcome_text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons) + ) + return + welcome_text += "Выберите действие из меню ниже:" if is_admin_user: @@ -196,9 +221,18 @@ async def show_lottery_details(callback: CallbackQuery): if winners: text += "\n\n🏆 Победители:\n" for winner in winners: - # Используем новую систему отображения - winner_display = format_winner_display(winner.user, lottery, show_sensitive_data=False) - text += f"{winner.place}. {winner_display}\n" + # Безопасное отображение победителя + if winner.user: + if winner.user.username: + winner_display = f"@{winner.user.username}" + else: + winner_display = f"{winner.user.first_name}" + elif winner.account_number: + winner_display = f"Счет: {winner.account_number}" + else: + winner_display = "Участник" + + text += f"{winner.place}. {winner_display} - {winner.prize}\n" else: text += f"\n🟢 Статус: {'Активен' if lottery.is_active else 'Неактивен'}" if is_participating: @@ -241,7 +275,8 @@ async def join_lottery(callback: CallbackQuery): await callback.answer("Ошибка получения данных пользователя", show_alert=True) return - success = await LotteryService.add_participant(session, lottery_id, user.id) + # Используем правильный метод ParticipationService + success = await ParticipationService.add_participant(session, lottery_id, user.id) if success: await callback.answer("✅ Вы успешно присоединились к розыгрышу!", show_alert=True) @@ -252,6 +287,237 @@ async def join_lottery(callback: CallbackQuery): await show_lottery_details(callback) +async def notify_winners_async(bot: Bot, lottery_id: int, results: dict): + """ + Асинхронно отправить уведомления победителям с кнопкой подтверждения + Вызывается после проведения розыгрыша + """ + async with async_session_maker() as session: + from src.core.registration_services import AccountService, WinnerNotificationService + from src.core.models import Winner + from sqlalchemy import select + + # Получаем информацию о розыгрыше + lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + return + + # Получаем всех победителей из БД + winners_result = await session.execute( + select(Winner).where(Winner.lottery_id == lottery_id) + ) + winners = winners_result.scalars().all() + + for winner in winners: + try: + # Если у победителя есть account_number, ищем владельца + if winner.account_number: + owner = await AccountService.get_account_owner(session, winner.account_number) + + if owner and owner.telegram_id: + # Создаем токен верификации + verification = await WinnerNotificationService.create_verification_token( + session, + winner.id + ) + + # Формируем сообщение с кнопкой подтверждения + message = ( + f"🎉 **Поздравляем! Ваш счет выиграл!**\n\n" + f"🎯 Розыгрыш: {lottery.title}\n" + f"🏆 Место: {winner.place}\n" + f"🎁 Приз: {winner.prize}\n" + f"💳 **Выигрышный счет: {winner.account_number}**\n\n" + f"⏰ **У вас есть 24 часа для подтверждения!**\n\n" + f"Нажмите кнопку ниже, чтобы подтвердить получение приза по этому счету.\n" + f"Если вы не подтвердите в течение 24 часов, " + f"приз будет разыгран заново.\n\n" + f"ℹ️ Если у вас несколько выигрышных счетов, " + f"подтвердите каждый из них отдельно." + ) + + # Создаем кнопку подтверждения с указанием счета + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=f"✅ Подтвердить счет {winner.account_number}", + callback_data=f"confirm_win_{winner.id}" + )], + [InlineKeyboardButton( + text="📞 Связаться с администратором", + url=f"tg://user?id={ADMIN_IDS[0]}" + )] + ]) + + # Отправляем уведомление с кнопкой + await bot.send_message( + owner.telegram_id, + message, + reply_markup=keyboard, + parse_mode="Markdown" + ) + + # Отмечаем, что уведомление отправлено + winner.is_notified = True + await session.commit() + + logger.info(f"Отправлено уведомление победителю {owner.telegram_id} за счет {winner.account_number}") + + # Если победитель - обычный пользователь (старая система) + elif 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 and user.telegram_id: + message = ( + f"🎉 Поздравляем! Вы выиграли!\n\n" + f"🎯 Розыгрыш: {lottery.title}\n" + f"🏆 Место: {winner.place}\n" + f"🎁 Приз: {winner.prize}\n\n" + f"⏰ **У вас есть 24 часа для подтверждения!**\n\n" + f"Нажмите кнопку ниже, чтобы подтвердить получение приза." + ) + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text="✅ Подтвердить получение приза", + callback_data=f"confirm_win_{winner.id}" + )] + ]) + + await bot.send_message( + user.telegram_id, + message, + reply_markup=keyboard, + parse_mode="Markdown" + ) + winner.is_notified = True + await session.commit() + + logger.info(f"Отправлено уведомление победителю {user.telegram_id}") + + except Exception as e: + logger.error(f"Ошибка при отправке уведомления победителю: {e}") + + +@router.callback_query(F.data.startswith("confirm_win_")) +async def confirm_winner_response(callback: CallbackQuery): + """Обработка подтверждения выигрыша победителем""" + winner_id = int(callback.data.split("_")[2]) + + async with async_session_maker() as session: + from src.core.models import Winner + from sqlalchemy import select + from sqlalchemy.orm import joinedload + + # Получаем выигрыш с загрузкой связанного розыгрыша + winner_result = await session.execute( + select(Winner) + .options(joinedload(Winner.lottery)) + .where(Winner.id == winner_id) + ) + winner = winner_result.scalar_one_or_none() + + if not winner: + await callback.answer("❌ Выигрыш не найден", show_alert=True) + return + + # Проверяем, не подтвержден ли уже этот конкретный счет + if winner.is_claimed: + await callback.message.edit_text( + "✅ **Выигрыш этого счета уже подтвержден!**\n\n" + f"🎯 Розыгрыш: {winner.lottery.title}\n" + f"🏆 Место: {winner.place}\n" + f"🎁 Приз: {winner.prize}\n" + f"💳 Счет: {winner.account_number}\n\n" + "Администратор свяжется с вами для передачи приза.", + parse_mode="Markdown" + ) + return + + # Проверяем, что подтверждает владелец именно ЭТОГО счета + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + + if winner.account_number: + # Проверяем что счет принадлежит текущему пользователю + from src.core.registration_services import AccountService + owner = await AccountService.get_account_owner(session, winner.account_number) + + if not owner or owner.telegram_id != callback.from_user.id: + await callback.answer( + f"❌ Счет {winner.account_number} вам не принадлежит", + show_alert=True + ) + return + elif winner.user_id: + # Старая логика для выигрышей без счета + if not user or user.id != winner.user_id: + await callback.answer("❌ Это не ваш выигрыш", show_alert=True) + return + + # Подтверждаем выигрыш ЭТОГО конкретного счета + from datetime import datetime, timezone + winner.is_claimed = True + winner.claimed_at = datetime.now(timezone.utc) + await session.commit() + + # Обновляем сообщение с указанием счета + confirmation_text = ( + "✅ **Выигрыш успешно подтвержден!**\n\n" + f"🎯 Розыгрыш: {winner.lottery.title}\n" + f"🏆 Место: {winner.place}\n" + f"🎁 Приз: {winner.prize}\n" + ) + + if winner.account_number: + confirmation_text += f"💳 Счет: {winner.account_number}\n" + + confirmation_text += ( + "\n🎊 Поздравляем! Администратор свяжется с вами " + "для передачи приза в ближайшее время.\n\n" + "Спасибо за участие!" + ) + + await callback.message.edit_text( + confirmation_text, + parse_mode="Markdown" + ) + + # Уведомляем администраторов о подтверждении конкретного счета + for admin_id in ADMIN_IDS: + try: + admin_msg = ( + f"✅ **Победитель подтвердил получение приза!**\n\n" + f"🎯 Розыгрыш: {winner.lottery.title}\n" + f"🏆 Место: {winner.place}\n" + f"🎁 Приз: {winner.prize}\n" + ) + + # Обязательно показываем счет + if winner.account_number: + admin_msg += f"� **Подтвержденный счет: {winner.account_number}**\n\n" + + if user: + admin_msg += f"👤 Владелец: {user.first_name}" + if user.username: + admin_msg += f" (@{user.username})" + admin_msg += f"\n🎫 Клубная карта: {user.club_card_number}\n" + if user.phone: + admin_msg += f"📱 Телефон: {user.phone}\n" + + await callback.bot.send_message(admin_id, admin_msg, parse_mode="Markdown") + except: + pass + + logger.info( + f"Победитель {callback.from_user.id} подтвердил выигрыш {winner_id} " + f"(счет: {winner.account_number})" + ) + + await callback.answer("✅ Выигрыш подтвержден!", show_alert=True) + + @router.callback_query(F.data.startswith("conduct_")) async def conduct_lottery(callback: CallbackQuery): """Провести розыгрыш""" @@ -262,6 +528,11 @@ async def conduct_lottery(callback: CallbackQuery): lottery_id = int(callback.data.split("_")[1]) async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await callback.answer("❌ Розыгрыш не найден", show_alert=True) + return + results = await LotteryService.conduct_draw(session, lottery_id) if not results: @@ -271,14 +542,26 @@ async def conduct_lottery(callback: CallbackQuery): text = "🎉 Розыгрыш завершен!\n\n🏆 Победители:\n\n" for place, winner_info in results.items(): - user = winner_info['user'] + user_obj = winner_info['user'] prize = winner_info['prize'] - # Используем новую систему отображения - winner_display = format_winner_display(user, lottery, show_sensitive_data=False) + # Безопасное отображение победителя + if hasattr(user_obj, 'username') and user_obj.username: + winner_display = f"@{user_obj.username}" + elif hasattr(user_obj, 'first_name'): + winner_display = f"{user_obj.first_name}" + elif hasattr(user_obj, 'account_number'): + winner_display = f"Счет: {user_obj.account_number}" + else: + winner_display = "Участник" + text += f"{place}. {winner_display}\n" text += f" 🎁 {prize}\n\n" + # Отправляем уведомления победителям асинхронно + asyncio.create_task(notify_winners_async(callback.bot, lottery_id, results)) + text += "📨 Уведомления отправляются победителям...\n" + await callback.message.edit_text( text, reply_markup=InlineKeyboardMarkup(inline_keyboard=[ @@ -339,6 +622,11 @@ async def process_lottery_prizes(message: Message, state: FSMContext): async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + if not user: + await message.answer("❌ Ошибка получения данных пользователя") + await state.clear() + return + data = await state.get_data() lottery = await LotteryService.create_lottery( session, @@ -529,7 +817,7 @@ async def show_my_participations(callback: CallbackQuery): @router.callback_query(F.data == "my_account") @db_operation() async def show_my_account(callback: CallbackQuery): - """Показать информацию о счёте пользователя""" + """Показать информацию о счетах пользователя""" async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) @@ -537,27 +825,44 @@ async def show_my_account(callback: CallbackQuery): await callback.answer("Пользователь не найден", show_alert=True) return - text = "💳 **Ваш клиентский счёт**\n\n" + # Проверяем регистрацию + if not user.is_registered: + text = "❌ **Вы не зарегистрированы**\n\n" + text += "Пройдите регистрацию для доступа к счетам" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📝 Зарегистрироваться", callback_data="start_registration")], + [InlineKeyboardButton(text="🔙 Главное меню", callback_data="back_to_main")] + ]), + parse_mode="Markdown" + ) + return - if user.account_number: - # Показываем маскированный номер для безопасности - from src.utils.account_utils import mask_account_number - masked = mask_account_number(user.account_number, show_last_digits=6) - text += f"📋 Номер счёта: `{masked}`\n" - text += f"✅ Статус: Активен\n\n" - text += "ℹ️ Счёт используется для идентификации в розыгрышах" + # Получаем счета пользователя + from src.core.registration_services import AccountService + accounts = await AccountService.get_user_accounts(session, user.id) + + text = "💳 **Ваши счета**\n\n" + + if accounts: + text += f"🎫 Клубная карта: `{user.club_card_number}`\n" + text += f"� Код верификации: `{user.verification_code}`\n\n" + text += f"**Счета ({len(accounts)}):**\n\n" + + for i, acc in enumerate(accounts, 1): + status = "✅ Активен" if acc.is_active else "❌ Неактивен" + text += f"{i}. `{acc.account_number}`\n" + text += f" {status}\n\n" + + text += "ℹ️ Счета используются для участия в розыгрышах" else: - text += "❌ Счёт не привязан\n\n" - text += "Привяжите счёт для участия в розыгрышах" + text += f"🎫 Клубная карта: `{user.club_card_number}`\n\n" + text += "❌ У вас нет счетов\n\n" + text += "Обратитесь к администратору для добавления счетов" - buttons = [] - - if user.account_number: - buttons.append([InlineKeyboardButton(text="🔄 Изменить счёт", callback_data="change_account")]) - else: - buttons.append([InlineKeyboardButton(text="➕ Привязать счёт", callback_data="add_account")]) - - buttons.append([InlineKeyboardButton(text="🔙 Главное меню", callback_data="back_to_main")]) + buttons = [[InlineKeyboardButton(text="🔙 Главное меню", callback_data="back_to_main")]] await callback.message.edit_text( text, @@ -700,7 +1005,10 @@ async def main(): await set_commands() # Подключение роутеров - dp.include_router(account_router) # Роутер для работы со счетами (приоритетный) + dp.include_router(registration_router) # Роутер регистрации (первый) + dp.include_router(admin_account_router) # Роутер админских команд для счетов + dp.include_router(redraw_router) # Роутер повторного розыгрыша + dp.include_router(account_router) # Роутер для работы со счетами dp.include_router(router) dp.include_router(admin_router) diff --git a/migrations/versions/init_tables.py b/migrations/versions/001_init_tables.py similarity index 100% rename from migrations/versions/init_tables.py rename to migrations/versions/001_init_tables.py diff --git a/migrations/versions/003_add_registration_and_accounts.py b/migrations/versions/003_add_registration_and_accounts.py new file mode 100644 index 0000000..20094c8 --- /dev/null +++ b/migrations/versions/003_add_registration_and_accounts.py @@ -0,0 +1,108 @@ +"""Add registration and account management + +Revision ID: 003 +Revises: init +Create Date: 2025-11-16 13:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '003' +down_revision = 'init' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Добавляем новые поля в users + op.add_column('users', sa.Column('phone', sa.String(20), nullable=True)) + op.add_column('users', sa.Column('club_card_number', sa.String(50), nullable=True)) + op.add_column('users', sa.Column('is_registered', sa.Boolean(), server_default='false', nullable=False)) + op.add_column('users', sa.Column('verification_code', sa.String(10), nullable=True)) + + # Создаем индексы для users + op.create_index('ix_users_club_card_number', 'users', ['club_card_number'], unique=True) + op.create_index('ix_users_verification_code', 'users', ['verification_code'], unique=True) + + # Удаляем старое поле account_number из users (оно теперь в отдельной таблице) + # Сначала удаляем индекс + op.drop_index('ix_users_account_number', table_name='users') + op.drop_column('users', 'account_number') + + # Создаем таблицу accounts + op.create_table( + 'accounts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('account_number', sa.String(20), nullable=False), + sa.Column('owner_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_accounts_account_number', 'accounts', ['account_number'], unique=True) + op.create_index('ix_accounts_owner_id', 'accounts', ['owner_id']) + + # Добавляем поле account_id в participations + op.add_column('participations', sa.Column('account_id', sa.Integer(), nullable=True)) + op.create_foreign_key( + 'fk_participations_account_id', + 'participations', 'accounts', + ['account_id'], ['id'], + ondelete='SET NULL' + ) + + # Добавляем поля в winners для отслеживания статуса + op.add_column('winners', sa.Column('is_notified', sa.Boolean(), server_default='false', nullable=False)) + op.add_column('winners', sa.Column('is_claimed', sa.Boolean(), server_default='false', nullable=False)) + + # Создаем таблицу winner_verifications + op.create_table( + 'winner_verifications', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('winner_id', sa.Integer(), nullable=False), + sa.Column('verification_token', sa.String(32), nullable=False), + sa.Column('is_verified', sa.Boolean(), server_default='false', nullable=False), + sa.Column('verified_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['winner_id'], ['winners.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_winner_verifications_winner_id', 'winner_verifications', ['winner_id'], unique=True) + op.create_index('ix_winner_verifications_token', 'winner_verifications', ['verification_token'], unique=True) + + +def downgrade() -> None: + # Удаляем winner_verifications + op.drop_index('ix_winner_verifications_token', table_name='winner_verifications') + op.drop_index('ix_winner_verifications_winner_id', table_name='winner_verifications') + op.drop_table('winner_verifications') + + # Удаляем новые поля из winners + op.drop_column('winners', 'is_claimed') + op.drop_column('winners', 'is_notified') + + # Удаляем account_id из participations + op.drop_constraint('fk_participations_account_id', 'participations', type_='foreignkey') + op.drop_column('participations', 'account_id') + + # Удаляем таблицу accounts + op.drop_index('ix_accounts_owner_id', table_name='accounts') + op.drop_index('ix_accounts_account_number', table_name='accounts') + op.drop_table('accounts') + + # Возвращаем account_number в users + op.add_column('users', sa.Column('account_number', sa.String(20), nullable=True)) + op.create_index('ix_users_account_number', 'users', ['account_number'], unique=True) + + # Удаляем новые поля из users + op.drop_index('ix_users_verification_code', table_name='users') + op.drop_index('ix_users_club_card_number', table_name='users') + op.drop_column('users', 'verification_code') + op.drop_column('users', 'is_registered') + op.drop_column('users', 'club_card_number') + op.drop_column('users', 'phone') diff --git a/migrations/versions/004_add_claimed_at.py b/migrations/versions/004_add_claimed_at.py new file mode 100644 index 0000000..f7a1006 --- /dev/null +++ b/migrations/versions/004_add_claimed_at.py @@ -0,0 +1,26 @@ +"""Add claimed_at field to winners + +Revision ID: 004 +Revises: 003 +Create Date: 2025-11-16 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '004' +down_revision = '003' +branch_labels = None +depends_on = None + + +def upgrade(): + """Add claimed_at timestamp to winners table""" + op.add_column('winners', sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=True)) + + +def downgrade(): + """Remove claimed_at field""" + op.drop_column('winners', 'claimed_at') diff --git a/scripts/README_CLEAR_DB.md b/scripts/README_CLEAR_DB.md new file mode 100644 index 0000000..9498e19 --- /dev/null +++ b/scripts/README_CLEAR_DB.md @@ -0,0 +1,227 @@ +# 🗑️ Скрипт очистки базы данных + +## Описание + +`clear_database.py` - скрипт для полной очистки всех данных из базы данных бота. + +## ⚠️ ВНИМАНИЕ! + +**Этот скрипт удаляет ВСЕ данные без возможности восстановления:** +- Всех пользователей +- Все розыгрыши (активные и завершенные) +- Все счета участников +- Все записи об участии +- Всех победителей +- Все записи верификации + +## Использование + +### Через Makefile (рекомендуется) + +```bash +make clear-db +``` + +Система попросит подтверждение: +``` +⚠️ ВНИМАНИЕ! Это удалит ВСЕ данные из базы данных! + - Все пользователи + - Все розыгрыши + - Все счета + - Все участия + - Всех победителей + +Вы уверены? Введите 'yes' для подтверждения: +``` + +### Напрямую через Python + +```bash +cd /home/trevor/new_lottery_bot +source .venv/bin/activate +python scripts/clear_database.py +``` + +## Что делает скрипт + +1. **Подключается к базе данных** используя `DATABASE_URL` из `.env` + +2. **Удаляет данные в правильном порядке:** + - `winner_verifications` - записи верификации победителей + - `winners` - записи о победителях + - `participations` - записи об участии в розыгрышах + - `accounts` - счета участников + - `lotteries` - розыгрыши + - `users` - пользователи + +3. **Сбрасывает счетчики ID (sequences):** + - `users_id_seq` → 1 + - `lotteries_id_seq` → 1 + - `accounts_id_seq` → 1 + - `participations_id_seq` → 1 + - `winners_id_seq` → 1 + - `winner_verifications_id_seq` → 1 + +4. **Фиксирует транзакцию** (COMMIT) + +5. **Закрывает соединение** + +## Вывод скрипта + +Пример успешного выполнения: + +``` +INFO:__main__:🔌 Подключение к базе данных... + +INFO:__main__:🗑️ Удаление данных из таблиц... +INFO:__main__:------------------------------------------------------------ +INFO:__main__:✅ winner_verifications - удалено 0 строк +INFO:__main__:✅ winners - удалено 2 строк +INFO:__main__:✅ participations - удалено 1 строк +INFO:__main__:✅ accounts - удалено 2 строк +INFO:__main__:✅ lotteries - удалено 1 строк +INFO:__main__:✅ users - удалено 2 строк +INFO:__main__:------------------------------------------------------------ + +INFO:__main__:🔄 Сброс последовательностей... +INFO:__main__:------------------------------------------------------------ +INFO:__main__:✅ users_id_seq +INFO:__main__:✅ lotteries_id_seq +INFO:__main__:✅ accounts_id_seq +INFO:__main__:✅ participations_id_seq +INFO:__main__:✅ winners_id_seq +INFO:__main__:✅ winner_verifications_id_seq +INFO:__main__:------------------------------------------------------------ + +INFO:__main__:============================================================ +INFO:__main__:🎉 ВСЕ ДАННЫЕ УСПЕШНО УДАЛЕНЫ ИЗ БАЗЫ ДАННЫХ! +INFO:__main__:============================================================ + +INFO:__main__:Следующие ID начнутся с 1: +INFO:__main__: - users.id +INFO:__main__: - lotteries.id +INFO:__main__: - accounts.id +INFO:__main__: - participations.id +INFO:__main__: - winners.id +INFO:__main__: - winner_verifications.id + +INFO:__main__:✅ Соединение с БД закрыто +``` + +## Когда использовать + +### ✅ Безопасные сценарии: + +- **Разработка/тестирование**: Очистка тестовых данных +- **Staging окружение**: Подготовка к тестированию +- **Миграция**: Перед полным переносом данных +- **Сброс демо**: Возврат к чистому состоянию + +### ❌ НЕ использовать: + +- **Production база**: НИКОГДА не запускайте на рабочей БД +- **Активные розыгрыши**: Если есть незавершенные розыгрыши +- **Без бэкапа**: Всегда делайте резервную копию перед очисткой + +## Безопасность + +### Файл читает DATABASE_URL из .env: + +```env +DATABASE_URL=postgresql+asyncpg://user:password@host:port/database +``` + +### Проверка перед запуском: + +```bash +# Проверьте что вы на правильной базе +grep DATABASE_URL .env +``` + +### Создание бэкапа (ОБЯЗАТЕЛЬНО для production): + +```bash +# PostgreSQL +pg_dump -h localhost -U bot_user -d bot_db > backup_$(date +%Y%m%d_%H%M%S).sql + +# Восстановление +psql -h localhost -U bot_user -d bot_db < backup_20251116_140000.sql +``` + +## Альтернативы + +### Мягкая очистка (сохранение структуры): + +```sql +-- Очистить только данные розыгрышей +DELETE FROM winners; +DELETE FROM participations; +DELETE FROM lotteries; +``` + +### Выборочная очистка: + +```sql +-- Удалить только тестовые данные +DELETE FROM users WHERE club_card_number LIKE 'TEST%'; +``` + +## Требования + +- Python 3.12+ +- Установленные зависимости (`pip install -r requirements.txt`) +- Файл `.env` с корректным `DATABASE_URL` +- Права на изменение данных в БД + +## Технические детали + +### Используемые библиотеки: + +```python +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine +``` + +### Асинхронное выполнение: + +```python +async with engine.begin() as conn: + await conn.execute(text('DELETE FROM table_name')) +``` + +### Обработка транзакций: + +- `engine.begin()` - автоматический COMMIT при успехе +- `engine.dispose()` - закрытие всех соединений + +## Отладка + +### Проблемы с подключением: + +```bash +# Проверка доступности БД +psql -h 192.168.0.102 -U bot_user -d bot_db -c "SELECT 1" +``` + +### Проверка прав: + +```sql +-- В psql +SELECT has_database_privilege('bot_user', 'bot_db', 'CONNECT'); +SELECT has_table_privilege('bot_user', 'users', 'DELETE'); +``` + +### Логи: + +Скрипт использует стандартный logging с уровнем INFO. Для отладки: + +```python +logging.basicConfig(level=logging.DEBUG) +``` + +## См. также + +- `docs/ADMIN_GUIDE.md` - Руководство администратора +- `scripts/setup_postgres.sh` - Настройка PostgreSQL +- `migrations/` - Миграции базы данных +- `Makefile` - Все доступные команды diff --git a/scripts/clear_database.py b/scripts/clear_database.py new file mode 100644 index 0000000..47aa1fb --- /dev/null +++ b/scripts/clear_database.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +Скрипт для полной очистки базы данных +Удаляет все данные из всех таблиц и сбрасывает счетчики ID +""" + +import asyncio +import logging +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def clear_database(): + """Очистка всех таблиц и сброс последовательностей""" + + # Читаем DATABASE_URL из .env + with open('.env', 'r') as f: + for line in f: + if line.startswith('DATABASE_URL='): + DATABASE_URL = line.split('=', 1)[1].strip().strip('"').strip("'") + break + + logger.info('🔌 Подключение к базе данных...') + engine = create_async_engine(DATABASE_URL) + + try: + async with engine.begin() as conn: + # Отключаем ограничения внешних ключей + await conn.execute(text('SET CONSTRAINTS ALL DEFERRED')) + + # Очищаем таблицы в правильном порядке (от зависимых к независимым) + tables = [ + 'winner_verifications', + 'winners', + 'participations', + 'accounts', + 'lotteries', + 'users' + ] + + logger.info('') + logger.info('🗑️ Удаление данных из таблиц...') + logger.info('-' * 60) + + for table in tables: + result = await conn.execute(text(f'DELETE FROM {table}')) + logger.info(f'✅ {table:25} - удалено {result.rowcount:5} строк') + + logger.info('-' * 60) + + # Сбрасываем последовательности (auto-increment) + sequences = [ + 'users_id_seq', + 'lotteries_id_seq', + 'accounts_id_seq', + 'participations_id_seq', + 'winners_id_seq', + 'winner_verifications_id_seq' + ] + + logger.info('') + logger.info('🔄 Сброс последовательностей...') + logger.info('-' * 60) + + for seq in sequences: + try: + await conn.execute(text(f'ALTER SEQUENCE {seq} RESTART WITH 1')) + logger.info(f'✅ {seq}') + except Exception as e: + logger.warning(f'⚠️ {seq}: {e}') + + logger.info('-' * 60) + + logger.info('') + logger.info('=' * 60) + logger.info('🎉 ВСЕ ДАННЫЕ УСПЕШНО УДАЛЕНЫ ИЗ БАЗЫ ДАННЫХ!') + logger.info('=' * 60) + logger.info('') + logger.info('Следующие ID начнутся с 1:') + logger.info(' - users.id') + logger.info(' - lotteries.id') + logger.info(' - accounts.id') + logger.info(' - participations.id') + logger.info(' - winners.id') + logger.info(' - winner_verifications.id') + logger.info('') + + finally: + await engine.dispose() + logger.info('✅ Соединение с БД закрыто') + + +if __name__ == '__main__': + asyncio.run(clear_database()) diff --git a/src/core/models.py b/src/core/models.py index 2db6ff4..2b09ec1 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -1,11 +1,12 @@ -from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON +from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON, UniqueConstraint from sqlalchemy.orm import relationship from datetime import datetime, timezone from .database import Base +import secrets class User(Base): - """Модель пользователя""" + """Модель пользователя с регистрацией""" __tablename__ = "users" id = Column(Integer, primary_key=True) @@ -13,16 +14,68 @@ class User(Base): username = Column(String(255)) first_name = Column(String(255)) last_name = Column(String(255)) - created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + phone = Column(String(20), nullable=True) # Телефон для верификации + club_card_number = Column(String(50), unique=True, nullable=True, index=True) # Номер клубной карты + is_registered = Column(Boolean, default=False) # Прошел ли полную регистрацию is_admin = Column(Boolean, default=False) - # Клиентский счет в формате: XX-XX-XX-XX-XX-XX-XX (7 пар цифр через дефис) - account_number = Column(String(20), unique=True, nullable=True, index=True) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + # Секретный код для верификации выигрыша (генерируется при регистрации) + verification_code = Column(String(10), unique=True, nullable=True) # Связи + accounts = relationship("Account", back_populates="owner", cascade="all, delete-orphan") participations = relationship("Participation", back_populates="user") + winners = relationship("Winner", back_populates="user") def __repr__(self): - return f"" + return f"" + + def generate_verification_code(self): + """Генерирует уникальный код верификации""" + self.verification_code = secrets.token_hex(4).upper() # 8-символьный код + + +class Account(Base): + """Модель счета клиента (может быть несколько у одного пользователя)""" + __tablename__ = "accounts" + + id = Column(Integer, primary_key=True) + account_number = Column(String(20), unique=True, nullable=False, index=True) # XX-XX-XX-XX-XX-XX-XX + owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + is_active = Column(Boolean, default=True) # Активен ли счет + + # Связи + owner = relationship("User", back_populates="accounts") + participations = relationship("Participation", back_populates="account") + + def __repr__(self): + return f"" + + +class WinnerVerification(Base): + """Модель верификации победителя""" + __tablename__ = "winner_verifications" + + id = Column(Integer, primary_key=True) + winner_id = Column(Integer, ForeignKey("winners.id"), nullable=False, unique=True) + verification_token = Column(String(32), unique=True, nullable=False) # Токен для подтверждения + is_verified = Column(Boolean, default=False) + verified_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + expires_at = Column(DateTime(timezone=True), nullable=False) # Срок действия токена + + # Связи + winner = relationship("Winner", back_populates="verification") + + def __repr__(self): + return f"" + + @staticmethod + def generate_token(): + """Генерирует уникальный токен верификации""" + return secrets.token_urlsafe(24) class Lottery(Base): @@ -60,14 +113,16 @@ class Participation(Base): __tablename__ = "participations" id = Column(Integer, primary_key=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Опционально + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False) - account_number = Column(String(20), nullable=True, index=True) # Счет участника (XX-XX-XX-XX-XX-XX-XX) + account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True) # Привязка к счету + account_number = Column(String(20), nullable=True, index=True) # Дублируем для быстрого доступа created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) # Связи user = relationship("User", back_populates="participations") lottery = relationship("Lottery", back_populates="participations") + account = relationship("Account", back_populates="participations") def __repr__(self): if self.account_number: @@ -81,16 +136,22 @@ class Winner(Base): id = Column(Integer, primary_key=True) lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False) - user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Опционально - account_number = Column(String(20), nullable=True, index=True) # Счет победителя - place = Column(Integer, nullable=False) # Место (1, 2, 3...) - prize = Column(String(500)) # Описание приза - is_manual = Column(Boolean, default=False) # Был ли установлен вручную + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + account_number = Column(String(20), nullable=True, index=True) + place = Column(Integer, nullable=False) + prize = Column(String(500)) + is_manual = Column(Boolean, default=False) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + # Статус подтверждения выигрыша + is_notified = Column(Boolean, default=False) # Отправлено ли уведомление + is_claimed = Column(Boolean, default=False) # Подтвердил ли победитель + claimed_at = Column(DateTime(timezone=True), nullable=True) # Время подтверждения # Связи - user = relationship("User") + user = relationship("User", back_populates="winners") lottery = relationship("Lottery") + verification = relationship("WinnerVerification", back_populates="winner", uselist=False) def __repr__(self): if self.account_number: diff --git a/src/core/registration_services.py b/src/core/registration_services.py new file mode 100644 index 0000000..4534ab9 --- /dev/null +++ b/src/core/registration_services.py @@ -0,0 +1,287 @@ +"""Сервисы для регистрации, управления счетами и верификации""" +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ +from .models import User, Account, Winner, WinnerVerification +from typing import Optional, List +from datetime import datetime, timezone, timedelta +import secrets + + +class RegistrationService: + """Сервис для регистрации пользователей""" + + @staticmethod + async def register_user( + session: AsyncSession, + telegram_id: int, + club_card_number: str, + phone: Optional[str] = None + ) -> User: + """Зарегистрировать нового пользователя с клубной картой""" + # Проверяем, не занята ли клубная карта + existing = await session.execute( + select(User).where(User.club_card_number == club_card_number) + ) + if existing.scalar_one_or_none(): + raise ValueError(f"Клубная карта {club_card_number} уже зарегистрирована") + + # Находим пользователя по telegram_id + result = await session.execute( + select(User).where(User.telegram_id == telegram_id) + ) + user = result.scalar_one_or_none() + + if not user: + raise ValueError("Пользователь не найден") + + # Обновляем данные пользователя + user.club_card_number = club_card_number + user.phone = phone + user.is_registered = True + user.generate_verification_code() + + await session.commit() + await session.refresh(user) + + return user + + @staticmethod + async def get_user_by_club_card( + session: AsyncSession, + club_card_number: str + ) -> Optional[User]: + """Найти пользователя по номеру клубной карты""" + result = await session.execute( + select(User).where(User.club_card_number == club_card_number) + ) + return result.scalar_one_or_none() + + @staticmethod + async def get_user_by_verification_code( + session: AsyncSession, + verification_code: str + ) -> Optional[User]: + """Найти пользователя по коду верификации""" + result = await session.execute( + select(User).where(User.verification_code == verification_code) + ) + return result.scalar_one_or_none() + + +class AccountService: + """Сервис для управления счетами пользователей""" + + @staticmethod + async def create_account( + session: AsyncSession, + club_card_number: str, + account_number: str + ) -> Account: + """Создать новый счет для пользователя по номеру клубной карты""" + # Находим владельца по клубной карте + user_result = await session.execute( + select(User).where(User.club_card_number == club_card_number) + ) + user = user_result.scalar_one_or_none() + + if not user: + raise ValueError(f"Пользователь с клубной картой {club_card_number} не найден") + + # Проверяем, не существует ли уже такой счет + existing = await session.execute( + select(Account).where(Account.account_number == account_number) + ) + if existing.scalar_one_or_none(): + raise ValueError(f"Счет {account_number} уже существует") + + # Создаем счет + account = Account( + account_number=account_number, + owner_id=user.id, + is_active=True + ) + session.add(account) + await session.commit() + await session.refresh(account) + + return account + + @staticmethod + async def get_account_owner( + session: AsyncSession, + account_number: str + ) -> Optional[User]: + """Найти владельца счета""" + result = await session.execute( + select(Account).where( + and_( + Account.account_number == account_number, + Account.is_active == True + ) + ) + ) + account = result.scalar_one_or_none() + + if not account: + return None + + # Получаем владельца + owner_result = await session.execute( + select(User).where(User.id == account.owner_id) + ) + return owner_result.scalar_one_or_none() + + @staticmethod + async def get_user_accounts( + session: AsyncSession, + user_id: int + ) -> List[Account]: + """Получить все счета пользователя""" + result = await session.execute( + select(Account) + .where(Account.owner_id == user_id) + .order_by(Account.created_at.desc()) + ) + return result.scalars().all() + + @staticmethod + async def deactivate_account( + session: AsyncSession, + account_number: str + ) -> bool: + """Деактивировать счет""" + result = await session.execute( + select(Account).where(Account.account_number == account_number) + ) + account = result.scalar_one_or_none() + + if not account: + return False + + account.is_active = False + await session.commit() + return True + + +class WinnerNotificationService: + """Сервис для уведомления победителей""" + + @staticmethod + async def create_verification_token( + session: AsyncSession, + winner_id: int, + expires_hours: int = 24 + ) -> WinnerVerification: + """Создать токен верификации для победителя""" + # Проверяем, нет ли уже токена + existing = await session.execute( + select(WinnerVerification).where(WinnerVerification.winner_id == winner_id) + ) + verification = existing.scalar_one_or_none() + + if verification: + # Обновляем существующий токен + verification.verification_token = WinnerVerification.generate_token() + verification.created_at = datetime.now(timezone.utc) + verification.expires_at = datetime.now(timezone.utc) + timedelta(hours=expires_hours) + verification.is_verified = False + verification.verified_at = None + else: + # Создаем новый + verification = WinnerVerification( + winner_id=winner_id, + verification_token=WinnerVerification.generate_token(), + created_at=datetime.now(timezone.utc), + expires_at=datetime.now(timezone.utc) + timedelta(hours=expires_hours) + ) + session.add(verification) + + await session.commit() + await session.refresh(verification) + return verification + + @staticmethod + async def verify_winner( + session: AsyncSession, + verification_code: str, + lottery_id: int + ) -> Optional[Winner]: + """Подтвердить выигрыш по коду верификации пользователя""" + # Находим пользователя по коду + user_result = await session.execute( + select(User).where(User.verification_code == verification_code) + ) + user = user_result.scalar_one_or_none() + + if not user: + return None + + # Находим выигрыш этого пользователя в данном розыгрыше + winner_result = await session.execute( + select(Winner).where( + and_( + Winner.user_id == user.id, + Winner.lottery_id == lottery_id, + Winner.is_claimed == False + ) + ) + ) + winner = winner_result.scalar_one_or_none() + + if not winner: + # Проверяем, может быть выигрыш по счету + # Получаем все счета пользователя + accounts_result = await session.execute( + select(Account).where(Account.owner_id == user.id) + ) + accounts = accounts_result.scalars().all() + account_numbers = [acc.account_number for acc in accounts] + + # Ищем выигрыш по любому из счетов + winner_result = await session.execute( + select(Winner).where( + and_( + Winner.account_number.in_(account_numbers), + Winner.lottery_id == lottery_id, + Winner.is_claimed == False + ) + ) + ) + winner = winner_result.scalar_one_or_none() + + if not winner: + return None + + # Помечаем как подтвержденный + winner.is_claimed = True + + # Обновляем верификацию если есть + verification_result = await session.execute( + select(WinnerVerification).where(WinnerVerification.winner_id == winner.id) + ) + verification = verification_result.scalar_one_or_none() + + if verification: + verification.is_verified = True + verification.verified_at = datetime.now(timezone.utc) + + await session.commit() + await session.refresh(winner) + + return winner + + @staticmethod + async def get_unverified_winners( + session: AsyncSession, + lottery_id: int + ) -> List[Winner]: + """Получить список неподтвержденных победителей""" + result = await session.execute( + select(Winner).where( + and_( + Winner.lottery_id == lottery_id, + Winner.is_claimed == False + ) + ) + ) + return result.scalars().all() diff --git a/src/core/services.py b/src/core/services.py index 9025658..8523b0c 100644 --- a/src/core/services.py +++ b/src/core/services.py @@ -1,7 +1,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update, delete from sqlalchemy.orm import selectinload -from .models import User, Lottery, Participation, Winner +from .models import User, Lottery, Participation, Winner, Account from typing import List, Optional, Dict, Any from ..utils.account_utils import validate_account_number, format_account_number import random @@ -251,8 +251,20 @@ class LotteryService: if not lottery or lottery.is_completed: return {} - # Получаем всех участников - participants = [p.user for p in lottery.participations] + # Получаем всех участников (включая тех, у кого нет user) + participants = [] + for p in lottery.participations: + if p.user: + participants.append(p.user) + else: + # Создаем временный объект для участников без пользователя + # Храним только номер счета + participants.append(type('obj', (object,), { + 'id': None, + 'telegram_id': None, + 'account_number': p.account_number + })()) + if not participants: return {} @@ -270,7 +282,7 @@ class LotteryService: # Находим пользователя среди участников manual_winner = None for participant in remaining_participants: - if participant.telegram_id == manual_winners[place_str]: + if hasattr(participant, 'telegram_id') and participant.telegram_id == manual_winners[place_str]: manual_winner = participant break @@ -295,9 +307,11 @@ class LotteryService: # Сохраняем победителей в базу данных for place, winner_info in results.items(): + user_obj = winner_info['user'] winner = Winner( lottery_id=lottery_id, - user_id=winner_info['user'].id, + user_id=user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None, + account_number=user_obj.account_number if hasattr(user_obj, 'account_number') else None, place=place, prize=winner_info['prize'], is_manual=winner_info['is_manual'] @@ -306,15 +320,17 @@ class LotteryService: # Обновляем статус розыгрыша lottery.is_completed = True - lottery.draw_results = { - str(place): { - 'user_id': info['user'].id, - 'telegram_id': info['user'].telegram_id, - 'username': info['user'].username, + lottery.draw_results = {} + for place, info in results.items(): + user_obj = info['user'] + lottery.draw_results[str(place)] = { + 'user_id': user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None, + 'telegram_id': user_obj.telegram_id if hasattr(user_obj, 'telegram_id') else None, + 'username': user_obj.username if hasattr(user_obj, 'username') else None, + 'account_number': user_obj.account_number if hasattr(user_obj, 'account_number') else None, 'prize': info['prize'], 'is_manual': info['is_manual'] - } for place, info in results.items() - } + } await session.commit() return results diff --git a/src/handlers/admin_account_handlers.py b/src/handlers/admin_account_handlers.py new file mode 100644 index 0000000..6ca58a7 --- /dev/null +++ b/src/handlers/admin_account_handlers.py @@ -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 + Или: /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 + """ + 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 " + ) + 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 + Пример: /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 \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 + """ + 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 " + ) + 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 + """ + 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 " + ) + 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)}") diff --git a/src/handlers/admin_panel.py b/src/handlers/admin_panel.py index d888404..095ae04 100644 --- a/src/handlers/admin_panel.py +++ b/src/handlers/admin_panel.py @@ -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: diff --git a/src/handlers/redraw_handlers.py b/src/handlers/redraw_handlers.py new file mode 100644 index 0000000..b001808 --- /dev/null +++ b/src/handlers/redraw_handlers.py @@ -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 + """ + 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 " + ) + 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 + """ + 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 " + ) + 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)}") diff --git a/src/handlers/registration_handlers.py b/src/handlers/registration_handlers.py new file mode 100644 index 0000000..a73c6b4 --- /dev/null +++ b/src/handlers/registration_handlers.py @@ -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)