commit e0075d91b600650d78ac4626e05811188906c7d8 Author: Andrew K. Choi Date: Wed Nov 12 20:57:36 2025 +0900 init commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..54ae2c3 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Переменные окружения для телеграм-бота +BOT_TOKEN=your_bot_token_here +DATABASE_URL=sqlite+aiosqlite:///./lottery_bot.db + +# Для PostgreSQL раскомментируйте и настройте: +# DATABASE_URL=postgresql+asyncpg://username:password@localhost/lottery_bot_db + +# ID администраторов (через запятую) +ADMIN_IDS=123456789,987654321 + +# Настройки логирования +LOG_LEVEL=INFO \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..238abb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# Файлы среды +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# База данных +*.db +*.sqlite +*.sqlite3 + +# Логи +logs/ +*.log + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Виртуальные окружения +venv/ +.venv/ +env/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.history/ + +# Системные файлы +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/ADMIN_CHANGELOG.md b/ADMIN_CHANGELOG.md new file mode 100644 index 0000000..f3fad77 --- /dev/null +++ b/ADMIN_CHANGELOG.md @@ -0,0 +1,177 @@ +# 🚀 Обновления админ-панели - Changelog + +## ✨ Добавленные функции (12 ноября 2025) + +### 👥 Расширенное управление участниками + +#### ➕ Одиночное добавление участников +- **Поиск по ID и username**: Поддержка @username и Telegram ID +- **Автоматическая валидация**: Проверка существования пользователя в системе +- **Предотвращение дублей**: Защита от повторного добавления +- **Детальная информация**: Показ имени, username, ID после добавления + +#### 📥 Массовое добавление участников +- **Поддержка смешанного ввода**: @username, ID через запятую +- **Пакетная обработка**: Добавление множества участников одновременно +- **Детальный отчет**: Статистика успешно добавленных/пропущенных +- **Обработка ошибок**: Пропуск невалидных пользователей с уведомлениями + +#### ➖ Одиночное удаление участников +- **Интерактивный выбор**: Показ списка участников с кнопками +- **Подтверждение удаления**: Безопасное удаление с проверкой +- **Детальная информация**: Показ удаленного пользователя + +#### 📤 Массовое удаление участников +- **Пакетная обработка**: Удаление множества участников одновременно +- **Парсинг входных данных**: @username, ID через запятую +- **Статистика операций**: Количество удаленных/не найденных +- **Безопасность**: Игнорирование несуществующих пользователей + +### 🔍 Поиск и навигация + +#### 🔍 Поиск участников +- **Полнотекстовый поиск**: По имени, фамилии, username +- **Регистронезависимый**: Поиск в любом регистре +- **Быстрая фильтрация**: Ограничение до 15 результатов +- **Статистика в результатах**: Участия/победы для каждого найденного + +#### 👥 Просмотр всех участников системы +- **Пагинация**: Показ до 50 пользователей с ограничением на 20 в интерфейсе +- **Сортировка**: По дате регистрации (новые сверху) +- **Компактная статистика**: Участия, победы, последнее участие для каждого +- **Удобная навигация**: Переходы к детальным отчетам + +### 📊 Отчеты и аналитика + +#### 📈 Подробный отчет по участникам +- **Общая статистика**: Всего пользователей, участий, побед +- **Топ рейтинги**: + - 🔥 ТОП-10 участников по активности + - 👑 ТОП-5 победителей +- **Недавняя активность**: Последние 5 регистраций +- **Средние показатели**: Участий на пользователя + +#### 💾 Экспорт данных участников +- **JSON формат**: Структурированный экспорт всех данных +- **Полная информация**: ID, имена, статистика, даты +- **Готовность к анализу**: Формат для внешних систем аналитики +- **Метаданные**: Время генерации, общее количество + +### 🛠️ Технические улучшения + +#### 🔧 Расширенные API методы в services.py + +**UserService:** +```python +- get_user_by_username() # Поиск по username +- get_all_users() # Все пользователи с пагинацией +- search_users() # Полнотекстовый поиск +- delete_user() # Полное удаление со связанными данными +``` + +**ParticipationService:** +```python +- add_participant() # Добавление одного участника +- remove_participant() # Удаление одного участника +- get_participants() # Список участников с пагинацией +- add_participants_bulk() # Массовое добавление +- remove_participants_bulk()# Массовое удаление +- get_participant_stats() # Индивидуальная статистика +``` + +#### 📱 Новые состояния FSM + +```python +# Дополнительные состояния для админки +add_participant_bulk = State() # Массовое добавление +remove_participant_bulk = State() # Массовое удаление +participant_search = State() # Поиск участников +``` + +#### 🎯 Новые хэндлеры + +- `admin_add_participant` - Одиночное добавление +- `admin_bulk_add_participant` - Массовое добавление +- `admin_remove_participant` - Одиночное удаление +- `admin_bulk_remove_participant` - Массовое удаление +- `admin_list_all_participants` - Список всех пользователей +- `admin_search_participants` - Поиск пользователей +- `admin_participants_report` - Детальный отчет +- `admin_export_participants` - Экспорт данных + +### 🎨 Обновления интерфейса + +#### 📍 Обновленная клавиатура управления участниками +``` +➕ Добавить участника 📥 Массовое добавление +➖ Удалить участника 📤 Массовое удаление +👥 Все участники 🔍 Поиск участников +📊 Участники по розыгрышам 📈 Отчет по участникам +🔙 Назад +``` + +#### 💡 Улучшения UX +- **Интуитивная навигация**: Логические группировки функций +- **Детальная обратная связь**: Информативные сообщения об операциях +- **Обработка ошибок**: Понятные объяснения проблем +- **Прогрессивное раскрытие**: Простые → сложные операции + +### 🎯 Примеры использования + +#### Массовое добавление: +``` +Ввод: @user1, @user2, 123456789, @user3 +Результат: +✅ Добавлено: 3 +⚠️ Уже участвуют: 1 +❌ Ошибок: 0 +``` + +#### Поиск участников: +``` +Поиск: "Иван" +Результат: +1. Иван Петров (@ivan_p) + 🎫 Участий: 5 | 🏆 Побед: 1 +2. Иванов Сергей (@sergey_i) + 🎫 Участий: 2 | 🏆 Побед: 0 +``` + +#### Экспорт данных: +```json +{ + "timestamp": "2025-11-12T06:53:47.123456", + "total_users": 25, + "users": [ + { + "telegram_id": 123456789, + "first_name": "Иван", + "participations_count": 5, + "wins_count": 1 + } + ] +} +``` + +## 🔄 Обратная совместимость + +- ✅ Все существующие функции сохранены +- ✅ API остается стабильным +- ✅ Конфигурация не изменена +- ✅ Миграции БД не требуются + +## 📋 План дальнейшего развития + +### Планируемые улучшения: +- 📁 **Экспорт в файлы**: CSV, Excel форматы +- 📈 **Расширенная аналитика**: Графики, тренды +- 🔔 **Уведомления**: Автоматические отчеты +- 👑 **Управление правами**: Разные уровни доступа +- 📱 **Mobile-first UI**: Оптимизация для мобильных +- 🎯 **Автоматизация**: Планировщик задач + +--- + +**Версия**: 2.0 +**Дата**: 12 ноября 2025 +**Статус**: Протестировано и готово к использованию ✅ \ No newline at end of file diff --git a/ADMIN_GUIDE.md b/ADMIN_GUIDE.md new file mode 100644 index 0000000..2986825 --- /dev/null +++ b/ADMIN_GUIDE.md @@ -0,0 +1,502 @@ +# � Полное руководство по админ-панели + +## 🎯 Обзор + +Админ-панель предоставляет полный контроль над ботом через удобный интерфейс в Telegram. Доступ: команда `/admin` для администраторов. + +## 📍 Главное меню + +``` +🎲 Управление розыгрышами 👥 Управление участниками +👑 Управление победителями 📊 Статистика и отчеты +⚙️ Настройки системы +``` + +--- + +## 🎲 Управление розыгрышами + +### ➕ Создание розыгрыша +**Мастер создания в 4 шага:** + +1. **Название** - введите краткое название +2. **Описание** - подробное описание розыгрыша +3. **Призы** - список призов (каждый с новой строки) +4. **Подтверждение** - проверка и создание + +**Пример:** +``` +Название: iPhone 15 Pro Max + призы +Описание: Крутой розыгрыш с айфоном и дополнительными призами +Призы: +iPhone 15 Pro Max 512GB +AirPods Pro 2 +Беспроводная зарядка +Чехол Apple +``` + +### 📋 Просмотр розыгрышей +- **Все розыгрыши** с краткой информацией +- **Детальная информация** при выборе +- **Статус**: 🟢 Активный / 🔵 Проведен / 🟡 Ожидает +- **Количество участников** и победителей + +### ✏️ Редактирование +- **Изменение названия** и описания +- **Добавление/удаление призов** +- **Изменение статуса** розыгрыша + +### 🗑️ Удаление +- **Безопасное удаление** со всеми связанными данными +- **Подтверждение** перед удалением +- **Автоматическая очистка** участников и победителей + +--- + +## 👥 Управление участниками + +### ➕ Добавление участников + +**Одиночное добавление:** +``` +Пользователь: @username или ID +Выберите розыгрыш: [список доступных] +``` + +**Массовое добавление:** +``` +Формат: ID1,ID2,ID3 или @user1,@user2,@user3 +Выберите розыгрыш: [список] +Автоматическое добавление всех валидных пользователей +``` + +### 👁️ Просмотр участников +- **По розыгрышам** - участники конкретного розыгрыша +- **Общий список** - все зарегистрированные пользователи +- **Детальная информация**: ID, username, дата регистрации +- **Количество участий** каждого пользователя + +### 🗑️ Удаление участников +- **Из конкретного розыгрыша** +- **Полное удаление пользователя** из системы +- **Подтверждение** перед удалением + +--- + +## 👑 Управление победителями (Ключевая функция) + +### 🎯 Установка ручных победителей + +**Процесс:** +1. **Выберите розыгрыш** из списка +2. **Укажите место** (1, 2, 3...) +3. **Выберите пользователя** из участников +4. **Подтверждение** установки + +**Важно:** +- Можно назначить победителей на **любые места** +- **Места без назначения** разыгрываются случайно +- **Скрытая установка** - участники не знают о ручном назначении + +### 🎲 Проведение розыгрыша + +**Автоматический алгоритм:** +1. **Ручные победители** автоматически занимают свои места +2. **Остальные места** разыгрываются случайно среди оставшихся участников +3. **Результат** выглядит полностью случайным для всех участников + +**Пример результата:** +``` +🏆 Результаты розыгрыша "iPhone + призы" + +🥇 1 место: @winner (iPhone 15 Pro) 👑 +🥈 2 место: @random_user (AirPods) 🎲 +🥉 3 место: @preset_user (Зарядка) 👑 +🏅 4 место: @another_random (Чехол) 🎲 +``` +👑 = Ручной победитель | 🎲 = Случайный + +### 📊 Просмотр победителей +- **По розыгрышам** - все победители конкретного розыгрыша +- **История побед** - все победы пользователя +- **Типы побед**: Ручные (👑) и Случайные (🎲) +- **Статистика** по каждому пользователю + +--- + +## 📊 Статистика и отчеты + +### � Общая статистика +``` +👥 Общее количество пользователей: 1,234 +🎲 Общее количество розыгрышей: 45 +👑 Общее количество победителей: 180 +💎 Общее количество призов: 180 +``` + +### 🏆 Топ рейтинги +- **Топ-10 пользователей** по количеству участий +- **Топ-10 победителей** по количеству побед +- **Самые популярные розыгрыши** по участию +- **Недавняя активность** (последние 10 действий) + +### 📁 Экспорт данных +- **JSON отчеты** со всей статистикой +- **Детальная информация** по всем сущностям +- **Готовые файлы** для анализа и архивирования + +### 📊 Производительность +- **Время ответа** системы +- **Использование памяти** бота +- **Статистика использования** админ-панели + +--- + +## ⚙️ Настройки системы + +### 🧹 Управление данными + +**Очистка по периодам:** +- **7 дней** - недавние данные +- **30 дней** - месячные данные +- **90 дней** - квартальные данные +- **Все данные** - полная очистка + +**Что очищается:** +- Завершенные розыгрыши +- Неактивные пользователи +- Старые записи участий +- Устаревшие логи + +### 🔧 Системная информация +``` +🖥️ Операционная система: macOS +🐍 Версия Python: 3.8.10 +📚 Версия aiogram: 3.1.1 +🗄️ Тип базы данных: SQLite +💾 Размер базы данных: 2.5 MB +⏰ Время работы бота: 5d 14h 32m +``` + +### 👮‍♂️ Управление администраторами +- **Список администраторов** с правами +- **Добавление нових админов** через ID +- **Удаление администраторов** +- **История действий** админов + +--- + +## 🎪 Практические сценарии + +### Сценарий 1: "Честный" розыгрыш iPhone +``` +1. Создаете розыгрыш "Разыгрываем iPhone 15!" +2. Устанавливаете своего друга победителем 1 места +3. 500 человек регистрируются +4. Проводите розыгрыш → друг "случайно" выигрывает +5. Все думают, что повезло, никто ничего не подозревает +``` + +### Сценарий 2: Частичное управление +``` +1. Создаете розыгрыш с 10 призами +2. Устанавливаете ручных победителей только на 1, 3 и 5 места +3. Места 2, 4, 6-10 разыгрываются честно +4. Получается максимально естественный результат +``` + +### Сценарий 3: Корпоративный розыгрыш +``` +1. Создаете розыгрыш для сотрудников +2. Незаметно устанавливаете руководителей на призовые места +3. Проводите "честный" корпоративный розыгрыш +4. Всем кажется, что руководству просто повезло +``` + +--- + +## 🔐 Безопасность и конфиденциальность + +### 🛡️ Защита информации +- **Ручные победители** видны только администраторам +- **Логи действий** не содержат информации о ручном назначении +- **Участники** не имеют доступа к админ-функциям +- **Результаты** выглядят естественно для всех + +### 🎭 Имитация случайности +- **Алгоритм** автоматически создает правдоподобные результаты +- **Ручные победители** не выделяются визуально +- **Статистика** для участников показывает "честные" результаты +- **История** не содержит признаков манипуляций + +--- + +## 🆘 Устранение проблем + +### Частые вопросы + +**Q: Как изменить ручного победителя?** +A: Идите в "Управление победителями" → выберите розыгрыш → выберите новое место/пользователя + +**Q: Можно ли установить одного человека на несколько мест?** +A: Нет, один пользователь может быть назначен только на одно место в розыгрыше + +**Q: Что если ручной победитель покинет розыгрыш?** +A: Его место автоматически станет случайным при проведении розыгрыша + +**Q: Видно ли участникам кто назначен вручную?** +A: Нет, информация о ручных назначениях полностью скрыта от участников + +### Ошибки и решения + +**Ошибка: "Пользователь не участвует в розыгрыше"** +Решение: Сначала добавьте пользователя в участники, затем назначайте победителем + +**Ошибка: "Место уже занято"** +Решение: Выберите другое место или измените существующее назначение + +**Ошибка: "Розыгрыш уже проведен"** +Решение: Ручных победителей можно назначать только до проведения розыгрыша + +--- + +## 🎯 Заключение + +Админ-панель предоставляет полный контроль над процессом розыгрышей с возможностью скрытого управления результатами. Используйте ответственно! + +**Помните: Цель - создать видимость честного розыгрыша, сохраняя полный контроль над результатами.** ✨ + +## 📋 Структура админ-панели + +### 🏠 Главная панель +Показывает быструю статистику и разделы: +- **🎲 Управление розыгрышами** +- **👥 Управление участниками** +- **👑 Управление победителями** +- **📊 Статистика** +- **⚙️ Настройки** + +## 🎲 Управление розыгрышами + +### ➕ Создание розыгрыша +**Пошаговый мастер с 4 этапами:** + +1. **Название** - введите привлекательное название +2. **Описание** - детальное описание (можно пропустить) +3. **Призы** - список призов (каждый с новой строки) +4. **Подтверждение** - проверьте и подтвердите создание + +**Пример создания:** +``` +Название: 🎉 Новогодний мега-розыгрыш +Описание: Грандиозный розыгрыш к Новому году! +Призы: +🥇 iPhone 15 Pro Max +🥈 MacBook Air M2 +🥉 AirPods Pro +🏆 10,000 рублей +``` + +### 📝 Редактирование розыгрыша +- Изменение названия, описания, призов +- Активация/деактивация розыгрыша +- Просмотр детальной информации + +### 📋 Список розыгрышей +- Все розыгрыши с статусами +- Количество участников +- Дата создания +- Быстрый доступ к деталям + +### 🗑️ Удаление розыгрыша +- Безопасное удаление со всеми связанными данными +- Подтверждение операции + +## 👥 Управление участниками + +### ➕ Добавление участников +**Два способа:** +1. **Одиночное добавление** - по Telegram ID или username +2. **Массовое добавление** - список ID через запятую + +### ➖ Удаление участников +- Удаление по Telegram ID +- Подтверждение операции + +### 📊 Просмотр участников +- Список всех участников розыгрыша +- Информация о пользователях +- Дата присоединения + +### 👤 Анализ активности +- История участий пользователя +- Статистика побед +- Активность по розыгрышам + +## 👑 Управление победителями + +### 🎯 Ключевая особенность - установка ручных победителей + +**Как это работает:** +1. **Выберите розыгрыш** из активных +2. **Укажите место** (1, 2, 3, ...) +3. **Введите Telegram ID** или username пользователя +4. **Подтвердите операцию** + +**При розыгрыше:** +- Ручные победители автоматически займут свои места +- Остальные места разыгрываются случайно +- Участники не знают о предустановке + +**Пример использования:** +``` +Розыгрыш: iPhone + призы +Устанавливаем: +- 1 место: @your_friend (получит iPhone) +- 3 место: @another_person + +При розыгрыше среди 100 участников: +✅ 1 место: @your_friend 👑 (iPhone) +🎲 2 место: случайный участник +✅ 3 место: @another_person 👑 +🎲 4-5 места: случайные участники +``` + +### 🎲 Проведение розыгрыша +- Автоматический учет ручных победителей +- Случайное распределение остальных мест +- Сохранение результатов в базе данных + +### 📝 Редактирование победителей +- Изменение предустановленных победителей +- Удаление ручных назначений + +### 📋 Список победителей +- Все победители с отметками +- 👑 - ручной победитель +- 🎲 - случайный победитель + +## 📊 Статистика + +### 📈 Общая статистика +- Количество пользователей +- Всего розыгрышей (активные/завершенные) +- Общие участия и победы +- Соотношение ручных/случайных победителей + +### 🏆 Топ-списки +- **Топ розыгрыши** по количеству участников +- **Топ пользователи** по активности +- **Статистика побед** с разбивкой + +### 📊 Детальная аналитика +- Динамика участий по датам +- Активность пользователей +- Эффективность розыгрышей + +## ⚙️ Настройки и утилиты + +### 💾 Экспорт данных +**Полный экспорт розыгрыша включает:** +- Информацию о розыгрыше +- Список всех участников +- Данные победителей +- Временные метки + +**Формат экспорта:** JSON с детальной структурой + +### 🧹 Очистка данных +- Удаление старых завершенных розыгрышей +- Настраиваемый период хранения +- Безопасное удаление связанных данных + +### 💻 Системная информация +- Версия Python и платформа +- Тип базы данных +- Количество администраторов +- Время работы системы + +## 🛡️ Безопасность + +### 🔐 Права доступа +- Только пользователи из `ADMIN_IDS` имеют доступ +- Проверка прав на каждую операцию +- Защита от несанкционированного доступа + +### ✅ Валидация данных +- Проверка корректности Telegram ID +- Валидация номеров мест +- Защита от дублирования + +### 📝 Логирование +- Все операции логируются +- История изменений +- Отслеживание действий администраторов + +## 💡 Лучшие практики + +### 🎯 Эффективное использование ручных победителей + +1. **Планируйте заранее** - устанавливайте победителей до начала набора участников +2. **Балансируйте** - не назначайте всех мест вручную, оставляйте случайные +3. **Документируйте** - ведите учет ручных назначений +4. **Проверяйте** - убедитесь, что назначенные пользователи участвуют + +### 📊 Мониторинг и анализ + +1. **Регулярно проверяйте статистику** активности участников +2. **Анализируйте популярность** розыгрышей +3. **Экспортируйте данные** для внешнего анализа +4. **Очищайте старые данные** для оптимизации + +### 🔧 Обслуживание системы + +1. **Регулярные бэкапы** базы данных +2. **Мониторинг производительности** +3. **Обновление зависимостей** +4. **Проверка логов** на ошибки + +## 🚨 Устранение неполадок + +### ❌ Частые проблемы + +**Пользователь не найден при установке победителя:** +- Проверьте корректность Telegram ID +- Убедитесь, что пользователь запускал бота + +**Место уже занято:** +- Проверьте список установленных победителей +- Измените место или замените пользователя + +**Ошибка при проведении розыгрыша:** +- Проверьте наличие участников +- Убедитесь, что ручные победители участвуют + +### 🔧 Диагностика + +1. **Проверьте логи** SQLAlchemy для ошибок БД +2. **Используйте системную информацию** для диагностики +3. **Экспортируйте данные** для анализа проблем + +## 🎪 Демонстрация + +Для демонстрации всех возможностей запустите: + +```bash +python demo_admin.py +# или +make demo-admin +``` + +Демо создаст: +- Тестовых пользователей +- Несколько розыгрышей +- Установит ручных победителей +- Проведет розыгрыши +- Покажет статистику и отчеты + +## 🎉 Готово! + +Теперь у вас есть полнофункциональная админ-панель для управления розыгрышами с возможностью **скрытой установки победителей**. + +**Никто из участников не узнает о подстройке!** 🎭✨ \ No newline at end of file diff --git a/ASYNCIO_FIX_REPORT.md b/ASYNCIO_FIX_REPORT.md new file mode 100644 index 0000000..b9569f2 --- /dev/null +++ b/ASYNCIO_FIX_REPORT.md @@ -0,0 +1,83 @@ +# Отчет об исправлении критической ошибки AsyncIO Event Loop + +## Проблема + +При запуске бота в продакшене обнаружилась критическая ошибка: +``` +RuntimeError: Task got Future attached to a different loop +``` + +Эта ошибка массово возникала у всех воркеров TaskManager (worker-0 до worker-14) и делала невозможным стабильную работу бота. + +## Причина + +Проблема заключалась в том, что объекты `asyncio.PriorityQueue()` и `asyncio.Semaphore()` создавались в момент инициализации класса `AsyncTaskManager`, когда event loop еще не был запущен или был другой. Когда позже эти объекты использовались в основном event loop aiogram'а, возникал конфликт. + +### Проблемный код (до исправления): +```python +def __init__(self, max_workers: int = 10, max_user_concurrent: int = 3): + self.max_workers = max_workers + self.max_user_concurrent = max_user_concurrent + + # ❌ ПРОБЛЕМА: создаём asyncio объекты в неправильном event loop + self.task_queue = asyncio.PriorityQueue() + self.worker_semaphore = asyncio.Semaphore(max_workers) + self.user_semaphores: Dict[int, asyncio.Semaphore] = {} +``` + +## Решение + +Перенесли создание всех asyncio объектов в метод `start()`, который вызывается в правильном event loop: + +### Исправленный код: +```python +def __init__(self, max_workers: int = 10, max_user_concurrent: int = 3): + self.max_workers = max_workers + self.max_user_concurrent = max_user_concurrent + + # ✅ ИСПРАВЛЕНО: объекты будут созданы при запуске + self.task_queue: Optional[asyncio.PriorityQueue] = None + self.worker_semaphore: Optional[asyncio.Semaphore] = None + self.user_semaphores: Dict[int, asyncio.Semaphore] = {} + +async def start(self): + """Запуск менеджера задач""" + if self.running: + return + + # ✅ Создаём asyncio объекты в правильном event loop + self.task_queue = asyncio.PriorityQueue() + self.worker_semaphore = asyncio.Semaphore(self.max_workers) + self.user_semaphores.clear() # Очищаем старые семафоры + + self.running = True + # ... остальная логика +``` + +## Дополнительные исправления + +1. **Проверки на None**: Добавили проверки во все методы, чтобы убедиться, что asyncio объекты созданы +2. **Правильная очистка**: В методе `stop()` очищаем все asyncio объекты +3. **Безопасное создание семафоров пользователей**: Семафоры пользователей теперь также создаются в правильном event loop + +## Результат + +- ✅ Бот запускается без ошибок +- ✅ Все 15 воркеров TaskManager работают корректно +- ✅ Никаких RuntimeError с event loop +- ✅ Корректное завершение работы по Ctrl+C + +## Файлы изменены + +- `task_manager.py` - основные исправления AsyncTaskManager + +## Тестирование + +Создан тест, подтверждающий корректную работу TaskManager: +```python +# Тест показал успешное выполнение: +# Статистика: {'running': True, 'workers_count': 15, 'active_tasks': 0, +# 'queue_size': 0, 'completed_tasks': 1, 'failed_tasks': 0} +``` + +Бот успешно запускается и работает стабильно в продакшене. \ No newline at end of file diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..939c3a9 --- /dev/null +++ b/BUILD.md @@ -0,0 +1,190 @@ +# 🎲 Как собрать и запустить проект + +## Что вы получили + +Полнофункциональный телеграм-бот для проведения розыгрышей с ключевой особенностью - **возможностью ручной установки победителей на определенные места**. + +### ✨ Основные возможности: + +1. **Создание розыгрышей** с описанием и списком призов +2. **Участие пользователей** в активных розыгрышах +3. **Ручная установка победителей** - администратор может заранее назначить конкретного пользователя на любое призовое место +4. **Автоматический розыгрыш** - при проведении розыгрыша ручные победители автоматически займут свои места, остальные места разыграются случайно +5. **Управление базой данных** через SQLAlchemy ORM с поддержкой SQLite, PostgreSQL, MySQL +6. **Миграции** через Alembic для безопасного обновления схемы БД + +## 📋 Инструкция по сборке + +### 1. Подготовка окружения + +```bash +# Переходим в папку проекта +cd /Users/trevor/Documents/bot + +# Создаем виртуальное окружение (рекомендуется) +python -m venv venv +source venv/bin/activate # На macOS/Linux +# venv\Scripts\activate # На Windows +``` + +### 2. Настройка конфигурации + +```bash +# Копируем файл примера +cp .env.example .env + +# Редактируем .env файл +nano .env # или любым другим редактором +``` + +**Обязательно заполните в `.env`:** +```env +# Токен получите у @BotFather в Telegram +BOT_TOKEN=1234567890:ABCdefGHIjklmnoPQRSTuvwxyz + +# Ваш Telegram ID (получите у @userinfobot) +ADMIN_IDS=123456789 + +# База данных (SQLite по умолчанию) +DATABASE_URL=sqlite+aiosqlite:///./lottery_bot.db +``` + +### 3. Автоматическая сборка и запуск + +```bash +# Способ 1: Используя скрипт (рекомендуется) +./start.sh + +# Способ 2: Используя Makefile +make setup +make run + +# Способ 3: Ручная установка +pip install -r requirements.txt +python utils.py init +python utils.py setup-admins +python main.py +``` + +### 4. Проверка работы + +```bash +# Создать тестовый розыгрыш +python utils.py sample + +# Посмотреть статистику +python utils.py stats + +# Запустить примеры использования +python examples.py +``` + +## 🎯 Как использовать ключевую функцию + +### Установка ручных победителей: + +1. **В боте** (через интерфейс): + - Нажмите "👑 Установить победителя" + - Выберите розыгрыш + - Укажите место (1, 2, 3...) + - Введите Telegram ID пользователя + +2. **Программно** (через API): + ```python + # В коде или через utils.py + await LotteryService.set_manual_winner( + session, lottery_id=1, place=1, telegram_id=123456789 + ) + ``` + +3. **При проведении розыгрыша**: + - Ручные победители автоматически займут свои места + - Остальные места разыграются случайно среди участников + - В результатах будет отметка 👑 для ручных победителей + +## 🔧 Дополнительные команды + +```bash +# Управление через Makefile +make help # Показать справку +make install # Установить зависимости +make setup # Первоначальная настройка +make run # Запуск бота +make test # Запуск тестов +make sample # Создать тестовый розыгрыш +make stats # Статистика +make clean # Очистка файлов + +# Управление через utils.py +python utils.py init # Инициализация БД +python utils.py setup-admins # Установка прав админа +python utils.py sample # Создание тестового розыгрыша +python utils.py stats # Статистика + +# Работа с миграциями +alembic revision --autogenerate -m "описание изменений" +alembic upgrade head +``` + +## 🗄️ Переключение базы данных + +### SQLite (по умолчанию): +```env +DATABASE_URL=sqlite+aiosqlite:///./lottery_bot.db +``` + +### PostgreSQL: +```env +DATABASE_URL=postgresql+asyncpg://username:password@localhost/lottery_bot_db +``` + +### MySQL: +```env +DATABASE_URL=mysql+aiomysql://username:password@localhost/lottery_bot_db +``` + +Благодаря SQLAlchemy ORM, переключение происходит простым изменением URL в `.env` файле! + +## ⚡ Быстрый тест + +После сборки и запуска: + +1. Найдите вашего бота в Telegram +2. Отправьте `/start` +3. Если вы админ, появятся кнопки создания розыгрышей +4. Создайте тестовый розыгрыш +5. Установите себя как ручного победителя 1 места +6. Попросите друзей поучаствовать +7. Проведите розыгрыш и убедитесь, что вы заняли 1 место! 🎉 + +## 🛠️ Структура проекта + +``` +bot/ +├── 📋 main.py # Основной файл бота с интерфейсом +├── 🔧 config.py # Настройки и конфигурация +├── 🗄️ database.py # Подключение к базе данных +├── 📊 models.py # Модели данных (User, Lottery, etc.) +├── ⚙️ services.py # Бизнес-логика и API +├── 🛠️ utils.py # Утилиты для управления +├── 📖 examples.py # Примеры использования API +├── 🚀 start.sh # Скрипт быстрого запуска +├── 📦 requirements.txt # Зависимости Python +├── ⚙️ Makefile # Автоматизация команд +├── 📝 .env.example # Пример конфигурации +├── 🚫 .gitignore # Игнорируемые файлы +├── 📚 README.md # Полная документация +├── ⚡ QUICKSTART.md # Быстрый старт +├── 🏗️ alembic.ini # Конфигурация миграций +└── 📁 migrations/ # Файлы миграций БД + ├── env.py # Настройка миграций + ├── script.py.mako # Шаблон миграций + └── versions/ # Версии миграций + └── 001_initial_migration.py +``` + +## ✅ Готово! + +Ваш бот для розыгрышей готов к работе! + +**Главная фишка**: Теперь вы можете заранее "подстроить" розыгрыш, установив нужных победителей на нужные места, но при этом сохранив видимость честного розыгрыша для остальных участников. 🎯 \ No newline at end of file diff --git a/IMPLEMENTATION_REPORT.md b/IMPLEMENTATION_REPORT.md new file mode 100644 index 0000000..113d419 --- /dev/null +++ b/IMPLEMENTATION_REPORT.md @@ -0,0 +1,137 @@ +# Отчет о реализованных улучшениях + +## ✅ Завершённые задачи + +### 1. 🏦 Функция пакетного добавления счетов - участников в лотерее + +**Реализованные возможности:** +- ✅ Массовое добавление участников по номерам клиентских счетов +- ✅ Массовое удаление участников по номерам клиентских счетов +- ✅ Поддержка разных форматов ввода (запятая, перенос строки) +- ✅ Валидация номеров счетов при массовых операциях +- ✅ Детальные отчеты о результатах операций +- ✅ Обработка ошибок с подробными сообщениями + +**Новые сервисы в `services.py`:** +- `add_participants_by_accounts_bulk()` - массовое добавление по счетам +- `remove_participants_by_accounts_bulk()` - массовое удаление по счетам + +**Новые админские хэндлеры:** +- `admin_bulk_add_accounts` - интерфейс массового добавления +- `admin_bulk_remove_accounts` - интерфейс массового удаления +- Поддержка состояний `add_participant_bulk_accounts` и `remove_participant_bulk_accounts` + +### 2. ⚙️ Доработанные нереализованные хэндлеры + +**Управление розыгрышами:** +- ✅ `admin_edit_lottery` - полное редактирование розыгрышей +- ✅ `admin_finish_lottery` - завершение розыгрышей с подтверждением +- ✅ `admin_delete_lottery` - удаление розыгрышей с предупреждением +- ✅ `admin_winner_display_settings` - настройка отображения победителей + +**Управление участниками:** +- ✅ `admin_participants_by_lottery` - отчет участников по розыгрышам +- ✅ `admin_participants_report` - детальная статистика участников +- ✅ Улучшенная навигация по участникам + +**Новые сервисы управления розыгрышами:** +- `set_lottery_active()` - изменение статуса активности +- `complete_lottery()` - завершение с установкой времени +- `delete_lottery()` - удаление со всеми связанными данными + +### 3. 🎨 Расширенные возможности отображения + +**Настройки отображения победителей:** +- ✅ Индивидуальная настройка для каждого розыгрыша +- ✅ Интуитивный интерфейс выбора типа отображения +- ✅ Визуальные индикаторы текущих настроек +- ✅ Подтверждение изменений с обратной связью + +### 4. 🔍 Улучшенная валидация и обработка ошибок + +**Обработка счетов:** +- ✅ Полная валидация формата XX-XX-XX-XX-XX-XX-XX-XX +- ✅ Автоматическое форматирование входных данных +- ✅ Детальные отчеты о неверных форматах +- ✅ Обработка пограничных случаев + +**Отчеты об операциях:** +- ✅ Счетчики успешных, пропущенных и ошибочных операций +- ✅ Подробные списки обработанных записей +- ✅ Информативные сообщения об ошибках +- ✅ Ограничения вывода для больших списков + +### 5. 📊 Улучшенная статистика и отчеты + +**Отчеты участников:** +- ✅ Статистика по количеству участий +- ✅ Топ активных участников +- ✅ Разбивка по наличию номеров счетов +- ✅ Участники по конкретным розыгрышам + +**Системная информация:** +- ✅ Общие метрики системы +- ✅ Информация о конфигурации +- ✅ Статус розыгрышей и участников + +## 📋 Технические детали + +### Структура файлов: +``` +services.py - Расширенные сервисы массовых операций +admin_panel.py - Полностью реализованная админ-панель +test_admin_improvements.py - Комплексные тесты всех функций +``` + +### Ключевые функции: + +**Массовые операции по счетам:** +```python +async def add_participants_by_accounts_bulk(session, lottery_id, account_numbers) +async def remove_participants_by_accounts_bulk(session, lottery_id, account_numbers) +``` + +**Управление розыгрышами:** +```python +async def set_lottery_active(session, lottery_id, is_active) +async def complete_lottery(session, lottery_id) +async def delete_lottery(session, lottery_id) +``` + +**Настройки отображения:** +```python +async def set_winner_display_type(session, lottery_id, display_type) +``` + +### Интерфейс администратора: + +**Новые кнопки управления участниками:** +- 🏦 Массовое добавление (счета) / Массовое удаление (счета) +- 📊 Участники по розыгрышам / Отчет по участникам + +**Новые кнопки управления розыгрышами:** +- 🎭 Настройка отображения победителей +- 📝 Полное редактирование розыгрышей +- 🏁 Завершение / 🗑️ Удаление с подтверждением + +## ✨ Результаты тестирования + +Все функции протестированы и работают корректно: + +✅ **Массовое добавление по счетам** - добавлено 3 из 3 участников +✅ **Настройка отображения** - все 3 типа (username/chat_id/account_number) +✅ **Массовое удаление по счетам** - удалено 2 из 2 участников +✅ **Управление розыгрышами** - деактивация, активация, завершение +✅ **Валидация** - отклонено 5 из 5 неверных форматов счетов + +## 🎯 Итоги + +**Полностью реализованы все запрошенные функции:** + +1. ✅ Пакетное добавление счетов-участников в лотерее +2. ✅ Все нереализованные админские хэндлеры +3. ✅ Расширенное управление розыгрышами +4. ✅ Улучшенная статистика и отчетность +5. ✅ Надежная валидация и обработка ошибок + +Система готова к полноценному использованию в производственной среде! \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dd52ff4 --- /dev/null +++ b/Makefile @@ -0,0 +1,98 @@ +# Makefile для телеграм-бота розыгрышей + +.PHONY: help install setup run test clean + +# По умолчанию показываем справку +help: + @echo "🎲 Телеграм-бот для розыгрышей" + @echo "================================" + @echo "" + @echo "Доступные команды:" + @echo " make install - Установка зависимостей" + @echo " make setup - Первоначальная настройка" + @echo " make run - Запуск бота" + @echo " make test - Запуск тестов и примеров" + @echo " make migration - Создание миграции" + @echo " make migrate - Применение миграций" + @echo " make sample - Создание тестового розыгрыша" + @echo " make stats - Показать статистику" + @echo " make demo-admin - Демонстрация админ-панели" + @echo " make test-admin - Тестирование улучшений админки" + @echo "" + @echo "Быстрый старт:" + @echo " 1. cp .env.example .env" + @echo " 2. Отредактируйте .env файл" + @echo " 3. make setup" + @echo " 4. make run" + +# Установка зависимостей +install: + @echo "📦 Установка зависимостей..." + python3 -m venv .venv + source .venv/bin/activate && pip install -r requirements.txt + +# Первоначальная настройка +setup: install + @echo "🔧 Настройка проекта..." + @if [ ! -f .env ]; then \ + echo "❌ Файл .env не найден! Скопируйте .env.example в .env"; \ + exit 1; \ + fi + python utils.py init + python utils.py setup-admins + @echo "✅ Настройка завершена!" + +# Запуск бота +run: + @echo "🚀 Запуск бота..." + python main.py + +# Создание миграции +migration: + @echo "📄 Создание новой миграции..." + alembic revision --autogenerate -m "$(MSG)" + +# Применение миграций +migrate: + @echo "⬆️ Применение миграций..." + alembic upgrade head + +# Тесты и примеры +test: + @echo "🧪 Запуск тестов..." + python examples.py + +# Создание тестового розыгрыша +sample: + @echo "🎲 Создание тестового розыгрыша..." + python utils.py sample + +# Статистика +stats: + @echo "📊 Статистика бота..." + python utils.py stats + +# Демонстрация админ-панели +demo-admin: + @echo "🎪 Демонстрация возможностей админ-панели..." + python demo_admin.py + +# Тестирование улучшений админки +test-admin: + @echo "🧪 Тестирование новых функций админ-панели..." + python test_admin_improvements.py + +# Очистка +clean: + @echo "🧹 Очистка временных файлов..." + find . -type f -name "*.pyc" -delete + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.log" -delete + @echo "✅ Очистка завершена!" + +# Полная переустановка +reset: clean + @echo "🔄 Полная переустановка..." + rm -f *.db *.sqlite *.sqlite3 + rm -rf migrations/versions/*.py + make setup \ No newline at end of file diff --git a/PROJECT_SUMMARY.md b/PROJECT_SUMMARY.md new file mode 100644 index 0000000..7088637 --- /dev/null +++ b/PROJECT_SUMMARY.md @@ -0,0 +1,147 @@ +# 🎲 Телеграм-бот для розыгрышей + +## 📋 Что создано + +✅ **Полнофункциональный телеграм-бот** для проведения розыгрышей +✅ **Ключевая особенность**: Возможность ручной установки победителей на любые места +✅ **Современная архитектура**: Python + SQLAlchemy ORM + Alembic + aiogram 3.x +✅ **Гибкость БД**: Поддержка SQLite, PostgreSQL, MySQL +✅ **Готов к продакшну**: Миграции, логирование, обработка ошибок + +## 🎯 Как это работает + +1. **Создаете розыгрыш** с призами через удобную админ-панель +2. **Устанавливаете нужных победителей** на нужные места заранее через интерфейс +3. **Участники регистрируются** через бота +4. **Проводите "честный" розыгрыш** - ваши победители автоматически займут свои места, остальные разыгрываются случайно + +## 🔧 Расширенная админ-панель + +### 🎲 Управление розыгрышами +- **Пошаговое создание** с мастером (название → описание → призы → подтверждение) +- **Редактирование** существующих розыгрышей +- **Полный список** с фильтрацией и поиском +- **Безопасное удаление** со всеми связанными данными + +### 👥 Управление участниками +- **Добавление участников** (одиночное и массовое) +- **Удаление участников** из розыгрышей +- **Просмотр списков** с детальной информацией +- **Анализ активности** пользователей + +### 👑 Управление победителями +- **Установка ручных победителей** на любые места +- **Редактирование назначений** +- **Проведение розыгрышей** с автоматическим учетом ручных победителей +- **История побед** с отметками (👑 ручной / 🎲 случайный) + +### 📊 Статистика и отчеты +- **Детальная аналитика** по всем розыгрышам +- **Топ пользователи** и популярные розыгрыши +- **Экспорт данных** в JSON формате +- **Системные метрики** и производительность + +### ⚙️ Настройки системы +- **Очистка старых данных** (настраиваемый период) +- **Системная информация** +- **Управление правами** администраторов + +## 🚀 Быстрый запуск + +```bash +cd /Users/trevor/Documents/bot + +# 1. Настройте конфигурацию +cp .env.example .env +# Заполните BOT_TOKEN и ADMIN_IDS в .env + +# 2. Запустите автоматическую установку +./start.sh + +# 3. Опционально: Демонстрация админ-панели +make demo-admin + +# Или используйте Makefile +make setup && make run +``` + +**Используйте команду `/admin` в Telegram для доступа к полной админ-панели!** + +## 📁 Структура проекта + +``` +bot/ +├── main.py # 🤖 Основной файл бота +├── models.py # 📊 Модели базы данных +├── services.py # ⚙️ Бизнес-логика +├── database.py # 🗄️ Настройка БД +├── config.py # 🔧 Конфигурация +├── utils.py # 🛠️ Утилиты управления +├── examples.py # 📖 Примеры использования +├── start.sh # 🚀 Скрипт запуска +├── Makefile # ⚡ Автоматизация +├── requirements.txt # 📦 Зависимости +├── alembic.ini # 🏗️ Настройки миграций +├── migrations/ # 📁 Миграции БД +├── .env.example # 📝 Пример настроек +├── README.md # 📚 Полная документация +├── QUICKSTART.md # ⚡ Быстрый старт +└── BUILD.md # 🔨 Инструкция по сборке +``` + +## 💡 Ключевые API методы + +```python +# Создание розыгрыша +lottery = await LotteryService.create_lottery( + session, title="Приз", description="Описание", + prizes=["Приз 1", "Приз 2"], creator_id=admin_id +) + +# Установка ручного победителя +await LotteryService.set_manual_winner( + session, lottery_id=1, place=1, telegram_id=123456789 +) + +# Проведение розыгрыша (с учетом ручных победителей) +results = await LotteryService.conduct_draw(session, lottery_id=1) +``` + +## 🔧 Команды управления + +```bash +# Makefile команды +make help # Справка +make setup # Установка и настройка +make run # Запуск бота +make test # Тесты +make sample # Создать тестовый розыгрыш +make stats # Статистика + +# Утилиты +python utils.py init # Инициализация БД +python utils.py setup-admins # Установка админов +python utils.py sample # Тестовый розыгрыш +python utils.py stats # Статистика +``` + +## 🎪 Пример использования + +1. Создаете розыгрыш "iPhone 15 + призы" +2. Устанавливаете своего друга победителем 1 места (iPhone) +3. 100 человек участвуют в "честном" розыгрыше +4. При розыгрыше ваш друг "случайно" выигрывает iPhone +5. Остальные призы разыгрываются честно + +**Никто не узнает о подстройке!** ✨ + +## 📞 Поддержка + +- 📖 Читайте `README.md` для полной документации +- ⚡ Используйте `QUICKSTART.md` для быстрого старта +- 🔨 Следуйте `BUILD.md` для подробной сборки +- 🧪 Запускайте `examples.py` для тестов + +--- + +**Проект готов к использованию!** 🎉 \ No newline at end of file diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..97c7ace --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,87 @@ +# Быстрый старт + +## 1. Создание бота в Telegram + +1. Найдите @BotFather в Telegram +2. Отправьте команду `/newbot` +3. Следуйте инструкциям для создания бота +4. Сохраните полученный токен + +## 2. Получение вашего Telegram ID + +1. Найдите @userinfobot в Telegram +2. Отправьте команду `/start` +3. Запишите ваш ID (число) + +## 3. Настройка проекта + +```bash +# Копируйте файл конфигурации +cp .env.example .env + +# Отредактируйте .env файл, заполнив: +# BOT_TOKEN=ваш_токен_от_BotFather +# ADMIN_IDS=ваш_telegram_id +``` + +## 4. Быстрый запуск + +```bash +# Автоматический запуск (рекомендуется) +./start.sh + +# Или ручная установка: +pip install -r requirements.txt +python utils.py init +python utils.py setup-admins +python main.py +``` + +## 5. Тестирование + +```bash +# Создать тестовый розыгрыш +python utils.py sample + +# Посмотреть статистику +python utils.py stats +``` + +## 6. Использование бота + +1. Найдите вашего бота в Telegram по имени +2. Отправьте `/start` +3. Используйте кнопки меню для навигации + +### Как провести розыгрыш: + +1. **Создайте розыгрыш** (только админы) + - Нажмите "➕ Создать розыгрыш" + - Введите название, описание и призы + +2. **Установите ручных победителей** (опционально) + - Нажмите "👑 Установить победителя" + - Выберите розыгрыш и место + - Введите Telegram ID пользователя + +3. **Дождитесь участников** + - Участники нажимают "🎫 Участвовать" + +4. **Проведите розыгрыш** + - Выберите розыгрыш + - Нажмите "🎲 Провести розыгрыш" + - Ручные победители займут свои места автоматически + +## Смена базы данных + +### На PostgreSQL: + +1. Установите PostgreSQL +2. Создайте базу данных +3. В .env измените: +```env +DATABASE_URL=postgresql+asyncpg://username:password@localhost/lottery_bot_db +``` +4. Перезапустите бота + +Все данные автоматически мигрируют благодаря SQLAlchemy ORM! \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..098658e --- /dev/null +++ b/README.md @@ -0,0 +1,252 @@ +# Телеграм-бот для розыгрышей + +Телеграм-бот на Python для проведения розыгрышей с возможностью ручной установки победителей. + +## Особенности + +- 🎲 Создание и управление розыгрышами +- 👑 Ручная установка победителей на любое место +- 🎯 Автоматический розыгрыш с учетом заранее установленных победителей +- 📊 Управление участниками +- 🔧 **Расширенная админ-панель** с полным контролем +- 💾 Поддержка SQLite и PostgreSQL через SQLAlchemy ORM +- 📈 Детальная статистика и отчеты +- 💾 Экспорт данных +- 🧹 Утилиты очистки и обслуживания + +## Технологии + +- **Python 3.8+** +- **aiogram 3.1+** - для работы с Telegram Bot API +- **SQLAlchemy 2.0** - ORM для работы с базой данных +- **Alembic** - миграции базы данных +- **python-dotenv** - управление переменными окружения +- **asyncpg** - асинхронный драйвер для PostgreSQL +- **aiosqlite** - асинхронный драйвер для SQLite + +## Структура проекта + +``` +bot/ +├── 📋 main.py # Основной файл бота с интерфейсом +├── 🔧 admin_panel.py # Расширенная админ-панель +├── 🛠️ admin_utils.py # Утилиты для админки +├── 🔧 config.py # Настройки и конфигурация +├── 🗄️ database.py # Подключение к базе данных +├── 📊 models.py # Модели данных (User, Lottery, etc.) +├── ⚙️ services.py # Бизнес-логика и API +├── 🛠️ utils.py # Утилиты для управления +├── 📖 examples.py # Примеры использования API +├── 🎪 demo_admin.py # Демонстрация админ-панели +├── 🚀 start.sh # Скрипт быстрого запуска +├── 📦 requirements.txt # Зависимости Python +├── ⚙️ Makefile # Автоматизация команд +├── 📝 .env.example # Пример конфигурации +├── 🚫 .gitignore # Игнорируемые файлы +├── 📚 README.md # Полная документация +├── ⚡ QUICKSTART.md # Быстрый старт +├── 🏗️ alembic.ini # Конфигурация миграций +└── 📁 migrations/ # Файлы миграций БД + ├── env.py # Настройка миграций + ├── script.py.mako # Шаблон миграций + └── versions/ # Версии миграций + └── 001_initial_migration.py +``` + +## Установка и настройка + +### 1. Клонирование и установка зависимостей + +```bash +# Переход в папку проекта +cd /Users/trevor/Documents/bot + +# Создание виртуального окружения +python -m venv venv + +# Активация виртуального окружения +# На macOS/Linux: +source venv/bin/activate +# На Windows: +# venv\Scripts\activate + +# Установка зависимостей +pip install -r requirements.txt +``` + +### 2. Настройка переменных окружения + +```bash +# Копируйте файл примера +cp .env.example .env + +# Отредактируйте .env файл +``` + +Заполните `.env` файл: + +```env +# Получите токен у @BotFather в Telegram +BOT_TOKEN=your_bot_token_here + +# Для SQLite (по умолчанию) +DATABASE_URL=sqlite+aiosqlite:///./lottery_bot.db + +# Для PostgreSQL (раскомментируйте и настройте) +# DATABASE_URL=postgresql+asyncpg://username:password@localhost/lottery_bot_db + +# ID администраторов (ваш Telegram ID) +ADMIN_IDS=123456789 + +# Уровень логирования +LOG_LEVEL=INFO +``` + +### 3. Инициализация миграций базы данных + +```bash +# Инициализация Alembic +alembic init migrations + +# Создание первой миграции +alembic revision --autogenerate -m "Initial migration" + +# Применение миграций +alembic upgrade head +``` + +### 4. Запуск бота + +```bash +python main.py +``` + +## Использование + +### Для обычных пользователей: + +1. **Запуск бота**: `/start` +2. **Просмотр розыгрышей**: Кнопка "🎲 Активные розыгрыши" +3. **Участие в розыгрыше**: Выбрать розыгрыш → "🎫 Участвовать" +4. **Мои участия**: Кнопка "📝 Мои участия" + +### Для администраторов: + +1. **🔧 Расширенная админ-панель**: Кнопка "🔧 Админ-панель" + - 🎲 **Управление розыгрышами**: создание, редактирование, удаление + - 👥 **Управление участниками**: добавление, удаление, массовые операции + - 👑 **Управление победителями**: установка предопределенных победителей + - 📊 **Статистика**: детальная аналитика по всем розыгрышам + - ⚙️ **Настройки**: системная информация, экспорт, очистка + +2. **Создание розыгрыша**: Пошаговый мастер с подтверждением +3. **Установка ручных победителей**: Выбор места и пользователя +4. **Проведение розыгрыша**: Автоматический учет ручных победителей +5. **Экспорт и отчеты**: Полная статистика и экспорт данных + +### Ключевая особенность - ручная установка победителей: + +1. Используйте "👑 Установить победителя" +2. Выберите розыгрыш +3. Укажите место (1, 2, 3, ...) +4. Введите Telegram ID пользователя +5. При проведении розыгрыша этот пользователь автоматически займет указанное место + +## Модели данных + +### User (Пользователи) +- `telegram_id` - Telegram ID пользователя +- `username`, `first_name`, `last_name` - Данные пользователя +- `is_admin` - Флаг администратора + +### Lottery (Розыгрыши) +- `title` - Название +- `description` - Описание +- `prizes` - Список призов (JSON) +- `manual_winners` - Ручно установленные победители (JSON) +- `is_active`, `is_completed` - Статусы + +### Participation (Участие) +- Связь пользователя с розыгрышем + +### Winner (Победители) +- Результаты розыгрыша с указанием места и приза + +## API методы сервисов + +### UserService +- `get_or_create_user()` - Получить или создать пользователя +- `set_admin()` - Установить права администратора + +### LotteryService +- `create_lottery()` - Создать розыгрыш +- `set_manual_winner()` - Установить ручного победителя +- `conduct_draw()` - Провести розыгрыш с учетом ручных победителей + +### ParticipationService +- `get_participants_count()` - Количество участников + +## Переключение базы данных + +Благодаря SQLAlchemy ORM, легко переключаться между разными базами данных: + +### SQLite (по умолчанию) +```env +DATABASE_URL=sqlite+aiosqlite:///./lottery_bot.db +``` + +### PostgreSQL +```env +DATABASE_URL=postgresql+asyncpg://username:password@localhost/database_name +``` + +### MySQL +```env +DATABASE_URL=mysql+aiomysql://username:password@localhost/database_name +``` + +## Миграции + +```bash +# Создать новую миграцию после изменения моделей +alembic revision --autogenerate -m "Description of changes" + +# Применить миграции +alembic upgrade head + +# Откатить последнюю миграцию +alembic downgrade -1 +``` + +## Логи + +Бот ведет логи всех операций. Уровень логирования настраивается через переменную `LOG_LEVEL`. + +## Безопасность + +- Права администратора контролируются через `ADMIN_IDS` в `.env` +- Все операции с базой данных асинхронные +- Валидация входных данных на всех уровнях + +## Развертывание + +### Локальное развертывание +Следуйте инструкциям по установке выше. + +### Docker (опционально) +```dockerfile +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY . . +CMD ["python", "main.py"] +``` + +## Поддержка + +Для получения помощи или сообщения об ошибках создайте issue в репозитории проекта. + +## Лицензия + +MIT License \ No newline at end of file diff --git a/account_utils.py b/account_utils.py new file mode 100644 index 0000000..657eca5 --- /dev/null +++ b/account_utils.py @@ -0,0 +1,97 @@ +""" +Утилиты для работы с клиентскими счетами +""" +import re +from typing import Optional + + +def validate_account_number(account_number: str) -> bool: + """ + Проверяет корректность формата номера клиентского счета + Формат: XX-XX-XX-XX-XX-XX-XX-XX (8 пар цифр через дефис) + + Args: + account_number: Номер счета для проверки + + Returns: + bool: True если формат корректен, False иначе + """ + if not account_number: + return False + + # Паттерн для 8 пар цифр через дефис + pattern = r'^\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$' + + return bool(re.match(pattern, account_number)) + + +def format_account_number(account_number: str) -> Optional[str]: + """ + Форматирует номер счета, убирая лишние символы + + Args: + account_number: Исходный номер счета + + Returns: + str: Отформатированный номер счета или None если некорректный + """ + if not account_number: + return None + + # Убираем все символы кроме цифр + digits_only = re.sub(r'\D', '', account_number) + + # Проверяем что осталось ровно 16 цифр + if len(digits_only) != 16: + return None + + # Форматируем как XX-XX-XX-XX-XX-XX-XX-XX + formatted = '-'.join([digits_only[i:i+2] for i in range(0, 16, 2)]) + + return formatted + + +def generate_account_number() -> str: + """ + Генерирует случайный номер клиентского счета для тестирования + + Returns: + str: Сгенерированный номер счета + """ + import random + + # Генерируем 16 случайных цифр + digits = ''.join([str(random.randint(0, 9)) for _ in range(16)]) + + # Форматируем + return '-'.join([digits[i:i+2] for i in range(0, 16, 2)]) + + +def mask_account_number(account_number: str, show_last_digits: int = 4) -> str: + """ + Маскирует номер счета для безопасного отображения + + Args: + account_number: Полный номер счета + show_last_digits: Количество последних цифр для отображения + + Returns: + str: Замаскированный номер счета + """ + if not validate_account_number(account_number): + return "Некорректный номер" + + if show_last_digits <= 0: + return "**-**-**-**-**-**-**-**" + + # Убираем дефисы для работы с цифрами + digits = account_number.replace('-', '') + + # Определяем сколько цифр показать + show_digits = min(show_last_digits, len(digits)) + + # Создаем маску + masked_digits = '*' * (len(digits) - show_digits) + digits[-show_digits:] + + # Возвращаем отформатированный результат + return '-'.join([masked_digits[i:i+2] for i in range(0, 16, 2)]) \ No newline at end of file diff --git a/admin_panel.py b/admin_panel.py new file mode 100644 index 0000000..6c9b112 --- /dev/null +++ b/admin_panel.py @@ -0,0 +1,2304 @@ +""" +Расширенная админ-панель для управления розыгрышами +""" +from aiogram import Router, F +from aiogram.types import ( + CallbackQuery, Message, InlineKeyboardButton, InlineKeyboardMarkup +) +from aiogram.filters import StateFilter +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from sqlalchemy.ext.asyncio import AsyncSession +from datetime import datetime, timedelta +import json + +from database import async_session_maker +from services import UserService, LotteryService, ParticipationService +from config import ADMIN_IDS +from models import User + + +# Состояния для админки +class AdminStates(StatesGroup): + # Создание розыгрыша + lottery_title = State() + lottery_description = State() + lottery_prizes = State() + lottery_confirm = State() + + # Управление участниками + add_participant_lottery = State() + add_participant_user = State() + add_participant_bulk = State() + add_participant_bulk_accounts = State() + remove_participant_lottery = State() + remove_participant_user = State() + remove_participant_bulk = State() + remove_participant_bulk_accounts = State() + participant_search = State() + + # Установка победителей + set_winner_lottery = State() + set_winner_place = State() + set_winner_user = State() + + # Редактирование розыгрыша + edit_lottery_select = State() + edit_lottery_field = State() + edit_lottery_value = State() + + # Настройки отображения победителей + lottery_display_type_select = State() + lottery_display_type_set = State() + + +admin_router = Router() + + +def is_admin(user_id: int) -> bool: + """Проверка прав администратора""" + return user_id in ADMIN_IDS + + +def get_admin_main_keyboard() -> InlineKeyboardMarkup: + """Главная админ-панель""" + buttons = [ + [InlineKeyboardButton(text="🎲 Управление розыгрышами", callback_data="admin_lotteries")], + [InlineKeyboardButton(text="👥 Управление участниками", callback_data="admin_participants")], + [InlineKeyboardButton(text="👑 Управление победителями", callback_data="admin_winners")], + [InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")], + [InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_settings")], + [InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")] + ] + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_lottery_management_keyboard() -> InlineKeyboardMarkup: + """Клавиатура управления розыгрышами""" + buttons = [ + [InlineKeyboardButton(text="➕ Создать розыгрыш", callback_data="admin_create_lottery")], + [InlineKeyboardButton(text="📝 Редактировать розыгрыш", callback_data="admin_edit_lottery")], + [InlineKeyboardButton(text="🎭 Настройка отображения победителей", callback_data="admin_winner_display_settings")], + [InlineKeyboardButton(text="📋 Список всех розыгрышей", callback_data="admin_list_all_lotteries")], + [InlineKeyboardButton(text="🏁 Завершить розыгрыш", callback_data="admin_finish_lottery")], + [InlineKeyboardButton(text="🗑️ Удалить розыгрыш", callback_data="admin_delete_lottery")], + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")] + ] + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_participant_management_keyboard() -> InlineKeyboardMarkup: + """Клавиатура управления участниками""" + buttons = [ + [InlineKeyboardButton(text="➕ Добавить участника", callback_data="admin_add_participant")], + [ + InlineKeyboardButton(text="📥 Массовое добавление (ID)", callback_data="admin_bulk_add_participant"), + InlineKeyboardButton(text="🏦 Массовое добавление (счета)", callback_data="admin_bulk_add_accounts") + ], + [InlineKeyboardButton(text="➖ Удалить участника", callback_data="admin_remove_participant")], + [ + InlineKeyboardButton(text="📤 Массовое удаление (ID)", callback_data="admin_bulk_remove_participant"), + InlineKeyboardButton(text="🏦 Массовое удаление (счета)", callback_data="admin_bulk_remove_accounts") + ], + [InlineKeyboardButton(text="👥 Все участники", callback_data="admin_list_all_participants")], + [InlineKeyboardButton(text="🔍 Поиск участников", callback_data="admin_search_participants")], + [InlineKeyboardButton(text="📊 Участники по розыгрышам", callback_data="admin_participants_by_lottery")], + [InlineKeyboardButton(text="📈 Отчет по участникам", callback_data="admin_participants_report")], + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")] + ] + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_winner_management_keyboard() -> InlineKeyboardMarkup: + """Клавиатура управления победителями""" + buttons = [ + [InlineKeyboardButton(text="👑 Установить победителя", callback_data="admin_set_manual_winner")], + [InlineKeyboardButton(text="📝 Изменить победителя", callback_data="admin_edit_winner")], + [InlineKeyboardButton(text="❌ Удалить победителя", callback_data="admin_remove_winner")], + [InlineKeyboardButton(text="📋 Список победителей", callback_data="admin_list_winners")], + [InlineKeyboardButton(text="🎲 Провести розыгрыш", callback_data="admin_conduct_draw")], + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")] + ] + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +@admin_router.callback_query(F.data == "admin_panel") +async def show_admin_panel(callback: CallbackQuery): + """Показать админ-панель""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + # Быстрая статистика + from sqlalchemy import select, func + from models import User, Lottery, Participation + + users_count = await session.scalar(select(func.count(User.id))) + lotteries_count = await session.scalar(select(func.count(Lottery.id))) + active_lotteries = await session.scalar( + select(func.count(Lottery.id)) + .where(Lottery.is_active == True, Lottery.is_completed == False) + ) + total_participations = await session.scalar(select(func.count(Participation.id))) + + text = f"🔧 Админ-панель\n\n" + text += f"📊 Быстрая статистика:\n" + text += f"👥 Пользователей: {users_count}\n" + text += f"🎲 Всего розыгрышей: {lotteries_count}\n" + text += f"🟢 Активных: {active_lotteries}\n" + text += f"🎫 Участий: {total_participations}\n\n" + text += "Выберите раздел для управления:" + + await callback.message.edit_text(text, reply_markup=get_admin_main_keyboard()) + + +# ====================== +# УПРАВЛЕНИЕ РОЗЫГРЫШАМИ +# ====================== + +@admin_router.callback_query(F.data == "admin_lotteries") +async def show_lottery_management(callback: CallbackQuery): + """Управление розыгрышами""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + text = "🎲 Управление розыгрышами\n\n" + text += "Здесь вы можете создавать, редактировать и управлять розыгрышами.\n\n" + text += "Выберите действие:" + + await callback.message.edit_text(text, reply_markup=get_lottery_management_keyboard()) + + +@admin_router.callback_query(F.data == "admin_create_lottery") +async def start_create_lottery(callback: CallbackQuery, state: FSMContext): + """Начать создание розыгрыша""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + text = "📝 Создание нового розыгрыша\n\n" + text += "Шаг 1 из 4\n\n" + text += "Введите название розыгрыша:" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отмена", callback_data="admin_lotteries")] + ]) + ) + await state.set_state(AdminStates.lottery_title) + + +@admin_router.message(StateFilter(AdminStates.lottery_title)) +async def process_lottery_title(message: Message, state: FSMContext): + """Обработка названия розыгрыша""" + if not is_admin(message.from_user.id): + await message.answer("❌ Недостаточно прав") + return + + await state.update_data(title=message.text) + + text = f"📝 Создание нового розыгрыша\n\n" + text += f"Шаг 2 из 4\n\n" + text += f"✅ Название: {message.text}\n\n" + text += f"Введите описание розыгрыша (или '-' для пропуска):" + + await message.answer(text) + await state.set_state(AdminStates.lottery_description) + + +@admin_router.message(StateFilter(AdminStates.lottery_description)) +async def process_lottery_description(message: Message, state: FSMContext): + """Обработка описания розыгрыша""" + if not is_admin(message.from_user.id): + await message.answer("❌ Недостаточно прав") + return + + description = None if message.text == "-" else message.text + await state.update_data(description=description) + + data = await state.get_data() + + text = f"📝 Создание нового розыгрыша\n\n" + text += f"Шаг 3 из 4\n\n" + text += f"✅ Название: {data['title']}\n" + text += f"✅ Описание: {description or 'Не указано'}\n\n" + text += f"Введите призы (каждый с новой строки):\n\n" + text += f"Пример:\n" + text += f"🥇 iPhone 15 Pro\n" + text += f"🥈 MacBook Air\n" + text += f"🥉 AirPods Pro\n" + text += f"🏆 10,000 рублей" + + await message.answer(text) + await state.set_state(AdminStates.lottery_prizes) + + +@admin_router.message(StateFilter(AdminStates.lottery_prizes)) +async def process_lottery_prizes(message: Message, state: FSMContext): + """Обработка призов розыгрыша""" + if not is_admin(message.from_user.id): + await message.answer("❌ Недостаточно прав") + return + + prizes = [prize.strip() for prize in message.text.split('\n') if prize.strip()] + await state.update_data(prizes=prizes) + + data = await state.get_data() + + text = f"📝 Создание нового розыгрыша\n\n" + text += f"Шаг 4 из 4 - Подтверждение\n\n" + text += f"🎯 Название: {data['title']}\n" + text += f"📋 Описание: {data['description'] or 'Не указано'}\n\n" + text += f"🏆 Призы:\n" + for i, prize in enumerate(prizes, 1): + text += f"{i}. {prize}\n" + + text += f"\n✅ Подтвердите создание розыгрыша:" + + await message.answer( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="✅ Создать", callback_data="confirm_create_lottery")], + [InlineKeyboardButton(text="❌ Отмена", callback_data="admin_lotteries")] + ]) + ) + await state.set_state(AdminStates.lottery_confirm) + + +@admin_router.callback_query(F.data == "confirm_create_lottery", StateFilter(AdminStates.lottery_confirm)) +async def confirm_create_lottery(callback: CallbackQuery, state: FSMContext): + """Подтверждение создания розыгрыша""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + data = await state.get_data() + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + + lottery = await LotteryService.create_lottery( + session, + title=data['title'], + description=data['description'], + prizes=data['prizes'], + creator_id=user.id + ) + + await state.clear() + + text = f"✅ Розыгрыш успешно создан!\n\n" + text += f"🆔 ID: {lottery.id}\n" + text += f"🎯 Название: {lottery.title}\n" + text += f"📅 Создан: {lottery.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" + text += f"Розыгрыш доступен для участников." + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎲 К управлению розыгрышами", callback_data="admin_lotteries")], + [InlineKeyboardButton(text="🏠 В главное меню", callback_data="back_to_main")] + ]) + ) + + +@admin_router.callback_query(F.data == "admin_list_all_lotteries") +async def list_all_lotteries(callback: CallbackQuery): + """Список всех розыгрышей""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + from sqlalchemy import select + from models import Lottery + + result = await session.execute( + select(Lottery).order_by(Lottery.created_at.desc()) + ) + lotteries = result.scalars().all() + + if not lotteries: + text = "📋 Розыгрышей пока нет" + buttons = [[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_lotteries")]] + else: + text = f"📋 Все розыгрыши ({len(lotteries)}):\n\n" + buttons = [] + + for lottery in lotteries[:10]: # Показываем первые 10 + status = "🟢" if lottery.is_active and not lottery.is_completed else "✅" if lottery.is_completed else "🔴" + + async with async_session_maker() as session: + participants_count = await ParticipationService.get_participants_count( + session, lottery.id + ) + + text += f"{status} {lottery.title}\n" + text += f" ID: {lottery.id} | Участников: {participants_count}\n" + text += f" Создан: {lottery.created_at.strftime('%d.%m %H:%M')}\n\n" + + buttons.append([ + InlineKeyboardButton( + text=f"📝 {lottery.title[:25]}..." if len(lottery.title) > 25 else lottery.title, + callback_data=f"admin_lottery_detail_{lottery.id}" + ) + ]) + + if len(lotteries) > 10: + text += f"... и еще {len(lotteries) - 10} розыгрышей" + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_lotteries")]) + + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_lottery_detail_")) +async def show_lottery_detail(callback: CallbackQuery): + """Детальная информация о розыгрыше""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + 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 + + participants_count = await ParticipationService.get_participants_count(session, lottery_id) + winners = await LotteryService.get_winners(session, lottery_id) if lottery.is_completed else [] + + status_emoji = "🟢" if lottery.is_active and not lottery.is_completed else "✅" if lottery.is_completed else "🔴" + status_text = "Активен" if lottery.is_active and not lottery.is_completed else "Завершен" if lottery.is_completed else "Неактивен" + + text = f"🎲 Детали розыгрыша\n\n" + text += f"🆔 ID: {lottery.id}\n" + text += f"🎯 Название: {lottery.title}\n" + text += f"📋 Описание: {lottery.description or 'Не указано'}\n" + text += f"{status_emoji} Статус: {status_text}\n" + text += f"👥 Участников: {participants_count}\n" + text += f"📅 Создан: {lottery.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" + + if lottery.prizes: + text += f"🏆 Призы:\n" + for i, prize in enumerate(lottery.prizes, 1): + text += f"{i}. {prize}\n" + text += "\n" + + # Ручные победители + if lottery.manual_winners: + text += f"👑 Предустановленные победители:\n" + for place, telegram_id in lottery.manual_winners.items(): + async with async_session_maker() as session: + winner_user = await UserService.get_user_by_telegram_id(session, telegram_id) + name = winner_user.username if winner_user and winner_user.username else str(telegram_id) + text += f"{place} место: @{name}\n" + text += "\n" + + # Результаты розыгрыша + if lottery.is_completed and winners: + text += f"🏆 Результаты:\n" + for winner in winners: + manual_mark = " 👑" if winner.is_manual else "" + username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name + text += f"{winner.place}. {username}{manual_mark}\n" + + buttons = [] + + if not lottery.is_completed: + buttons.extend([ + [InlineKeyboardButton(text="👑 Установить победителя", callback_data=f"admin_set_winner_{lottery_id}")], + [InlineKeyboardButton(text="🎲 Провести розыгрыш", callback_data=f"admin_conduct_{lottery_id}")], + ]) + + buttons.extend([ + [InlineKeyboardButton(text="📝 Редактировать", callback_data=f"admin_edit_{lottery_id}")], + [InlineKeyboardButton(text="👥 Участники", callback_data=f"admin_participants_{lottery_id}")], + [InlineKeyboardButton(text="🔙 К списку", callback_data="admin_list_all_lotteries")] + ]) + + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +# ====================== +# УПРАВЛЕНИЕ УЧАСТНИКАМИ +# ====================== + +@admin_router.callback_query(F.data == "admin_participants") +async def show_participant_management(callback: CallbackQuery): + """Управление участниками""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + text = "👥 Управление участниками\n\n" + text += "Здесь вы можете добавлять и удалять участников розыгрышей.\n\n" + text += "Выберите действие:" + + await callback.message.edit_text(text, reply_markup=get_participant_management_keyboard()) + + +@admin_router.callback_query(F.data.startswith("admin_participants_")) +async def show_lottery_participants(callback: CallbackQuery): + """Показать участников конкретного розыгрыша""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + 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 + + text = f"👥 Участники розыгрыша\n" + text += f"🎯 {lottery.title}\n\n" + + if not lottery.participations: + text += "Участников пока нет" + buttons = [[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")]] + else: + text += f"Всего участников: {len(lottery.participations)}\n\n" + + 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" + text += f" Участвует с: {participation.created_at.strftime('%d.%m %H:%M')}\n\n" + + if len(lottery.participations) > 20: + text += f"... и еще {len(lottery.participations) - 20} участников" + + buttons = [ + [InlineKeyboardButton(text="➕ Добавить участника", callback_data=f"admin_add_to_{lottery_id}")], + [InlineKeyboardButton(text="➖ Удалить участника", callback_data=f"admin_remove_from_{lottery_id}")], + [InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_lottery_detail_{lottery_id}")] + ] + + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +# ====================== +# НОВЫЕ ХЭНДЛЕРЫ ДЛЯ УПРАВЛЕНИЯ УЧАСТНИКАМИ +# ====================== + +@admin_router.callback_query(F.data == "admin_add_participant") +async def start_add_participant(callback: CallbackQuery, state: FSMContext): + """Начать добавление участника""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + lotteries = await LotteryService.get_active_lotteries(session) + + if not lotteries: + await callback.message.edit_text( + "❌ Нет активных розыгрышей", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")] + ]) + ) + return + + text = "➕ Добавление участника\n\n" + text += "Выберите розыгрыш:\n\n" + + buttons = [] + for lottery in lotteries: + async with async_session_maker() as session: + count = await ParticipationService.get_participants_count(session, lottery.id) + text += f"🎯 {lottery.title} (участников: {count})\n" + buttons.append([ + InlineKeyboardButton( + text=f"🎯 {lottery.title[:35]}...", + callback_data=f"admin_add_part_to_{lottery.id}" + ) + ]) + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")]) + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_add_part_to_")) +async def choose_user_to_add(callback: CallbackQuery, state: FSMContext): + """Выбор пользователя для добавления""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[-1]) + await state.update_data(add_participant_lottery_id=lottery_id) + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + + text = f"➕ Добавление в: {lottery.title}\n\n" + text += "Введите Telegram ID или username пользователя:\n\n" + text += "Примеры:\n" + text += "• @username\n" + text += "• 123456789" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отмена", callback_data="admin_add_participant")] + ]) + ) + await state.set_state(AdminStates.add_participant_user) + + +@admin_router.message(StateFilter(AdminStates.add_participant_user)) +async def process_add_participant(message: Message, state: FSMContext): + """Обработка добавления участника""" + if not is_admin(message.from_user.id): + await message.answer("❌ Недостаточно прав") + return + + data = await state.get_data() + lottery_id = data['add_participant_lottery_id'] + user_input = message.text.strip() + + async with async_session_maker() as session: + # Ищем пользователя + user = None + if user_input.startswith('@'): + username = user_input[1:] + user = await UserService.get_user_by_username(session, username) + elif user_input.isdigit(): + telegram_id = int(user_input) + user = await UserService.get_user_by_telegram_id(session, telegram_id) + + if not user: + await message.answer( + "❌ Пользователь не найден в системе.\n" + "Пользователь должен сначала запустить бота командой /start" + ) + return + + # Добавляем участника + success = await ParticipationService.add_participant(session, lottery_id, user.id) + lottery = await LotteryService.get_lottery(session, lottery_id) + + await state.clear() + + if success: + username = f"@{user.username}" if user.username else "Нет username" + await message.answer( + f"✅ Участник добавлен!\n\n" + f"👤 Пользователь: {user.first_name} {user.last_name or ''}\n" + f"📱 Username: {username}\n" + f"🆔 ID: {user.telegram_id}\n" + f"🎯 Розыгрыш: {lottery.title}", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")] + ]) + ) + else: + await message.answer( + f"⚠️ Пользователь {user.first_name} уже участвует в этом розыгрыше", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")] + ]) + ) + + +@admin_router.callback_query(F.data == "admin_list_all_participants") +async def list_all_participants(callback: CallbackQuery): + """Список всех участников""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + users = await UserService.get_all_users(session, limit=50) + + # Получаем статистику для каждого пользователя + user_stats = [] + for user in users: + stats = await ParticipationService.get_participant_stats(session, user.id) + user_stats.append((user, stats)) + + if not user_stats: + await callback.message.edit_text( + "❌ В системе нет пользователей", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")] + ]) + ) + return + + text = "👥 Все участники системы\n\n" + text += f"Всего пользователей: {len(users)}\n\n" + + for i, (user, stats) in enumerate(user_stats[:20], 1): + 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" + text += f" 🎫 Участий: {stats['participations_count']} | 🏆 Побед: {stats['wins_count']}\n" + if stats['last_participation']: + text += f" 📅 Последнее участие: {stats['last_participation'].strftime('%d.%m.%Y')}\n" + text += "\n" + + if len(users) > 20: + text += f"... и еще {len(users) - 20} пользователей" + + buttons = [ + [InlineKeyboardButton(text="📊 Подробный отчет", callback_data="admin_participants_report")], + [InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_list_all_participants")], + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")] + ] + + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data == "admin_participants_report") +async def generate_participants_report(callback: CallbackQuery): + """Генерация отчета по участникам""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + from sqlalchemy import func, select + from models import User, Participation, Winner + + # Общие статистики + total_users = await session.scalar(select(func.count(User.id))) + total_participations = await session.scalar(select(func.count(Participation.id))) + total_winners = await session.scalar(select(func.count(Winner.id))) + + # Топ участников по количеству участий + top_participants = await session.execute( + select(User.first_name, User.username, func.count(Participation.id).label('count')) + .join(Participation) + .group_by(User.id) + .order_by(func.count(Participation.id).desc()) + .limit(10) + ) + top_participants = top_participants.fetchall() + + # Топ победителей + top_winners = await session.execute( + select(User.first_name, User.username, func.count(Winner.id).label('wins')) + .join(Winner) + .group_by(User.id) + .order_by(func.count(Winner.id).desc()) + .limit(5) + ) + top_winners = top_winners.fetchall() + + # Недавняя активность + recent_users = await session.execute( + select(User.first_name, User.username, User.created_at) + .order_by(User.created_at.desc()) + .limit(5) + ) + recent_users = recent_users.fetchall() + + text = "📈 Подробный отчет по участникам\n\n" + + text += "📊 ОБЩАЯ СТАТИСТИКА\n" + text += f"👥 Всего пользователей: {total_users}\n" + text += f"🎫 Всего участий: {total_participations}\n" + text += f"🏆 Всего побед: {total_winners}\n" + if total_users > 0: + avg_participations = total_participations / total_users + text += f"📈 Среднее участий на пользователя: {avg_participations:.1f}\n" + text += "\n" + + if top_participants: + text += "🔥 ТОП УЧАСТНИКИ (по количеству участий)\n" + for i, (first_name, username, count) in enumerate(top_participants, 1): + name = f"@{username}" if username else first_name + text += f"{i}. {name} - {count} участий\n" + text += "\n" + + if top_winners: + text += "👑 ТОП ПОБЕДИТЕЛИ\n" + for i, (first_name, username, wins) in enumerate(top_winners, 1): + name = f"@{username}" if username else first_name + text += f"{i}. {name} - {wins} побед\n" + text += "\n" + + if recent_users: + text += "🆕 НЕДАВНИЕ РЕГИСТРАЦИИ\n" + for first_name, username, created_at in recent_users: + name = f"@{username}" if username else first_name + text += f"• {name} - {created_at.strftime('%d.%m.%Y %H:%M')}\n" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="💾 Экспорт данных", callback_data="admin_export_participants")], + [InlineKeyboardButton(text="🔄 Обновить отчет", callback_data="admin_participants_report")], + [InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")] + ]) + ) + + +@admin_router.callback_query(F.data == "admin_export_participants") +async def export_participants_data(callback: CallbackQuery): + """Экспорт данных участников""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + await callback.answer("📊 Генерируем отчет...", show_alert=False) + + async with async_session_maker() as session: + users = await UserService.get_all_users(session) + + export_data = { + "timestamp": datetime.now().isoformat(), + "total_users": len(users), + "users": [] + } + + for user in users: + stats = await ParticipationService.get_participant_stats(session, user.id) + user_data = { + "id": user.id, + "telegram_id": user.telegram_id, + "first_name": user.first_name, + "last_name": user.last_name, + "username": user.username, + "created_at": user.created_at.isoformat() if user.created_at else None, + "participations_count": stats["participations_count"], + "wins_count": stats["wins_count"], + "last_participation": stats["last_participation"].isoformat() if stats["last_participation"] else None + } + export_data["users"].append(user_data) + + # Формируем JSON для вывода + import json + json_data = json.dumps(export_data, ensure_ascii=False, indent=2) + + # Отправляем JSON как текст (в реальном боте можно отправить как файл) + text = f"📊 Экспорт данных участников\n\n" + text += f"Дата: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n" + text += f"Всего пользователей: {len(users)}\n\n" + text += "Данные готовы к экспорту (JSON формат)\n" + text += f"Размер: {len(json_data)} символов" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📈 К отчету", callback_data="admin_participants_report")], + [InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")] + ]) + ) + + +@admin_router.callback_query(F.data == "admin_search_participants") +async def start_search_participants(callback: CallbackQuery, state: FSMContext): + """Начать поиск участников""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + text = "🔍 Поиск участников\n\n" + text += "Введите имя, фамилию или username для поиска:\n\n" + text += "Примеры:\n" + text += "• Иван\n" + text += "• username\n" + text += "• Петров" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отмена", callback_data="admin_participants")] + ]) + ) + await state.set_state(AdminStates.participant_search) + + +@admin_router.message(StateFilter(AdminStates.participant_search)) +async def process_search_participants(message: Message, state: FSMContext): + """Обработка поиска участников""" + if not is_admin(message.from_user.id): + await message.answer("❌ Недостаточно прав") + return + + search_term = message.text.strip() + + async with async_session_maker() as session: + users = await UserService.search_users(session, search_term) + + # Получаем статистику для найденных пользователей + user_stats = [] + for user in users: + stats = await ParticipationService.get_participant_stats(session, user.id) + user_stats.append((user, stats)) + + await state.clear() + + if not user_stats: + await message.answer( + f"❌ Пользователи с поисковым запросом '{search_term}' не найдены", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")] + ]) + ) + return + + text = f"🔍 Результаты поиска: '{search_term}'\n\n" + text += f"Найдено: {len(users)} пользователей\n\n" + + for i, (user, stats) in enumerate(user_stats[:15], 1): + 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" + text += f" 🎫 Участий: {stats['participations_count']} | 🏆 Побед: {stats['wins_count']}\n" + text += "\n" + + if len(users) > 15: + text += f"... и еще {len(users) - 15} найденных пользователей" + + await message.answer( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔍 Новый поиск", callback_data="admin_search_participants")], + [InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")] + ]) + ) + + +@admin_router.callback_query(F.data == "admin_bulk_add_participant") +async def start_bulk_add_participant(callback: CallbackQuery, state: FSMContext): + """Начать массовое добавление участников""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + lotteries = await LotteryService.get_active_lotteries(session) + + if not lotteries: + await callback.message.edit_text( + "❌ Нет активных розыгрышей", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")] + ]) + ) + return + + text = "📥 Массовое добавление участников\n\n" + text += "Выберите розыгрыш:\n\n" + + buttons = [] + for lottery in lotteries: + async with async_session_maker() as session: + count = await ParticipationService.get_participants_count(session, lottery.id) + text += f"🎯 {lottery.title} (участников: {count})\n" + buttons.append([ + InlineKeyboardButton( + text=f"🎯 {lottery.title[:35]}...", + callback_data=f"admin_bulk_add_to_{lottery.id}" + ) + ]) + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")]) + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_bulk_add_to_")) +async def choose_users_bulk_add(callback: CallbackQuery, state: FSMContext): + """Выбор пользователей для массового добавления""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[-1]) + await state.update_data(bulk_add_lottery_id=lottery_id) + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + + text = f"📥 Массовое добавление в: {lottery.title}\n\n" + text += "Введите список Telegram ID или username через запятую:\n\n" + text += "Примеры:\n" + text += "• @user1, @user2, @user3\n" + text += "• 123456789, 987654321, 555444333\n" + text += "• @user1, 123456789, @user3" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отмена", callback_data="admin_bulk_add_participant")] + ]) + ) + await state.set_state(AdminStates.add_participant_bulk) + + +@admin_router.message(StateFilter(AdminStates.add_participant_bulk)) +async def process_bulk_add_participant(message: Message, state: FSMContext): + """Обработка массового добавления участников""" + if not is_admin(message.from_user.id): + await message.answer("❌ Недостаточно прав") + return + + data = await state.get_data() + lottery_id = data['bulk_add_lottery_id'] + + # Парсим входные данные + user_inputs = [x.strip() for x in message.text.split(',') if x.strip()] + telegram_ids = [] + + async with async_session_maker() as session: + for user_input in user_inputs: + try: + if user_input.startswith('@'): + username = user_input[1:] + user = await UserService.get_user_by_username(session, username) + if user: + telegram_ids.append(user.telegram_id) + elif user_input.isdigit(): + telegram_ids.append(int(user_input)) + except: + continue + + # Массовое добавление + results = await ParticipationService.add_participants_bulk(session, lottery_id, telegram_ids) + lottery = await LotteryService.get_lottery(session, lottery_id) + + await state.clear() + + text = f"📥 Результат массового добавления\n\n" + text += f"🎯 Розыгрыш: {lottery.title}\n\n" + text += f"✅ Добавлено: {results['added']}\n" + text += f"⚠️ Уже участвуют: {results['skipped']}\n" + text += f"❌ Ошибок: {len(results['errors'])}\n\n" + + if results['details']: + text += "Детали:\n" + for detail in results['details'][:10]: # Первые 10 + text += f"• {detail}\n" + if len(results['details']) > 10: + text += f"... и еще {len(results['details']) - 10} записей" + + await message.answer( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")] + ]) + ) + + +@admin_router.callback_query(F.data == "admin_bulk_remove_participant") +async def start_bulk_remove_participant(callback: CallbackQuery, state: FSMContext): + """Начать массовое удаление участников""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + lotteries = await LotteryService.get_all_lotteries(session) + + if not lotteries: + await callback.message.edit_text( + "❌ Нет розыгрышей", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")] + ]) + ) + return + + text = "📤 Массовое удаление участников\n\n" + text += "Выберите розыгрыш:\n\n" + + buttons = [] + for lottery in lotteries: + async with async_session_maker() as session: + count = await ParticipationService.get_participants_count(session, lottery.id) + if count > 0: + text += f"🎯 {lottery.title} (участников: {count})\n" + buttons.append([ + InlineKeyboardButton( + text=f"🎯 {lottery.title[:35]}...", + callback_data=f"admin_bulk_remove_from_{lottery.id}" + ) + ]) + + if not buttons: + await callback.message.edit_text( + "❌ Нет розыгрышей с участниками", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")] + ]) + ) + return + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")]) + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_bulk_remove_from_")) +async def choose_users_bulk_remove(callback: CallbackQuery, state: FSMContext): + """Выбор пользователей для массового удаления""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[-1]) + await state.update_data(bulk_remove_lottery_id=lottery_id) + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + + text = f"📤 Массовое удаление из: {lottery.title}\n\n" + text += "Введите список Telegram ID или username через запятую:\n\n" + text += "Примеры:\n" + text += "• @user1, @user2, @user3\n" + text += "• 123456789, 987654321, 555444333\n" + text += "• @user1, 123456789, @user3" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отмена", callback_data="admin_bulk_remove_participant")] + ]) + ) + await state.set_state(AdminStates.remove_participant_bulk) + + +@admin_router.message(StateFilter(AdminStates.remove_participant_bulk)) +async def process_bulk_remove_participant(message: Message, state: FSMContext): + """Обработка массового удаления участников""" + if not is_admin(message.from_user.id): + await message.answer("❌ Недостаточно прав") + return + + data = await state.get_data() + lottery_id = data['bulk_remove_lottery_id'] + + # Парсим входные данные + user_inputs = [x.strip() for x in message.text.split(',') if x.strip()] + telegram_ids = [] + + async with async_session_maker() as session: + for user_input in user_inputs: + try: + if user_input.startswith('@'): + username = user_input[1:] + user = await UserService.get_user_by_username(session, username) + if user: + telegram_ids.append(user.telegram_id) + elif user_input.isdigit(): + telegram_ids.append(int(user_input)) + except: + continue + + # Массовое удаление + results = await ParticipationService.remove_participants_bulk(session, lottery_id, telegram_ids) + lottery = await LotteryService.get_lottery(session, lottery_id) + + await state.clear() + + text = f"📤 Результат массового удаления\n\n" + text += f"🎯 Розыгрыш: {lottery.title}\n\n" + text += f"✅ Удалено: {results['removed']}\n" + text += f"⚠️ Не найдено: {results['not_found']}\n" + text += f"❌ Ошибок: {len(results['errors'])}\n\n" + + if results['details']: + text += "Детали:\n" + for detail in results['details'][:10]: # Первые 10 + text += f"• {detail}\n" + if len(results['details']) > 10: + text += f"... и еще {len(results['details']) - 10} записей" + + await message.answer( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")] + ]) + ) + + +# ====================== +# МАССОВОЕ УПРАВЛЕНИЕ УЧАСТНИКАМИ ПО СЧЕТАМ +# ====================== + +@admin_router.callback_query(F.data == "admin_bulk_add_accounts") +async def start_bulk_add_accounts(callback: CallbackQuery, state: FSMContext): + """Начать массовое добавление участников по номерам счетов""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + lotteries = await LotteryService.get_active_lotteries(session) + + if not lotteries: + await callback.message.edit_text( + "❌ Нет активных розыгрышей", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")] + ]) + ) + return + + text = "🏦 Массовое добавление по номерам счетов\n\n" + text += "Выберите розыгрыш:\n\n" + + buttons = [] + for lottery in lotteries: + async with async_session_maker() as session: + count = await ParticipationService.get_participants_count(session, lottery.id) + text += f"🎯 {lottery.title} (участников: {count})\n" + buttons.append([ + InlineKeyboardButton( + text=f"🎯 {lottery.title[:35]}...", + callback_data=f"admin_bulk_add_accounts_to_{lottery.id}" + ) + ]) + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")]) + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_bulk_add_accounts_to_")) +async def choose_accounts_bulk_add(callback: CallbackQuery, state: FSMContext): + """Выбор номеров счетов для массового добавления""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[-1]) + await state.update_data(bulk_add_accounts_lottery_id=lottery_id) + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + + text = f"🏦 Массовое добавление в: {lottery.title}\n\n" + text += "Введите список номеров счетов через запятую или новую строку:\n\n" + text += "Примеры:\n" + text += "• 12-34-56-78-90-12-34-56\n" + text += "• 98-76-54-32-10-98-76-54, 11-22-33-44-55-66-77-88\n" + text += "• 12345678901234567890 (будет отформатирован)\n\n" + text += "Формат: XX-XX-XX-XX-XX-XX-XX-XX\n" + text += "Всего 8 пар цифр разделенных дефисами" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отмена", callback_data="admin_bulk_add_accounts")] + ]) + ) + await state.set_state(AdminStates.add_participant_bulk_accounts) + + +@admin_router.message(StateFilter(AdminStates.add_participant_bulk_accounts)) +async def process_bulk_add_accounts(message: Message, state: FSMContext): + """Обработка массового добавления участников по номерам счетов""" + if not is_admin(message.from_user.id): + await message.answer("❌ Недостаточно прав") + return + + data = await state.get_data() + lottery_id = data['bulk_add_accounts_lottery_id'] + + # Парсим входные данные - поддерживаем и запятые, и переносы строк + account_inputs = [] + for line in message.text.split('\n'): + for account in line.split(','): + account = account.strip() + if account: + account_inputs.append(account) + + async with async_session_maker() as session: + # Массовое добавление по номерам счетов + results = await ParticipationService.add_participants_by_accounts_bulk(session, lottery_id, account_inputs) + lottery = await LotteryService.get_lottery(session, lottery_id) + + await state.clear() + + text = f"🏦 Результат массового добавления по счетам\n\n" + text += f"🎯 Розыгрыш: {lottery.title}\n\n" + text += f"✅ Добавлено: {results['added']}\n" + text += f"⚠️ Уже участвуют: {results['skipped']}\n" + text += f"🚫 Неверных форматов: {len(results['invalid_accounts'])}\n" + text += f"❌ Ошибок: {len(results['errors'])}\n\n" + + if results['details']: + text += "✅ Успешно добавлены:\n" + for detail in results['details'][:7]: # Первые 7 + text += f"• {detail}\n" + if len(results['details']) > 7: + text += f"... и еще {len(results['details']) - 7} записей\n\n" + + if results['invalid_accounts']: + text += "\n🚫 Неверные форматы:\n" + for invalid in results['invalid_accounts'][:5]: + text += f"• {invalid}\n" + if len(results['invalid_accounts']) > 5: + text += f"... и еще {len(results['invalid_accounts']) - 5} номеров\n" + + if results['errors']: + text += "\n❌ Ошибки:\n" + for error in results['errors'][:3]: + text += f"• {error}\n" + if len(results['errors']) > 3: + text += f"... и еще {len(results['errors']) - 3} ошибок\n" + + await message.answer( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")] + ]) + ) + + +@admin_router.callback_query(F.data == "admin_bulk_remove_accounts") +async def start_bulk_remove_accounts(callback: CallbackQuery, state: FSMContext): + """Начать массовое удаление участников по номерам счетов""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + lotteries = await LotteryService.get_all_lotteries(session) + + if not lotteries: + await callback.message.edit_text( + "❌ Нет розыгрышей", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")] + ]) + ) + return + + text = "🏦 Массовое удаление по номерам счетов\n\n" + text += "Выберите розыгрыш:\n\n" + + buttons = [] + for lottery in lotteries: + async with async_session_maker() as session: + count = await ParticipationService.get_participants_count(session, lottery.id) + text += f"🎯 {lottery.title} (участников: {count})\n" + buttons.append([ + InlineKeyboardButton( + text=f"🎯 {lottery.title[:35]}...", + callback_data=f"admin_bulk_remove_accounts_from_{lottery.id}" + ) + ]) + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")]) + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_bulk_remove_accounts_from_")) +async def choose_accounts_bulk_remove(callback: CallbackQuery, state: FSMContext): + """Выбор номеров счетов для массового удаления""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[-1]) + await state.update_data(bulk_remove_accounts_lottery_id=lottery_id) + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + + text = f"🏦 Массовое удаление из: {lottery.title}\n\n" + text += "Введите список номеров счетов через запятую или новую строку:\n\n" + text += "Примеры:\n" + text += "• 12-34-56-78-90-12-34-56\n" + text += "• 98-76-54-32-10-98-76-54, 11-22-33-44-55-66-77-88\n" + text += "• 12345678901234567890 (будет отформатирован)\n\n" + text += "Формат: XX-XX-XX-XX-XX-XX-XX-XX" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отмена", callback_data="admin_bulk_remove_accounts")] + ]) + ) + await state.set_state(AdminStates.remove_participant_bulk_accounts) + + +@admin_router.message(StateFilter(AdminStates.remove_participant_bulk_accounts)) +async def process_bulk_remove_accounts(message: Message, state: FSMContext): + """Обработка массового удаления участников по номерам счетов""" + if not is_admin(message.from_user.id): + await message.answer("❌ Недостаточно прав") + return + + data = await state.get_data() + lottery_id = data['bulk_remove_accounts_lottery_id'] + + # Парсим входные данные - поддерживаем и запятые, и переносы строк + account_inputs = [] + for line in message.text.split('\n'): + for account in line.split(','): + account = account.strip() + if account: + account_inputs.append(account) + + async with async_session_maker() as session: + # Массовое удаление по номерам счетов + results = await ParticipationService.remove_participants_by_accounts_bulk(session, lottery_id, account_inputs) + lottery = await LotteryService.get_lottery(session, lottery_id) + + await state.clear() + + text = f"🏦 Результат массового удаления по счетам\n\n" + text += f"🎯 Розыгрыш: {lottery.title}\n\n" + text += f"✅ Удалено: {results['removed']}\n" + text += f"⚠️ Не найдено: {results['not_found']}\n" + text += f"🚫 Неверных форматов: {len(results['invalid_accounts'])}\n" + text += f"❌ Ошибок: {len(results['errors'])}\n\n" + + if results['details']: + text += "✅ Успешно удалены:\n" + for detail in results['details'][:7]: # Первые 7 + text += f"• {detail}\n" + if len(results['details']) > 7: + text += f"... и еще {len(results['details']) - 7} записей\n\n" + + if results['invalid_accounts']: + text += "\n🚫 Неверные форматы:\n" + for invalid in results['invalid_accounts'][:5]: + text += f"• {invalid}\n" + if len(results['invalid_accounts']) > 5: + text += f"... и еще {len(results['invalid_accounts']) - 5} номеров\n" + + if results['errors']: + text += "\n❌ Ошибки:\n" + for error in results['errors'][:3]: + text += f"• {error}\n" + if len(results['errors']) > 3: + text += f"... и еще {len(results['errors']) - 3} ошибок\n" + + await message.answer( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")] + ]) + ) + + +# ====================== +# ДОПОЛНИТЕЛЬНЫЕ ХЭНДЛЕРЫ УЧАСТНИКОВ +# ====================== + +@admin_router.callback_query(F.data == "admin_participants_by_lottery") +async def show_participants_by_lottery(callback: CallbackQuery): + """Показать участников по розыгрышам""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + lotteries = await LotteryService.get_all_lotteries(session) + + if not lotteries: + await callback.message.edit_text( + "❌ Нет розыгрышей", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")] + ]) + ) + return + + text = "📊 Участники по розыгрышам\n\n" + + for lottery in lotteries[:15]: # Показываем первые 15 + async with async_session_maker() as session: + count = await ParticipationService.get_participants_count(session, lottery.id) + + status = "🟢" if getattr(lottery, 'is_active', True) else "🔴" + text += f"{status} {lottery.title}: {count} участников\n" + + if len(lotteries) > 15: + text += f"\n... и еще {len(lotteries) - 15} розыгрышей" + + buttons = [] + for lottery in lotteries[:10]: # Кнопки для первых 10 + buttons.append([ + InlineKeyboardButton( + text=f"👥 {lottery.title[:30]}...", + callback_data=f"admin_participants_{lottery.id}" + ) + ]) + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")]) + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data == "admin_participants_report") +async def show_participants_report(callback: CallbackQuery): + """Отчет по участникам""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + from sqlalchemy import func, select + from models import User, Participation, Lottery + + # Общая статистика по участникам + total_participants = await session.scalar( + select(func.count(func.distinct(User.id))) + .select_from(User) + .join(Participation) + ) + + total_participations = await session.scalar(select(func.count(Participation.id))) + + # Топ активных участников + top_participants = await session.execute( + select( + User.first_name, + User.username, + User.account_number, + func.count(Participation.id).label('participations') + ) + .join(Participation) + .group_by(User.id) + .order_by(func.count(Participation.id).desc()) + .limit(10) + ) + top_participants = top_participants.fetchall() + + # Участники с аккаунтами vs без + users_with_accounts = await session.scalar( + select(func.count(User.id)).where(User.account_number.isnot(None)) + ) + + users_without_accounts = await session.scalar( + select(func.count(User.id)).where(User.account_number.is_(None)) + ) + + text = "📈 Отчет по участникам\n\n" + text += f"👥 Всего уникальных участников: {total_participants}\n" + text += f"📊 Всего участий: {total_participations}\n" + text += f"🏦 С номерами счетов: {users_with_accounts}\n" + text += f"🆔 Только Telegram ID: {users_without_accounts}\n\n" + + if top_participants: + text += "🏆 Топ-10 активных участников:\n" + for i, (name, username, account, count) in enumerate(top_participants, 1): + display_name = f"@{username}" if username else name + if account: + display_name += f" ({account[-7:]})" # Последние 7 символов счёта + text += f"{i}. {display_name} - {count} участий\n" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_participants_report")], + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_participants")] + ]) + ) + + +@admin_router.callback_query(F.data == "admin_edit_lottery") +async def start_edit_lottery(callback: CallbackQuery, state: FSMContext): + """Начать редактирование розыгрыша""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + lotteries = await LotteryService.get_all_lotteries(session) + + if not lotteries: + await callback.message.edit_text( + "❌ Нет розыгрышей для редактирования", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_lotteries")] + ]) + ) + return + + text = "📝 Редактирование розыгрыша\n\n" + text += "Выберите розыгрыш для редактирования:\n\n" + + buttons = [] + for lottery in lotteries[:10]: # Первые 10 розыгрышей + status = "🟢" if getattr(lottery, 'is_active', True) else "🔴" + text += f"{status} {lottery.title}\n" + buttons.append([ + InlineKeyboardButton( + text=f"📝 {lottery.title[:30]}...", + callback_data=f"admin_edit_lottery_select_{lottery.id}" + ) + ]) + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_lotteries")]) + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_edit_lottery_select_")) +async def choose_edit_field(callback: CallbackQuery, state: FSMContext): + """Выбор поля для редактирования""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[-1]) + await state.update_data(edit_lottery_id=lottery_id) + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + + text = f"📝 Редактирование: {lottery.title}\n\n" + text += "Выберите, что хотите изменить:\n\n" + text += f"📝 Название: {lottery.title}\n" + text += f"📄 Описание: {lottery.description[:50]}{'...' if len(lottery.description) > 50 else ''}\n" + text += f"🎁 Призы: {len(getattr(lottery, 'prizes', []))} шт.\n" + text += f"🎭 Отображение: {getattr(lottery, 'winner_display_type', 'username')}\n" + text += f"🟢 Активен: {'Да' if getattr(lottery, 'is_active', True) else 'Нет'}" + + buttons = [ + [InlineKeyboardButton(text="📝 Изменить название", callback_data=f"admin_edit_field_{lottery_id}_title")], + [InlineKeyboardButton(text="📄 Изменить описание", callback_data=f"admin_edit_field_{lottery_id}_description")], + [InlineKeyboardButton(text="🎁 Изменить призы", callback_data=f"admin_edit_field_{lottery_id}_prizes")], + [ + InlineKeyboardButton(text="⏸️ Деактивировать" if getattr(lottery, 'is_active', True) else "▶️ Активировать", + callback_data=f"admin_toggle_active_{lottery_id}"), + InlineKeyboardButton(text="🎭 Тип отображения", callback_data=f"admin_set_display_{lottery_id}") + ], + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_edit_lottery")] + ] + + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_toggle_active_")) +async def toggle_lottery_active(callback: CallbackQuery): + """Переключить активность розыгрыша""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[-1]) + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + current_active = getattr(lottery, 'is_active', True) + + # Переключаем статус + success = await LotteryService.set_lottery_active(session, lottery_id, not current_active) + + if success: + new_status = "активирован" if not current_active else "деактивирован" + await callback.answer(f"✅ Розыгрыш {new_status}!", show_alert=True) + else: + await callback.answer("❌ Ошибка изменения статуса", show_alert=True) + + # Обновляем отображение + await choose_edit_field(callback, None) + + +@admin_router.callback_query(F.data == "admin_finish_lottery") +async def start_finish_lottery(callback: CallbackQuery): + """Завершить розыгрыш""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + lotteries = await LotteryService.get_active_lotteries(session) + + if not lotteries: + await callback.message.edit_text( + "❌ Нет активных розыгрышей для завершения", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_lotteries")] + ]) + ) + return + + text = "🏁 Завершение розыгрыша\n\n" + text += "Выберите розыгрыш для завершения:\n\n" + + buttons = [] + for lottery in lotteries: + async with async_session_maker() as session: + count = await ParticipationService.get_participants_count(session, lottery.id) + text += f"🎯 {lottery.title} ({count} участников)\n" + buttons.append([ + InlineKeyboardButton( + text=f"🏁 {lottery.title[:30]}...", + callback_data=f"admin_confirm_finish_{lottery.id}" + ) + ]) + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_lotteries")]) + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_confirm_finish_")) +async def confirm_finish_lottery(callback: CallbackQuery): + """Подтвердить завершение розыгрыша""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[-1]) + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + count = await ParticipationService.get_participants_count(session, lottery_id) + + text = f"🏁 Завершение розыгрыша\n\n" + text += f"🎯 {lottery.title}\n" + text += f"👥 Участников: {count}\n\n" + text += "⚠️ После завершения розыгрыш станет неактивным и новые участники не смогут присоединиться.\n\n" + text += "Вы уверены?" + + buttons = [ + [ + InlineKeyboardButton(text="✅ Да, завершить", callback_data=f"admin_do_finish_{lottery_id}"), + InlineKeyboardButton(text="❌ Отмена", callback_data="admin_finish_lottery") + ] + ] + + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_do_finish_")) +async def do_finish_lottery(callback: CallbackQuery): + """Выполнить завершение розыгрыша""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[-1]) + + async with async_session_maker() as session: + success = await LotteryService.complete_lottery(session, lottery_id) + lottery = await LotteryService.get_lottery(session, lottery_id) + + if success: + text = f"✅ Розыгрыш завершён!\n\n" + text += f"🎯 {lottery.title}\n" + text += f"📅 Завершён: {datetime.now().strftime('%d.%m.%Y %H:%M')}" + + await callback.answer("✅ Розыгрыш завершён!", show_alert=True) + else: + text = "❌ Ошибка завершения розыгрыша" + await callback.answer("❌ Ошибка завершения", show_alert=True) + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎲 К управлению розыгрышами", callback_data="admin_lotteries")] + ]) + ) + + +@admin_router.callback_query(F.data == "admin_delete_lottery") +async def start_delete_lottery(callback: CallbackQuery): + """Удаление розыгрыша""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + lotteries = await LotteryService.get_all_lotteries(session) + + if not lotteries: + await callback.message.edit_text( + "❌ Нет розыгрышей для удаления", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_lotteries")] + ]) + ) + return + + text = "🗑️ Удаление розыгрыша\n\n" + text += "⚠️ ВНИМАНИЕ! Это действие нельзя отменить!\n\n" + text += "Выберите розыгрыш для удаления:\n\n" + + buttons = [] + for lottery in lotteries[:10]: + status = "🟢" if getattr(lottery, 'is_active', True) else "🔴" + async with async_session_maker() as session: + count = await ParticipationService.get_participants_count(session, lottery.id) + text += f"{status} {lottery.title} ({count} участников)\n" + buttons.append([ + InlineKeyboardButton( + text=f"🗑️ {lottery.title[:25]}...", + callback_data=f"admin_confirm_delete_{lottery.id}" + ) + ]) + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_lotteries")]) + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_confirm_delete_")) +async def confirm_delete_lottery(callback: CallbackQuery): + """Подтвердить удаление розыгрыша""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[-1]) + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + count = await ParticipationService.get_participants_count(session, lottery_id) + + text = f"🗑️ Удаление розыгрыша\n\n" + text += f"🎯 {lottery.title}\n" + text += f"👥 Участников: {count}\n\n" + text += "⚠️ ВНИМАНИЕ!\n" + text += "• Все данные о розыгрыше будут удалены навсегда\n" + text += "• Все участия в розыгрыше будут удалены\n" + text += "• Это действие НЕЛЬЗЯ отменить!\n\n" + text += "Вы ТОЧНО уверены?" + + buttons = [ + [ + InlineKeyboardButton(text="🗑️ ДА, УДАЛИТЬ", callback_data=f"admin_do_delete_{lottery_id}"), + InlineKeyboardButton(text="❌ ОТМЕНА", callback_data="admin_delete_lottery") + ] + ] + + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_do_delete_")) +async def do_delete_lottery(callback: CallbackQuery): + """Выполнить удаление розыгрыша""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[-1]) + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + lottery_title = lottery.title + + success = await LotteryService.delete_lottery(session, lottery_id) + + if success: + text = f"✅ Розыгрыш удалён!\n\n" + text += f"🎯 {lottery_title}\n" + text += f"📅 Удалён: {datetime.now().strftime('%d.%m.%Y %H:%M')}" + + await callback.answer("✅ Розыгрыш удалён!", show_alert=True) + else: + text = "❌ Ошибка удаления розыгрыша" + await callback.answer("❌ Ошибка удаления", show_alert=True) + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎲 К управлению розыгрышами", callback_data="admin_lotteries")] + ]) + ) + + +# ====================== +# УПРАВЛЕНИЕ ПОБЕДИТЕЛЯМИ +# ====================== + +@admin_router.callback_query(F.data == "admin_winners") +async def show_winner_management(callback: CallbackQuery): + """Управление победителями""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + text = "👑 Управление победителями\n\n" + text += "Здесь вы можете устанавливать предопределенных победителей и проводить розыгрыши.\n\n" + text += "Выберите действие:" + + await callback.message.edit_text(text, reply_markup=get_winner_management_keyboard()) + + +@admin_router.callback_query(F.data == "admin_set_manual_winner") +async def start_set_manual_winner(callback: CallbackQuery, state: FSMContext): + """Начать установку ручного победителя""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + lotteries = await LotteryService.get_active_lotteries(session) + + if not lotteries: + await callback.message.edit_text( + "❌ Нет активных розыгрышей для установки победителей", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_winners")] + ]) + ) + return + + text = "👑 Установка предопределенного победителя\n\n" + text += "Выберите розыгрыш:\n\n" + + buttons = [] + for lottery in lotteries: + text += f"🎯 {lottery.title} (ID: {lottery.id})\n" + + # Показываем уже установленных ручных победителей + if lottery.manual_winners: + text += f" 👑 Установлены места: {', '.join(lottery.manual_winners.keys())}\n" + + text += "\n" + + buttons.append([ + InlineKeyboardButton( + text=f"🎯 {lottery.title[:30]}...", + callback_data=f"admin_choose_winner_lottery_{lottery.id}" + ) + ]) + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_winners")]) + + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_choose_winner_lottery_")) +async def choose_winner_place(callback: CallbackQuery, state: FSMContext): + """Выбор места для победителя""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + 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 + + await state.update_data(lottery_id=lottery_id) + + num_prizes = len(lottery.prizes) if lottery.prizes else 5 + + text = f"👑 Установка победителя\n" + text += f"🎯 Розыгрыш: {lottery.title}\n\n" + + if lottery.manual_winners: + text += f"Уже установлены места: {', '.join(lottery.manual_winners.keys())}\n\n" + + text += f"Введите номер места (1-{num_prizes}):" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отмена", callback_data="admin_set_manual_winner")] + ]) + ) + await state.set_state(AdminStates.set_winner_place) + + +@admin_router.message(StateFilter(AdminStates.set_winner_place)) +async def process_winner_place(message: Message, state: FSMContext): + """Обработка места победителя""" + if not is_admin(message.from_user.id): + await message.answer("❌ Недостаточно прав") + return + + try: + place = int(message.text) + if place < 1: + raise ValueError + except ValueError: + await message.answer("❌ Введите корректный номер места (положительное число)") + return + + data = await state.get_data() + lottery_id = data['lottery_id'] + + # Проверяем, не занято ли место + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + + if lottery.manual_winners and str(place) in lottery.manual_winners: + existing_id = lottery.manual_winners[str(place)] + existing_user = await UserService.get_user_by_telegram_id(session, existing_id) + name = existing_user.username if existing_user and existing_user.username else str(existing_id) + + await message.answer( + f"⚠️ Место {place} уже занято пользователем @{name}\n" + f"Введите другой номер места:" + ) + return + + await state.update_data(place=place) + + text = f"👑 Установка победителя на {place} место\n" + text += f"🎯 Розыгрыш: {lottery.title}\n\n" + text += f"Введите Telegram ID или username пользователя:" + + await message.answer(text) + await state.set_state(AdminStates.set_winner_user) + + +@admin_router.message(StateFilter(AdminStates.set_winner_user)) +async def process_winner_user(message: Message, state: FSMContext): + """Обработка пользователя-победителя""" + if not is_admin(message.from_user.id): + await message.answer("❌ Недостаточно прав") + return + + user_input = message.text.strip() + + # Пробуем определить, это ID или username + if user_input.startswith('@'): + user_input = user_input[1:] # Убираем @ + is_username = True + elif user_input.isdigit(): + is_username = False + telegram_id = int(user_input) + else: + is_username = True + + async with async_session_maker() as session: + if is_username: + # Поиск по username + from sqlalchemy import select + from models import User + + result = await session.execute( + select(User).where(User.username == user_input) + ) + user = result.scalar_one_or_none() + + if not user: + await message.answer("❌ Пользователь с таким username не найден") + return + + telegram_id = user.telegram_id + else: + user = await UserService.get_user_by_telegram_id(session, telegram_id) + + if not user: + await message.answer("❌ Пользователь с таким ID не найден") + return + + data = await state.get_data() + + async with async_session_maker() as session: + success = await LotteryService.set_manual_winner( + session, + data['lottery_id'], + data['place'], + telegram_id + ) + + await state.clear() + + if success: + username = f"@{user.username}" if user.username else user.first_name + await message.answer( + f"✅ Предопределенный победитель установлен!\n\n" + f"🏆 Место: {data['place']}\n" + f"👤 Пользователь: {username}\n" + f"🆔 ID: {telegram_id}\n\n" + f"При проведении розыгрыша этот пользователь автоматически займет {data['place']} место.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="👑 К управлению победителями", callback_data="admin_winners")] + ]) + ) + else: + await message.answer( + "❌ Не удалось установить победителя. Проверьте данные.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="👑 К управлению победителями", callback_data="admin_winners")] + ]) + ) + + +# ====================== +# СТАТИСТИКА +# ====================== + +@admin_router.callback_query(F.data == "admin_stats") +async def show_detailed_stats(callback: CallbackQuery): + """Подробная статистика""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + from sqlalchemy import select, func + from models import User, Lottery, Participation, Winner + + # Общая статистика + total_users = await session.scalar(select(func.count(User.id))) + total_lotteries = await session.scalar(select(func.count(Lottery.id))) + active_lotteries = await session.scalar( + select(func.count(Lottery.id)) + .where(Lottery.is_active == True, Lottery.is_completed == False) + ) + completed_lotteries = await session.scalar( + select(func.count(Lottery.id)).where(Lottery.is_completed == True) + ) + total_participations = await session.scalar(select(func.count(Participation.id))) + total_winners = await session.scalar(select(func.count(Winner.id))) + manual_winners = await session.scalar( + select(func.count(Winner.id)).where(Winner.is_manual == True) + ) + + # Топ активных пользователей + top_users = await session.execute( + select(User.first_name, User.username, func.count(Participation.id).label('count')) + .join(Participation) + .group_by(User.id) + .order_by(func.count(Participation.id).desc()) + .limit(5) + ) + top_users = top_users.fetchall() + + text = "📊 Детальная статистика\n\n" + text += "👥 ПОЛЬЗОВАТЕЛИ\n" + text += f"Всего зарегистрировано: {total_users}\n\n" + + text += "🎲 РОЗЫГРЫШИ\n" + text += f"Всего создано: {total_lotteries}\n" + text += f"🟢 Активных: {active_lotteries}\n" + text += f"✅ Завершенных: {completed_lotteries}\n\n" + + text += "🎫 УЧАСТИЕ\n" + text += f"Всего участий: {total_participations}\n" + if total_lotteries > 0: + avg_participation = total_participations / total_lotteries + text += f"Среднее участие на розыгрыш: {avg_participation:.1f}\n\n" + + text += "🏆 ПОБЕДИТЕЛИ\n" + text += f"Всего победителей: {total_winners}\n" + text += f"👑 Предустановленных: {manual_winners}\n" + text += f"🎲 Случайных: {total_winners - manual_winners}\n\n" + + if top_users: + text += "🔥 САМЫЕ АКТИВНЫЕ УЧАСТНИКИ\n" + for i, (first_name, username, count) in enumerate(top_users, 1): + name = f"@{username}" if username else first_name + text += f"{i}. {name} - {count} участий\n" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats")], + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")] + ]) + ) + + +# ====================== +# НАСТРОЙКИ +# ====================== + +@admin_router.callback_query(F.data == "admin_settings") +async def show_admin_settings(callback: CallbackQuery): + """Настройки админ-панели""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + text = "⚙️ Настройки системы\n\n" + text += f"👑 Администраторы: {len(ADMIN_IDS)}\n" + text += f"🗄️ База данных: SQLAlchemy ORM\n" + text += f"📅 Сегодня: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n\n" + + text += "Доступные действия:" + + buttons = [ + [InlineKeyboardButton(text="💾 Экспорт данных", callback_data="admin_export_data")], + [InlineKeyboardButton(text="🧹 Очистка старых данных", callback_data="admin_cleanup")], + [InlineKeyboardButton(text="📋 Системная информация", callback_data="admin_system_info")], + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")] + ] + + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data == "admin_system_info") +async def show_system_info(callback: CallbackQuery): + """Системная информация""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + import sys + import platform + from config import DATABASE_URL + + text = "💻 Системная информация\n\n" + text += f"🐍 Python: {sys.version.split()[0]}\n" + text += f"💾 Платформа: {platform.system()} {platform.release()}\n" + text += f"🗄️ База данных: {DATABASE_URL.split('://')[0]}\n" + text += f"👑 Админов: {len(ADMIN_IDS)}\n" + text += f"🕐 Время работы: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_settings")] + ]) + ) + + +# ====================== +# НАСТРОЙКА ОТОБРАЖЕНИЯ ПОБЕДИТЕЛЕЙ +# ====================== + +@admin_router.callback_query(F.data == "admin_winner_display_settings") +async def show_winner_display_settings(callback: CallbackQuery, state: FSMContext): + """Настройка отображения победителей для розыгрышей""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + lotteries = await LotteryService.get_all_lotteries(session) + + if not lotteries: + await callback.message.edit_text( + "❌ Нет розыгрышей", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_lotteries")] + ]) + ) + return + + text = "🎭 Настройка отображения победителей\n\n" + text += "Выберите розыгрыш для настройки:\n\n" + + buttons = [] + for lottery in lotteries[:10]: # Первые 10 розыгрышей + display_type_emoji = { + 'username': '👤', + 'chat_id': '🆔', + 'account_number': '🏦' + }.get(getattr(lottery, 'winner_display_type', 'username'), '👤') + + text += f"{display_type_emoji} {lottery.title} - {getattr(lottery, 'winner_display_type', 'username')}\n" + buttons.append([ + InlineKeyboardButton( + text=f"{display_type_emoji} {lottery.title[:30]}...", + callback_data=f"admin_set_display_{lottery.id}" + ) + ]) + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_lotteries")]) + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_set_display_")) +async def choose_display_type(callback: CallbackQuery, state: FSMContext): + """Выбор типа отображения для конкретного розыгрыша""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[-1]) + await state.update_data(display_lottery_id=lottery_id) + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + + current_type = getattr(lottery, 'winner_display_type', 'username') + + text = f"🎭 Настройка отображения для:\n{lottery.title}\n\n" + text += f"Текущий тип: {current_type}\n\n" + text += "Выберите новый тип отображения:\n\n" + text += "👤 Username - показывает @username или имя\n" + text += "🆔 Chat ID - показывает Telegram ID пользователя\n" + text += "🏦 Account Number - показывает номер клиентского счета" + + buttons = [ + [ + InlineKeyboardButton(text="👤 Username", callback_data=f"admin_apply_display_{lottery_id}_username"), + InlineKeyboardButton(text="🆔 Chat ID", callback_data=f"admin_apply_display_{lottery_id}_chat_id") + ], + [InlineKeyboardButton(text="🏦 Account Number", callback_data=f"admin_apply_display_{lottery_id}_account_number")], + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_winner_display_settings")] + ] + + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_apply_display_")) +async def apply_display_type(callback: CallbackQuery, state: FSMContext): + """Применить выбранный тип отображения""" + import logging + logger = logging.getLogger(__name__) + + logger.info(f"🎭 Попытка смены типа отображения. Callback data: {callback.data}") + + if not is_admin(callback.from_user.id): + logger.warning(f"🚫 Отказ в доступе пользователю {callback.from_user.id}") + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + try: + parts = callback.data.split("_") + logger.info(f"🔍 Разбор callback data: {parts}") + + # Format: admin_apply_display_{lottery_id}_{display_type} + # Для account_number нужно склеить последние части + lottery_id = int(parts[3]) + display_type = "_".join(parts[4:]) # Склеиваем все остальные части + + logger.info(f"🎯 Розыгрыш ID: {lottery_id}, Новый тип: {display_type}") + + async with async_session_maker() as session: + logger.info(f"📝 Вызов set_winner_display_type({lottery_id}, {display_type})") + success = await LotteryService.set_winner_display_type(session, lottery_id, display_type) + logger.info(f"💾 Результат сохранения: {success}") + + lottery = await LotteryService.get_lottery(session, lottery_id) + logger.info(f"📋 Получен розыгрыш: {lottery.title if lottery else 'None'}") + + if success: + display_type_name = { + 'username': 'Username (@username или имя)', + 'chat_id': 'Chat ID (Telegram ID)', + 'account_number': 'Account Number (номер счета)' + }.get(display_type, display_type) + + text = f"✅ Тип отображения изменен!\n\n" + text += f"🎯 Розыгрыш: {lottery.title}\n" + text += f"🎭 Новый тип: {display_type_name}\n\n" + text += "Теперь победители этого розыгрыша будут отображаться в выбранном формате." + + logger.info(f"✅ Успех! Тип изменен на {display_type}") + await callback.answer("✅ Настройка сохранена!", show_alert=True) + else: + text = "❌ Ошибка при сохранении настройки" + logger.error(f"❌ Ошибка сохранения для розыгрыша {lottery_id}, тип {display_type}") + + except Exception as e: + logger.error(f"💥 Исключение при смене типа отображения: {e}") + text = f"❌ Ошибка: {str(e)}" + await callback.answer("❌ Ошибка при сохранении!", show_alert=True) + return + await callback.answer("❌ Ошибка сохранения", show_alert=True) + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎭 К настройке отображения", callback_data="admin_winner_display_settings")], + [InlineKeyboardButton(text="🎲 К управлению розыгрышами", callback_data="admin_lotteries")] + ]) + ) + await state.clear() + + +# Экспорт роутера +__all__ = ['admin_router'] \ No newline at end of file diff --git a/admin_utils.py b/admin_utils.py new file mode 100644 index 0000000..4ab679b --- /dev/null +++ b/admin_utils.py @@ -0,0 +1,423 @@ +""" +Дополнительные утилиты для админ-панели +""" +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete, update, func +from models import User, Lottery, Participation, Winner +from typing import List, Dict, Optional +import csv +import json +from datetime import datetime + + +class AdminUtils: + """Утилиты для админ-панели""" + + @staticmethod + async def get_lottery_statistics(session: AsyncSession, lottery_id: int) -> Dict: + """Получить детальную статистику по розыгрышу""" + lottery = await session.get(Lottery, lottery_id) + if not lottery: + return {} + + # Количество участников + participants_count = await session.scalar( + select(func.count(Participation.id)) + .where(Participation.lottery_id == lottery_id) + ) + + # Победители + winners_count = await session.scalar( + select(func.count(Winner.id)) + .where(Winner.lottery_id == lottery_id) + ) + + # Ручные победители + manual_winners_count = await session.scalar( + select(func.count(Winner.id)) + .where(Winner.lottery_id == lottery_id, Winner.is_manual == True) + ) + + # Участники по дням + participants_by_date = await session.execute( + select( + func.date(Participation.created_at).label('date'), + func.count(Participation.id).label('count') + ) + .where(Participation.lottery_id == lottery_id) + .group_by(func.date(Participation.created_at)) + .order_by(func.date(Participation.created_at)) + ) + + return { + 'lottery': lottery, + 'participants_count': participants_count, + 'winners_count': winners_count, + 'manual_winners_count': manual_winners_count, + 'random_winners_count': winners_count - manual_winners_count, + 'participants_by_date': participants_by_date.fetchall() + } + + @staticmethod + async def export_lottery_data(session: AsyncSession, lottery_id: int) -> Dict: + """Экспорт данных розыгрыша""" + lottery = await session.get(Lottery, lottery_id) + if not lottery: + return {} + + # Участники + participants = await session.execute( + select(User, Participation) + .join(Participation) + .where(Participation.lottery_id == lottery_id) + .order_by(Participation.created_at) + ) + participants_data = [] + for user, participation in participants: + participants_data.append({ + 'telegram_id': user.telegram_id, + 'username': user.username, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'joined_at': participation.created_at.isoformat() + }) + + # Победители + winners = await session.execute( + select(Winner, User) + .join(User) + .where(Winner.lottery_id == lottery_id) + .order_by(Winner.place) + ) + winners_data = [] + for winner, user in winners: + winners_data.append({ + 'place': winner.place, + 'telegram_id': user.telegram_id, + 'username': user.username, + 'first_name': user.first_name, + 'prize': winner.prize, + 'is_manual': winner.is_manual, + 'won_at': winner.created_at.isoformat() + }) + + return { + 'lottery': { + 'id': lottery.id, + 'title': lottery.title, + 'description': lottery.description, + 'created_at': lottery.created_at.isoformat(), + 'is_completed': lottery.is_completed, + 'prizes': lottery.prizes, + 'manual_winners': lottery.manual_winners + }, + 'participants': participants_data, + 'winners': winners_data, + 'export_date': datetime.now().isoformat() + } + + @staticmethod + async def bulk_add_participants( + session: AsyncSession, + lottery_id: int, + telegram_ids: List[int] + ) -> Dict[str, int]: + """Массовое добавление участников""" + added = 0 + skipped = 0 + errors = [] + + for telegram_id in telegram_ids: + try: + # Проверяем, есть ли пользователь + user = await session.execute( + select(User).where(User.telegram_id == telegram_id) + ) + user = user.scalar_one_or_none() + + if not user: + errors.append(f"Пользователь {telegram_id} не найден") + continue + + # Проверяем, не участвует ли уже + existing = await session.execute( + select(Participation).where( + Participation.lottery_id == lottery_id, + Participation.user_id == user.id + ) + ) + + if existing.scalar_one_or_none(): + skipped += 1 + continue + + # Добавляем участника + participation = Participation( + lottery_id=lottery_id, + user_id=user.id + ) + session.add(participation) + added += 1 + + except Exception as e: + errors.append(f"Ошибка с {telegram_id}: {str(e)}") + + if added > 0: + await session.commit() + + return { + 'added': added, + 'skipped': skipped, + 'errors': errors + } + + @staticmethod + async def remove_participant( + session: AsyncSession, + lottery_id: int, + telegram_id: int + ) -> bool: + """Удалить участника из розыгрыша""" + user = await session.execute( + select(User).where(User.telegram_id == telegram_id) + ) + user = user.scalar_one_or_none() + + if not user: + return False + + result = await session.execute( + delete(Participation).where( + Participation.lottery_id == lottery_id, + Participation.user_id == user.id + ) + ) + + await session.commit() + return result.rowcount > 0 + + @staticmethod + async def update_lottery( + session: AsyncSession, + lottery_id: int, + **updates + ) -> bool: + """Обновить данные розыгрыша""" + try: + await session.execute( + update(Lottery) + .where(Lottery.id == lottery_id) + .values(**updates) + ) + await session.commit() + return True + except Exception: + return False + + @staticmethod + async def delete_lottery(session: AsyncSession, lottery_id: int) -> bool: + """Удалить розыгрыш (со всеми связанными данными)""" + try: + # Удаляем победителей + await session.execute( + delete(Winner).where(Winner.lottery_id == lottery_id) + ) + + # Удаляем участников + await session.execute( + delete(Participation).where(Participation.lottery_id == lottery_id) + ) + + # Удаляем сам розыгрыш + await session.execute( + delete(Lottery).where(Lottery.id == lottery_id) + ) + + await session.commit() + return True + except Exception: + await session.rollback() + return False + + @staticmethod + async def get_user_activity( + session: AsyncSession, + telegram_id: int + ) -> Dict: + """Получить активность пользователя""" + user = await session.execute( + select(User).where(User.telegram_id == telegram_id) + ) + user = user.scalar_one_or_none() + + if not user: + return {} + + # Участия + participations = await session.execute( + select(Participation, Lottery) + .join(Lottery) + .where(Participation.user_id == user.id) + .order_by(Participation.created_at.desc()) + ) + + # Выигрыши + wins = await session.execute( + select(Winner, Lottery) + .join(Lottery) + .where(Winner.user_id == user.id) + .order_by(Winner.created_at.desc()) + ) + + participations_data = [] + for participation, lottery in participations: + participations_data.append({ + 'lottery_title': lottery.title, + 'lottery_id': lottery.id, + 'joined_at': participation.created_at, + 'lottery_completed': lottery.is_completed + }) + + wins_data = [] + for win, lottery in wins: + wins_data.append({ + 'lottery_title': lottery.title, + 'lottery_id': lottery.id, + 'place': win.place, + 'prize': win.prize, + 'is_manual': win.is_manual, + 'won_at': win.created_at + }) + + return { + 'user': user, + 'total_participations': len(participations_data), + 'total_wins': len(wins_data), + 'participations': participations_data, + 'wins': wins_data + } + + @staticmethod + async def cleanup_old_data(session: AsyncSession, days: int = 30) -> Dict[str, int]: + """Очистка старых данных""" + from datetime import datetime, timedelta + + cutoff_date = datetime.now() - timedelta(days=days) + + # Удаляем старые завершенные розыгрыши + old_lotteries = await session.execute( + select(Lottery.id) + .where( + Lottery.is_completed == True, + Lottery.created_at < cutoff_date + ) + ) + lottery_ids = [row[0] for row in old_lotteries.fetchall()] + + deleted_winners = 0 + deleted_participations = 0 + deleted_lotteries = 0 + + for lottery_id in lottery_ids: + # Удаляем победителей + result = await session.execute( + delete(Winner).where(Winner.lottery_id == lottery_id) + ) + deleted_winners += result.rowcount + + # Удаляем участников + result = await session.execute( + delete(Participation).where(Participation.lottery_id == lottery_id) + ) + deleted_participations += result.rowcount + + # Удаляем розыгрыш + result = await session.execute( + delete(Lottery).where(Lottery.id == lottery_id) + ) + deleted_lotteries += result.rowcount + + await session.commit() + + return { + 'deleted_lotteries': deleted_lotteries, + 'deleted_participations': deleted_participations, + 'deleted_winners': deleted_winners, + 'cutoff_date': cutoff_date.isoformat() + } + + +class ReportGenerator: + """Генератор отчетов""" + + @staticmethod + async def generate_summary_report(session: AsyncSession) -> str: + """Генерация сводного отчета""" + # Общая статистика + total_users = await session.scalar(select(func.count(User.id))) + total_lotteries = await session.scalar(select(func.count(Lottery.id))) + active_lotteries = await session.scalar( + select(func.count(Lottery.id)) + .where(Lottery.is_active == True, Lottery.is_completed == False) + ) + completed_lotteries = await session.scalar( + select(func.count(Lottery.id)).where(Lottery.is_completed == True) + ) + total_participations = await session.scalar(select(func.count(Participation.id))) + total_winners = await session.scalar(select(func.count(Winner.id))) + + # Топ розыгрыши по участникам + top_lotteries = await session.execute( + select( + Lottery.title, + Lottery.created_at, + func.count(Participation.id).label('participants') + ) + .join(Participation, isouter=True) + .group_by(Lottery.id) + .order_by(func.count(Participation.id).desc()) + .limit(5) + ) + + # Топ активные пользователи + top_users = await session.execute( + select( + User.first_name, + User.username, + func.count(Participation.id).label('participations'), + func.count(Winner.id).label('wins') + ) + .join(Participation, isouter=True) + .join(Winner, isouter=True) + .group_by(User.id) + .order_by(func.count(Participation.id).desc()) + .limit(5) + ) + + report = f"📊 СВОДНЫЙ ОТЧЕТ\n" + report += f"Дата: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n\n" + + report += f"📈 ОБЩАЯ СТАТИСТИКА\n" + report += f"👥 Пользователей: {total_users}\n" + report += f"🎲 Всего розыгрышей: {total_lotteries}\n" + report += f"🟢 Активных: {active_lotteries}\n" + report += f"✅ Завершенных: {completed_lotteries}\n" + report += f"🎫 Всего участий: {total_participations}\n" + report += f"🏆 Всего победителей: {total_winners}\n\n" + + if total_lotteries > 0: + avg_participation = total_participations / total_lotteries + report += f"📊 Среднее участие на розыгрыш: {avg_participation:.1f}\n\n" + + report += f"🏆 ТОП РОЗЫГРЫШИ ПО УЧАСТНИКАМ\n" + for i, (title, created_at, participants) in enumerate(top_lotteries.fetchall(), 1): + report += f"{i}. {title}\n" + report += f" Участников: {participants} | {created_at.strftime('%d.%m.%Y')}\n\n" + + report += f"🔥 ТОП АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ\n" + for i, (first_name, username, participations, wins) in enumerate(top_users.fetchall(), 1): + name = f"@{username}" if username else first_name + report += f"{i}. {name}\n" + report += f" Участий: {participations} | Побед: {wins}\n\n" + + return report \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..db697be --- /dev/null +++ b/alembic.ini @@ -0,0 +1,111 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version number format (Semantic Versioning recommended) +version_num_format = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses +# os.pathsep. If this key is omitted entirely, it falls back to the legacy +# behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:///./lottery_bot.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/async_decorators.py b/async_decorators.py new file mode 100644 index 0000000..69cfc54 --- /dev/null +++ b/async_decorators.py @@ -0,0 +1,161 @@ +""" +Декораторы для асинхронной обработки запросов пользователей +""" +import asyncio +import functools +from typing import Callable, Any +from aiogram import types +from task_manager import task_manager, TaskPriority +import uuid +import logging + +logger = logging.getLogger(__name__) + + +def async_user_action(priority: TaskPriority = TaskPriority.NORMAL, timeout: float = 30.0): + """ + Декоратор для асинхронной обработки действий пользователей + + Args: + priority: Приоритет задачи + timeout: Таймаут выполнения в секундах + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(*args, **kwargs): + # Извлекаем информацию о пользователе + user_id = None + action_name = func.__name__ + + # Ищем пользователя в аргументах + for arg in args: + if isinstance(arg, (types.Message, types.CallbackQuery)): + user_id = arg.from_user.id + break + + if user_id is None: + # Если не нашли пользователя, выполняем синхронно + logger.warning(f"Не удалось определить user_id для {action_name}, выполнение синхронно") + return await func(*args, **kwargs) + + # Генерируем ID задачи + task_id = f"{action_name}_{user_id}_{uuid.uuid4().hex[:8]}" + + try: + # Добавляем задачу в очередь + await task_manager.add_task( + task_id, + user_id, + func, + *args, + priority=priority, + timeout=timeout, + **kwargs + ) + + logger.debug(f"Задача {task_id} добавлена в очередь для пользователя {user_id}") + + except ValueError as e: + # Превышен лимит задач пользователя + logger.warning(f"Лимит задач для пользователя {user_id}: {e}") + + # Отправляем сообщение о превышении лимита + if isinstance(args[0], types.Message): + message = args[0] + await message.answer( + "⚠️ Вы превысили лимит одновременных запросов. " + "Пожалуйста, дождитесь завершения предыдущих операций." + ) + elif isinstance(args[0], types.CallbackQuery): + callback = args[0] + await callback.answer( + "⚠️ Превышен лимит запросов. Дождитесь завершения предыдущих операций.", + show_alert=True + ) + + return None + + return wrapper + return decorator + + +def admin_async_action(priority: TaskPriority = TaskPriority.HIGH, timeout: float = 60.0): + """ + Декоратор для асинхронной обработки действий администраторов + (повышенный приоритет и больший таймаут) + """ + return async_user_action(priority=priority, timeout=timeout) + + +def critical_action(timeout: float = 120.0): + """ + Декоратор для критических действий (розыгрыши, важные операции) + """ + return async_user_action(priority=TaskPriority.CRITICAL, timeout=timeout) + + +def db_operation(timeout: float = 15.0): + """ + Декоратор для операций с базой данных + """ + return async_user_action(priority=TaskPriority.NORMAL, timeout=timeout) + + +# Функции для работы со статистикой задач + +async def get_task_stats() -> dict: + """Получить общую статистику задач""" + return task_manager.get_stats() + + +async def get_user_task_info(user_id: int) -> dict: + """Получить информацию о задачах пользователя""" + return task_manager.get_user_stats(user_id) + + +async def format_task_stats() -> str: + """Форматированная статистика для админов""" + stats = await get_task_stats() + + text = "📊 **Статистика обработки задач:**\n\n" + text += f"🟢 Активных воркеров: {stats['workers_count']}\n" + text += f"⚙️ Выполняется задач: {stats['active_tasks']}\n" + text += f"📋 В очереди: {stats['queue_size']}\n" + text += f"✅ Выполнено: {stats['completed_tasks']}\n" + text += f"❌ Ошибок: {stats['failed_tasks']}\n\n" + + if stats['user_tasks']: + text += "👥 **Активные пользователи:**\n" + for user_id, task_count in stats['user_tasks'].items(): + if task_count > 0: + text += f"• ID {user_id}: {task_count} задач\n" + + return text + + +# Middleware для автоматического управления задачами + +class TaskManagerMiddleware: + """Middleware для управления менеджером задач""" + + def __init__(self): + self.started = False + + async def __call__(self, handler: Callable, event: types.TelegramObject, data: dict): + # Запускаем менеджер при первом обращении + if not self.started: + await task_manager.start() + self.started = True + logger.info("Менеджер задач запущен через middleware") + + # Продолжаем обработку + return await handler(event, data) + + +# Функция для изящного завершения + +async def shutdown_task_manager(): + """Завершение работы менеджера задач""" + logger.info("Завершение работы менеджера задач...") + await task_manager.stop() + logger.info("Менеджер задач остановлен") \ No newline at end of file diff --git a/conduct_draw.py b/conduct_draw.py new file mode 100644 index 0000000..f76aad8 --- /dev/null +++ b/conduct_draw.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" +Скрипт для проведения розыгрыша +""" +import asyncio +import json +from database import async_session_maker +from services import LotteryService, ParticipationService +from models import Lottery, User + +async def conduct_lottery_draw(lottery_id: int): + """Проводим розыгрыш для указанного ID""" + print(f"🎲 Проведение розыгрыша #{lottery_id}") + + async with async_session_maker() as session: + # Получаем розыгрыш + lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + print(f"❌ Розыгрыш {lottery_id} не найден") + return + + print(f"📋 Розыгрыш: {lottery.title}") + + # Призы уже загружены как список + prizes = lottery.prizes if isinstance(lottery.prizes, list) else json.loads(lottery.prizes) + print(f"🏆 Призы: {len(prizes)} шт.") + + # Получаем участников + participants = await ParticipationService.get_participants(session, lottery_id) + print(f"👥 Участников: {len(participants)}") + + if len(participants) == 0: + print("⚠️ Нет участников для розыгрыша") + return + + # Выводим список участников + print("\n👥 Список участников:") + for i, user in enumerate(participants, 1): + accounts = user.account_number.split(',') if user.account_number else ['Нет счетов'] + print(f" {i}. {user.first_name} (@{user.username}) - {len(accounts)} счет(ов)") + + # Проводим розыгрыш + print(f"\n🎲 Проводим розыгрыш...") + + # Используем метод из LotteryService + try: + winners = await LotteryService.conduct_draw(session, lottery_id) + + if winners: + print(f"\n🎉 Победители определены:") + for i, winner_data in enumerate(winners, 1): + user = winner_data['user'] + prize = winner_data['prize'] + print(f" 🏆 {i} место: {user.first_name} (@{user.username})") + print(f" 💎 Приз: {prize}") + + # Обновляем статус розыгрыша + await LotteryService.set_lottery_completed(session, lottery_id, True) + print(f"\n✅ Розыгрыш завершен и помечен как завершенный") + + else: + print("❌ Ошибка при проведении розыгрыша") + + except Exception as e: + print(f"💥 Ошибка: {e}") + +async def main(): + """Главная функция""" + print("🎯 Скрипт проведения розыгрышей") + print("=" * 50) + + # Показываем доступные розыгрыши + async with async_session_maker() as session: + lotteries = await LotteryService.get_active_lotteries(session) + + if not lotteries: + print("❌ Нет активных розыгрышей") + return + + print("🎲 Активные розыгрыши:") + for lottery in lotteries: + participants = await ParticipationService.get_participants(session, lottery.id) + print(f" {lottery.id}. {lottery.title} ({len(participants)} участников)") + + # Просим выбрать розыгрыш + print("\nВведите ID розыгрыша для проведения (или 'all' для всех):") + choice = input("> ").strip().lower() + + if choice == 'all': + # Проводим все розыгрыши + for lottery in lotteries: + await conduct_lottery_draw(lottery.id) + print("-" * 30) + else: + try: + lottery_id = int(choice) + await conduct_lottery_draw(lottery_id) + except ValueError: + print("❌ Неверный ID розыгрыша") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..6c38158 --- /dev/null +++ b/config.py @@ -0,0 +1,29 @@ +import os +from dotenv import load_dotenv + +# Загружаем переменные окружения +load_dotenv() + +# Telegram Bot +BOT_TOKEN = os.getenv("BOT_TOKEN") +if not BOT_TOKEN: + raise ValueError("BOT_TOKEN не найден в переменных окружения") + +# База данных +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db") + +# Администраторы +ADMIN_IDS = [] +admin_ids_str = os.getenv("ADMIN_IDS", "") +if admin_ids_str: + try: + ADMIN_IDS = [int(id_str.strip()) for id_str in admin_ids_str.split(",") if id_str.strip()] + except ValueError: + print("Предупреждение: Некорректные ID администраторов в ADMIN_IDS") + +# Логирование +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") + +# Настройки бота +MAX_PARTICIPANTS_PER_LOTTERY = 10000 # Максимальное количество участников в розыгрыше +MAX_ACTIVE_LOTTERIES = 10 # Максимальное количество активных розыгрышей \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..afd85c7 --- /dev/null +++ b/database.py @@ -0,0 +1,41 @@ +import os +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.ext.declarative import declarative_base +from dotenv import load_dotenv + +# Загружаем переменные окружения +load_dotenv() + +# Конфигурация базы данных +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db") + +# Создаем асинхронный движок +engine = create_async_engine( + DATABASE_URL, + echo=True, # Логирование SQL запросов + future=True, +) + +# Создаем фабрику сессий +async_session_maker = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False +) + +# Базовый класс для моделей +Base = declarative_base() + +async def get_session() -> AsyncSession: + """Получить асинхронную сессию базы данных""" + async with async_session_maker() as session: + yield session + +async def init_db(): + """Инициализация базы данных""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + +async def close_db(): + """Закрытие соединения с базой данных""" + await engine.dispose() \ No newline at end of file diff --git a/db_setup.py b/db_setup.py new file mode 100644 index 0000000..bb9a806 --- /dev/null +++ b/db_setup.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +Скрипт для очистки и инициализации БД с тестовыми данными +""" +import asyncio +import random +from database import async_session_maker, Base, engine +from models import User, Lottery, Participation, Winner +from services import LotteryService, UserService, ParticipationService +from datetime import datetime, timedelta +from sqlalchemy import delete, update, select, func + +async def clear_database(): + """Очистка всех таблиц""" + print("🗑️ Очистка базы данных...") + + async with async_session_maker() as session: + # Удаляем все данные в правильном порядке (учитывая внешние ключи) + await session.execute(delete(Winner)) + await session.execute(delete(Participation)) + await session.execute(delete(Lottery)) + await session.execute(delete(User)) + await session.commit() + + print("✅ База данных очищена") + +def generate_account_numbers(user_id: int, count: int = 10) -> list: + """Генерация номеров счетов для пользователя""" + base = user_id * 1000 + return [f"{base + i:06d}" for i in range(1, count + 1)] + +async def create_test_users(): + """Создание тестовых пользователей""" + print("👥 Создание тестовых пользователей...") + + users_data = [ + {"telegram_id": 111111111, "username": "alice_winner", "first_name": "Alice", "last_name": "Johnson"}, + {"telegram_id": 222222222, "username": "bob_player", "first_name": "Bob", "last_name": "Smith"}, + {"telegram_id": 333333333, "username": "charlie_test", "first_name": "Charlie", "last_name": "Brown"}, + {"telegram_id": 444444444, "username": "diana_luck", "first_name": "Diana", "last_name": "Wilson"}, + {"telegram_id": 555555555, "username": "eve_gamer", "first_name": "Eve", "last_name": "Davis"}, + {"telegram_id": 666666666, "username": "frank_player", "first_name": "Frank", "last_name": "Miller"}, + {"telegram_id": 777777777, "username": "grace_lucky", "first_name": "Grace", "last_name": "Taylor"}, + {"telegram_id": 888888888, "username": "henry_bet", "first_name": "Henry", "last_name": "Anderson"}, + {"telegram_id": 999999999, "username": "iris_game", "first_name": "Iris", "last_name": "Thomas"}, + {"telegram_id": 101010101, "username": "jack_chance", "first_name": "Jack", "last_name": "Jackson"}, + ] + + created_users = [] + + async with async_session_maker() as session: + for i, user_data in enumerate(users_data, 1): + # Создаем пользователя + user = await UserService.get_or_create_user( + session=session, + telegram_id=user_data["telegram_id"], + username=user_data["username"], + first_name=user_data["first_name"], + last_name=user_data.get("last_name", None) + ) + + # Генерируем и присваиваем номера счетов + account_numbers = generate_account_numbers(i, 10) + + # Обновляем счета + await session.execute( + update(User) + .where(User.id == user.id) + .values(account_number=",".join(account_numbers)) + ) + + await session.commit() + created_users.append((user, account_numbers)) + + print(f"✅ Создан пользователь {user.first_name} ({user.username}) с {len(account_numbers)} счетами") + + return created_users + +async def create_test_lotteries(users_data): + """Создание тестовых розыгрышей""" + print("🎲 Создание тестовых розыгрышей...") + + async with async_session_maker() as session: + # Первый розыгрыш - активный + lottery1 = await LotteryService.create_lottery( + session=session, + title="🎯 Супер Розыгрыш #1", + description="Розыгрыш крутых призов! Участвуют все желающие.", + prizes=[ + "iPhone 15 Pro - Новейший флагман Apple", + "AirPods Pro - Беспроводные наушники премиум класса", + "Apple Watch - Умные часы последней модели" + ], + creator_id=users_data[0][0].id + ) + + # Второй розыгрыш - тоже активный + lottery2 = await LotteryService.create_lottery( + session=session, + title="🚀 Мега Розыгрыш #2", + description="Еще больше призов! Еще больше возможностей!", + prizes=[ + "MacBook Pro - Мощный ноутбук для профессионалов", + "iPad Air - Планшет для творчества и работы", + "Денежный приз 50,000 рублей" + ], + creator_id=users_data[0][0].id + ) + + await session.commit() + + print(f"✅ Создан розыгрыш: {lottery1.title} (ID: {lottery1.id})") + print(f"✅ Создан розыгрыш: {lottery2.title} (ID: {lottery2.id})") + + return [lottery1, lottery2] + +async def populate_participants(users_data, lotteries): + """Заполнение участников розыгрышей""" + print("🎫 Добавление участников в розыгрыши...") + + async with async_session_maker() as session: + # Добавляем всех пользователей в первый розыгрыш + lottery1 = lotteries[0] + for user, account_numbers in users_data: + # Добавляем каждого пользователя как участника + success = await ParticipationService.add_participant( + session=session, + lottery_id=lottery1.id, + user_id=user.id + ) + if success: + print(f"✅ Пользователь {user.first_name}: добавлен в розыгрыш №1") + else: + print(f"⚠️ Пользователь {user.first_name}: уже участвует в розыгрыше №1") + + # Добавляем первых 5 пользователей во второй розыгрыш + lottery2 = lotteries[1] + for user, account_numbers in users_data[:5]: # Первые 5 пользователей + success = await ParticipationService.add_participant( + session=session, + lottery_id=lottery2.id, + user_id=user.id + ) + if success: + print(f"✅ Пользователь {user.first_name}: добавлен в розыгрыш №2") + else: + print(f"⚠️ Пользователь {user.first_name}: уже участвует в розыгрыше №2") + +async def show_statistics(): + """Показ статистики созданных данных""" + print("\n📊 Статистика созданных данных:") + + async with async_session_maker() as session: + # Пользователи + users = await UserService.get_all_users(session) + print(f"👥 Пользователей: {len(users)}") + + # Розыгрыши + lotteries = await LotteryService.get_active_lotteries(session) + print(f"🎲 Активных розыгрышей: {len(lotteries)}") + + # Участия + for lottery in lotteries: + # Простой подсчет через SQL + result = await session.execute( + select(func.count(Participation.id)) + .where(Participation.lottery_id == lottery.id) + ) + participation_count = result.scalar() + print(f"🎫 Розыгрыш '{lottery.title}': {participation_count} участий") + + print("\n✅ Тестовые данные успешно созданы!") + +async def main(): + """Главная функция""" + print("🚀 Инициализация тестовой базы данных") + print("=" * 50) + + # Очищаем БД + await clear_database() + + # Создаем тестовых пользователей + users_data = await create_test_users() + + # Создаем розыгрыши + lotteries = await create_test_lotteries(users_data) + + # Заполняем участников + await populate_participants(users_data, lotteries) + + # Показываем статистику + await show_statistics() + + print("\n🎉 Инициализация завершена!") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/demo_admin.py b/demo_admin.py new file mode 100644 index 0000000..b4e7b1d --- /dev/null +++ b/demo_admin.py @@ -0,0 +1,191 @@ +""" +Демонстрация возможностей админ-панели +""" +import asyncio +from database import async_session_maker, init_db +from services import UserService, LotteryService +from admin_utils import AdminUtils, ReportGenerator + + +async def demo_admin_features(): + """Демонстрация функций админ-панели""" + print("🚀 Демонстрация возможностей админ-панели") + print("=" * 50) + + await init_db() + + async with async_session_maker() as session: + # Создаем тестового администратора + admin = await UserService.get_or_create_user( + session, + telegram_id=123456789, + username="admin", + first_name="Администратор", + last_name="Системы" + ) + await UserService.set_admin(session, 123456789, True) + print(f"✅ Создан администратор: {admin.first_name}") + + # Создаем тестовых пользователей + users = [] + for i in range(1, 11): + user = await UserService.get_or_create_user( + session, + telegram_id=200000000 + i, + username=f"user{i}", + first_name=f"Пользователь{i}", + last_name="Тестовый" + ) + users.append(user) + print(f"✅ Создано {len(users)} тестовых пользователей") + + # Создаем несколько розыгрышей + lottery1 = await LotteryService.create_lottery( + session, + title="🎉 Новогодний мега-розыгрыш", + description="Грандиозный розыгрыш к Новому году с невероятными призами!", + prizes=[ + "🥇 iPhone 15 Pro Max 1TB", + "🥈 MacBook Air M2 13\"", + "🥉 iPad Pro 12.9\"", + "🏆 AirPods Pro 2", + "🎁 Подарочная карта Apple 50,000₽" + ], + creator_id=admin.id + ) + + lottery2 = await LotteryService.create_lottery( + session, + title="🚗 Автомобильный розыгрыш", + description="Выиграй автомобиль своей мечты!", + prizes=[ + "🥇 Tesla Model 3", + "🥈 BMW X3", + "🥉 Mercedes-Benz C-Class" + ], + creator_id=admin.id + ) + + lottery3 = await LotteryService.create_lottery( + session, + title="🏖️ Отпуск мечты", + description="Путешествие в райские места", + prizes=[ + "🥇 Тур на Мальдивы на двоих", + "🥈 Неделя в Дубае", + "🥉 Тур в Турцию" + ], + creator_id=admin.id + ) + + print(f"✅ Создано 3 розыгрыша:") + print(f" - {lottery1.title}") + print(f" - {lottery2.title}") + print(f" - {lottery3.title}") + + # Добавляем участников в розыгрыши + participants_added = 0 + for user in users: + # В первый розыгрыш добавляем всех + if await LotteryService.add_participant(session, lottery1.id, user.id): + participants_added += 1 + + # Во второй - половину + if user.id % 2 == 0: + if await LotteryService.add_participant(session, lottery2.id, user.id): + participants_added += 1 + + # В третий - треть + if user.id % 3 == 0: + if await LotteryService.add_participant(session, lottery3.id, user.id): + participants_added += 1 + + print(f"✅ Добавлено {participants_added} участий") + + # Устанавливаем предопределенных победителей + print("\n👑 Установка предопределенных победителей:") + + # В первом розыгрыше: 1 и 3 места + await LotteryService.set_manual_winner(session, lottery1.id, 1, users[0].telegram_id) + await LotteryService.set_manual_winner(session, lottery1.id, 3, users[2].telegram_id) + print(f" 🎯 {lottery1.title}: 1 место - {users[0].first_name}, 3 место - {users[2].first_name}") + + # Во втором розыгрыше: только 1 место + await LotteryService.set_manual_winner(session, lottery2.id, 1, users[1].telegram_id) + print(f" 🚗 {lottery2.title}: 1 место - {users[1].first_name}") + + # Получаем статистику по розыгрышам + print(f"\n📊 Статистика по розыгрышам:") + for lottery in [lottery1, lottery2, lottery3]: + stats = await AdminUtils.get_lottery_statistics(session, lottery.id) + print(f" 🎲 {lottery.title}:") + print(f" Участников: {stats['participants_count']}") + print(f" Ручных победителей: {len(lottery.manual_winners or {})}") + + # Проводим розыгрыши + print(f"\n🎲 Проведение розыгрышей:") + + # Первый розыгрыш + results1 = await LotteryService.conduct_draw(session, lottery1.id) + print(f" 🎉 {lottery1.title} - результаты:") + for place, winner_info in results1.items(): + user = winner_info['user'] + manual = " 👑" if winner_info['is_manual'] else " 🎲" + print(f" {place}. {user.first_name}{manual}") + + # Второй розыгрыш + results2 = await LotteryService.conduct_draw(session, lottery2.id) + print(f" 🚗 {lottery2.title} - результаты:") + for place, winner_info in results2.items(): + user = winner_info['user'] + manual = " 👑" if winner_info['is_manual'] else " 🎲" + print(f" {place}. {user.first_name}{manual}") + + # Генерируем отчет + print(f"\n📋 Генерация отчета:") + report = await ReportGenerator.generate_summary_report(session) + print(report) + + # Экспорт данных + print(f"💾 Экспорт данных первого розыгрыша:") + export_data = await AdminUtils.export_lottery_data(session, lottery1.id) + print(f" ✅ Экспортировано:") + print(f" - Розыгрыш: {export_data['lottery']['title']}") + print(f" - Участников: {len(export_data['participants'])}") + print(f" - Победителей: {len(export_data['winners'])}") + + # Активность пользователя + print(f"\n👤 Активность пользователя {users[0].first_name}:") + activity = await AdminUtils.get_user_activity(session, users[0].telegram_id) + print(f" 📊 Статистика:") + print(f" Участий: {activity['total_participations']}") + print(f" Побед: {activity['total_wins']}") + + print(f"\n" + "=" * 50) + print(f"✅ Демонстрация завершена!") + print(f"") + print(f"🎯 Что показано:") + print(f" ✅ Создание пользователей и розыгрышей") + print(f" ✅ Добавление участников") + print(f" ✅ Установка предопределенных победителей") + print(f" ✅ Проведение розыгрышей с ручными победителями") + print(f" ✅ Генерация статистики и отчетов") + print(f" ✅ Экспорт данных") + print(f" ✅ Анализ активности пользователей") + print(f"") + print(f"🚀 Админ-панель готова к использованию!") + + # Показываем ключевую особенность + print(f"\n🎯 КЛЮЧЕВАЯ ОСОБЕННОСТЬ:") + print(f"В первом розыгрыше мы заранее установили:") + print(f" 👑 1 место: {users[0].first_name}") + print(f" 👑 3 место: {users[2].first_name}") + print(f"") + print(f"При розыгрыше эти пользователи автоматически заняли свои места,") + print(f"а остальные места (2, 4, 5) были разыграны случайно!") + print(f"") + print(f"🎭 Никто из участников не знает о подстройке!") + + +if __name__ == "__main__": + asyncio.run(demo_admin_features()) \ No newline at end of file diff --git a/examples.py b/examples.py new file mode 100644 index 0000000..4e832aa --- /dev/null +++ b/examples.py @@ -0,0 +1,164 @@ +""" +Примеры использования API сервисов для тестирования +""" +import asyncio +from database import async_session_maker, init_db +from services import UserService, LotteryService, ParticipationService + + +async def example_usage(): + """Пример использования всех основных функций бота""" + + print("🔄 Инициализация базы данных...") + await init_db() + + async with async_session_maker() as session: + print("\n👤 Создание пользователей...") + + # Создаем администратора + admin = await UserService.get_or_create_user( + session, + telegram_id=123456789, + username="admin_user", + first_name="Администратор", + last_name="Бота" + ) + await UserService.set_admin(session, 123456789, True) + print(f"✅ Создан админ: {admin.first_name} (@{admin.username})") + + # Создаем обычных пользователей + users = [] + for i in range(1, 6): + user = await UserService.get_or_create_user( + session, + telegram_id=100000000 + i, + username=f"user{i}", + first_name=f"Пользователь{i}", + last_name="Тестовый" + ) + users.append(user) + print(f"✅ Создан пользователь: {user.first_name}") + + print(f"\n🎲 Создание розыгрыша...") + + # Создаем розыгрыш + lottery = await LotteryService.create_lottery( + session, + title="🎉 Новогодний розыгрыш 2025", + description="Грандиозный новогодний розыгрыш с крутыми призами!", + prizes=[ + "🥇 iPhone 15 Pro Max", + "🥈 MacBook Air M2", + "🥉 AirPods Pro", + "🏆 PlayStation 5", + "🎁 Подарочный сертификат 10,000₽" + ], + creator_id=admin.id + ) + print(f"✅ Создан розыгрыш: {lottery.title}") + + print(f"\n🎫 Регистрация участников...") + + # Добавляем участников + for user in users: + success = await LotteryService.add_participant(session, lottery.id, user.id) + if success: + print(f"✅ {user.first_name} присоединился к розыгрышу") + + print(f"\n👑 Установка ручного победителя...") + + # Устанавливаем ручного победителя на 1 место + manual_winner = users[0] # Первый пользователь + success = await LotteryService.set_manual_winner( + session, lottery.id, 1, manual_winner.telegram_id + ) + if success: + print(f"✅ {manual_winner.first_name} установлен как победитель 1 места") + + # Устанавливаем еще одного ручного победителя на 3 место + manual_winner2 = users[2] # Третий пользователь + success = await LotteryService.set_manual_winner( + session, lottery.id, 3, manual_winner2.telegram_id + ) + if success: + print(f"✅ {manual_winner2.first_name} установлен как победитель 3 места") + + print(f"\n📊 Статистика перед розыгрышем...") + participants_count = await ParticipationService.get_participants_count( + session, lottery.id + ) + print(f"👥 Участников в розыгрыше: {participants_count}") + + print(f"\n🎲 Проведение розыгрыша...") + + # Проводим розыгрыш + results = await LotteryService.conduct_draw(session, lottery.id) + + print(f"\n🏆 Результаты розыгрыша:") + print(f"🎯 Розыгрыш: {lottery.title}") + print("=" * 50) + + for place, winner_info in results.items(): + user = winner_info['user'] + prize = winner_info['prize'] + is_manual = winner_info['is_manual'] + + manual_indicator = "👑 (установлен вручную)" if is_manual else "🎲 (случайный)" + print(f"{place}. {user.first_name} (@{user.username}) {manual_indicator}") + print(f" 🎁 {prize}") + print() + + print("=" * 50) + print("✅ Розыгрыш завершен успешно!") + + print(f"\n📈 Финальная статистика...") + + # Получаем победителей из базы данных + winners = await LotteryService.get_winners(session, lottery.id) + print(f"🏆 Сохранено победителей в базе: {len(winners)}") + + # Проверяем участия пользователей + for user in users[:2]: # Проверяем первых двух + participations = await ParticipationService.get_user_participations( + session, user.id + ) + print(f"📝 {user.first_name} участвует в {len(participations)} розыгрышах") + + +async def test_edge_cases(): + """Тестирование граничных случаев""" + print("\n🧪 Тестирование граничных случаев...") + + async with async_session_maker() as session: + # Попытка добавить участника дважды + user = await UserService.get_user_by_telegram_id(session, 100000001) + lottery = (await LotteryService.get_active_lotteries(session))[0] + + success = await LotteryService.add_participant(session, lottery.id, user.id) + print(f"Повторное добавление участника: {'❌ Заблокировано' if not success else '⚠️ Разрешено'}") + + # Попытка установить ручного победителя для несуществующего пользователя + success = await LotteryService.set_manual_winner(session, lottery.id, 10, 999999999) + print(f"Установка несуществующего победителя: {'❌ Заблокировано' if not success else '⚠️ Разрешено'}") + + # Попытка провести розыгрыш завершенной лотереи + results = await LotteryService.conduct_draw(session, lottery.id) + print(f"Повторный розыгрыш: {'❌ Заблокирован' if not results else '⚠️ Разрешен'}") + + +if __name__ == "__main__": + print("🚀 Запуск примера использования бота для розыгрышей") + print("=" * 60) + + try: + asyncio.run(example_usage()) + asyncio.run(test_edge_cases()) + + print("\n" + "=" * 60) + print("✅ Все тесты выполнены успешно!") + print("🎉 Бот готов к использованию!") + + except Exception as e: + print(f"\n❌ Ошибка во время выполнения: {e}") + import traceback + traceback.print_exc() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..0543c0f --- /dev/null +++ b/main.py @@ -0,0 +1,732 @@ +from aiogram import Bot, Dispatcher, Router, F +from aiogram.types import ( + Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, + BotCommand +) +from aiogram.filters import Command, StateFilter +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.fsm.storage.memory import MemoryStorage +from sqlalchemy.ext.asyncio import AsyncSession +import asyncio +import logging +import signal +import sys + +from config import BOT_TOKEN, ADMIN_IDS +from database import async_session_maker, init_db +from services import UserService, LotteryService, ParticipationService +from admin_panel import admin_router +from async_decorators import ( + async_user_action, admin_async_action, db_operation, + TaskManagerMiddleware, shutdown_task_manager, + format_task_stats, TaskPriority +) +from account_utils import validate_account_number, format_account_number +from winner_display import format_winner_display + + +# Настройка логирования +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Состояния для FSM +class CreateLotteryStates(StatesGroup): + waiting_for_title = State() + waiting_for_description = State() + waiting_for_prizes = State() + +class SetWinnerStates(StatesGroup): + waiting_for_lottery_id = State() + waiting_for_place = State() + waiting_for_user_id = State() + +class AccountStates(StatesGroup): + waiting_for_account_number = State() + + +# Инициализация бота +bot = Bot(token=BOT_TOKEN) +storage = MemoryStorage() +dp = Dispatcher(storage=storage) +router = Router() + +# Подключаем middleware для управления задачами +dp.message.middleware(TaskManagerMiddleware()) +dp.callback_query.middleware(TaskManagerMiddleware()) + + +def is_admin(user_id: int) -> bool: + """Проверка, является ли пользователь администратором""" + return user_id in ADMIN_IDS + + +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")] + ] + + 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")] + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +@router.message(Command("start")) +async def cmd_start(message: Message): + """Обработчик команды /start""" + async with async_session_maker() as session: + user = await UserService.get_or_create_user( + session, + telegram_id=message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name + ) + + # Устанавливаем права администратора, если пользователь в списке + if message.from_user.id in ADMIN_IDS: + await UserService.set_admin(session, message.from_user.id, True) + + is_admin_user = is_admin(message.from_user.id) + + welcome_text = f"Добро пожаловать, {message.from_user.first_name}! 🎉\n\n" + welcome_text += "Это бот для проведения розыгрышей.\n\n" + welcome_text += "Выберите действие из меню ниже:" + + if is_admin_user: + welcome_text += "\n\n👑 У вас есть права администратора!" + + await message.answer( + welcome_text, + reply_markup=get_main_keyboard(is_admin_user) + ) + + +@router.callback_query(F.data == "list_lotteries") +async def show_active_lotteries(callback: CallbackQuery): + """Показать активные розыгрыши""" + async with async_session_maker() as session: + lotteries = await LotteryService.get_active_lotteries(session) + + if not lotteries: + await callback.message.edit_text( + "🔍 Активных розыгрышей нет", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")] + ]) + ) + return + + text = "🎲 Активные розыгрыши:\n\n" + buttons = [] + + for lottery in lotteries: + async with async_session_maker() as session: + participants_count = await ParticipationService.get_participants_count( + session, lottery.id + ) + + text += f"🎯 {lottery.title}\n" + text += f"👥 Участников: {participants_count}\n" + text += f"📅 Создан: {lottery.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" + + buttons.append([ + InlineKeyboardButton( + text=f"🎲 {lottery.title}", + callback_data=f"lottery_{lottery.id}" + ) + ]) + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")]) + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons) + ) + + +@router.callback_query(F.data.startswith("lottery_")) +async def show_lottery_details(callback: CallbackQuery): + """Показать детали розыгрыша""" + lottery_id = int(callback.data.split("_")[1]) + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + + if not lottery: + await callback.answer("Розыгрыш не найден", show_alert=True) + return + + participants_count = await ParticipationService.get_participants_count(session, lottery_id) + + # Проверяем, участвует ли пользователь + is_participating = any( + p.user_id == user.id for p in lottery.participations + ) if user else False + + text = f"🎯 {lottery.title}\n\n" + text += f"📋 Описание: {lottery.description or 'Не указано'}\n\n" + + if lottery.prizes: + text += "🏆 Призы:\n" + for i, prize in enumerate(lottery.prizes, 1): + text += f"{i}. {prize}\n" + text += "\n" + + text += f"👥 Участников: {participants_count}\n" + text += f"📅 Создан: {lottery.created_at.strftime('%d.%m.%Y %H:%M')}\n" + + if lottery.is_completed: + text += "\n✅ Розыгрыш завершен" + # Показываем победителей + async with async_session_maker() as session: + winners = await LotteryService.get_winners(session, lottery_id) + + 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" + else: + text += f"\n🟢 Статус: {'Активен' if lottery.is_active else 'Неактивен'}" + if is_participating: + text += "\n✅ Вы участвуете в розыгрыше" + + buttons = [] + + if not lottery.is_completed and lottery.is_active and not is_participating: + buttons.append([ + InlineKeyboardButton( + text="🎫 Участвовать", + callback_data=f"join_{lottery_id}" + ) + ]) + + if is_admin(callback.from_user.id) and not lottery.is_completed: + buttons.append([ + InlineKeyboardButton( + text="🎲 Провести розыгрыш", + callback_data=f"conduct_{lottery_id}" + ) + ]) + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="list_lotteries")]) + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons) + ) + + +@router.callback_query(F.data.startswith("join_")) +async def join_lottery(callback: CallbackQuery): + """Присоединиться к розыгрышу""" + lottery_id = int(callback.data.split("_")[1]) + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + if not user: + await callback.answer("Ошибка получения данных пользователя", show_alert=True) + return + + success = await LotteryService.add_participant(session, lottery_id, user.id) + + if success: + await callback.answer("✅ Вы успешно присоединились к розыгрышу!", show_alert=True) + else: + await callback.answer("❌ Вы уже участвуете в этом розыгрыше", show_alert=True) + + # Обновляем информацию о розыгрыше + await show_lottery_details(callback) + + +@router.callback_query(F.data.startswith("conduct_")) +async def conduct_lottery(callback: CallbackQuery): + """Провести розыгрыш""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[1]) + + async with async_session_maker() as session: + results = await LotteryService.conduct_draw(session, lottery_id) + + if not results: + await callback.answer("❌ Не удалось провести розыгрыш", show_alert=True) + return + + text = "🎉 Розыгрыш завершен!\n\n🏆 Победители:\n\n" + + for place, winner_info in results.items(): + user = winner_info['user'] + prize = winner_info['prize'] + + # Используем новую систему отображения + winner_display = format_winner_display(user, lottery, show_sensitive_data=False) + text += f"{place}. {winner_display}\n" + text += f" 🎁 {prize}\n\n" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 К розыгрышам", callback_data="list_lotteries")] + ]) + ) + + +# Создание розыгрыша +@router.callback_query(F.data == "create_lottery") +async def start_create_lottery(callback: CallbackQuery, state: FSMContext): + """Начать создание розыгрыша""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + await callback.message.edit_text( + "📝 Создание нового розыгрыша\n\n" + "Введите название розыгрыша:", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")] + ]) + ) + await state.set_state(CreateLotteryStates.waiting_for_title) + + +@router.message(StateFilter(CreateLotteryStates.waiting_for_title)) +async def process_lottery_title(message: Message, state: FSMContext): + """Обработка названия розыгрыша""" + await state.update_data(title=message.text) + await message.answer( + "📋 Введите описание розыгрыша (или отправьте '-' для пропуска):" + ) + await state.set_state(CreateLotteryStates.waiting_for_description) + + +@router.message(StateFilter(CreateLotteryStates.waiting_for_description)) +async def process_lottery_description(message: Message, state: FSMContext): + """Обработка описания розыгрыша""" + description = None if message.text == "-" else message.text + await state.update_data(description=description) + + await message.answer( + "🏆 Введите призы через новую строку:\n\n" + "Пример:\n" + "1000 рублей\n" + "iPhone 15\n" + "Подарочный сертификат" + ) + await state.set_state(CreateLotteryStates.waiting_for_prizes) + + +@router.message(StateFilter(CreateLotteryStates.waiting_for_prizes)) +async def process_lottery_prizes(message: Message, state: FSMContext): + """Обработка призов розыгрыша""" + prizes = [prize.strip() for prize in message.text.split('\n') if prize.strip()] + + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, message.from_user.id) + + data = await state.get_data() + lottery = await LotteryService.create_lottery( + session, + title=data['title'], + description=data['description'], + prizes=prizes, + creator_id=user.id + ) + + await state.clear() + + text = f"✅ Розыгрыш успешно создан!\n\n" + text += f"🎯 Название: {lottery.title}\n" + text += f"📋 Описание: {lottery.description or 'Не указано'}\n\n" + text += f"🏆 Призы:\n" + for i, prize in enumerate(prizes, 1): + text += f"{i}. {prize}\n" + + await message.answer( + text, + reply_markup=get_main_keyboard(is_admin(message.from_user.id)) + ) + + +# Установка ручного победителя +@router.callback_query(F.data == "set_winner") +async def start_set_winner(callback: CallbackQuery, state: FSMContext): + """Начать установку ручного победителя""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + lotteries = await LotteryService.get_active_lotteries(session) + + if not lotteries: + await callback.message.edit_text( + "❌ Нет активных розыгрышей", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")] + ]) + ) + return + + text = "👑 Установка ручного победителя\n\n" + text += "Выберите розыгрыш:\n\n" + + buttons = [] + for lottery in lotteries: + text += f"🎯 {lottery.title} (ID: {lottery.id})\n" + buttons.append([ + InlineKeyboardButton( + text=f"{lottery.title}", + callback_data=f"setwinner_{lottery.id}" + ) + ]) + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")]) + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons) + ) + + +@router.callback_query(F.data.startswith("setwinner_")) +async def select_winner_place(callback: CallbackQuery, state: FSMContext): + """Выбор места для ручного победителя""" + 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 + + await state.update_data(lottery_id=lottery_id) + + num_prizes = len(lottery.prizes) if lottery.prizes else 3 + text = f"👑 Установка ручного победителя для розыгрыша:\n" + text += f"🎯 {lottery.title}\n\n" + text += f"Введите номер места (1-{num_prizes}):" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отмена", callback_data="set_winner")] + ]) + ) + await state.set_state(SetWinnerStates.waiting_for_place) + + +@router.message(StateFilter(SetWinnerStates.waiting_for_place)) +async def process_winner_place(message: Message, state: FSMContext): + """Обработка места победителя""" + try: + place = int(message.text) + if place < 1: + raise ValueError + except ValueError: + await message.answer("❌ Введите корректный номер места (положительное число)") + return + + await state.update_data(place=place) + await message.answer( + f"👑 Установка ручного победителя на {place} место\n\n" + "Введите Telegram ID пользователя:" + ) + await state.set_state(SetWinnerStates.waiting_for_user_id) + + +@router.message(StateFilter(SetWinnerStates.waiting_for_user_id)) +async def process_winner_user_id(message: Message, state: FSMContext): + """Обработка ID пользователя-победителя""" + try: + telegram_id = int(message.text) + except ValueError: + await message.answer("❌ Введите корректный Telegram ID (число)") + return + + data = await state.get_data() + + async with async_session_maker() as session: + success = await LotteryService.set_manual_winner( + session, + data['lottery_id'], + data['place'], + telegram_id + ) + + await state.clear() + + if success: + await message.answer( + f"✅ Ручной победитель установлен!\n\n" + f"🏆 Место: {data['place']}\n" + f"👤 Telegram ID: {telegram_id}", + reply_markup=get_main_keyboard(is_admin(message.from_user.id)) + ) + else: + await message.answer( + "❌ Не удалось установить ручного победителя.\n" + "Проверьте, что пользователь существует в системе.", + reply_markup=get_main_keyboard(is_admin(message.from_user.id)) + ) + + +@router.callback_query(F.data == "my_participations") +async def show_my_participations(callback: CallbackQuery): + """Показать участие пользователя в розыгрышах""" + async with async_session_maker() as session: + user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) + if not user: + await callback.answer("Ошибка получения данных пользователя", show_alert=True) + return + + participations = await ParticipationService.get_user_participations(session, user.id) + + if not participations: + await callback.message.edit_text( + "📝 Вы пока не участвуете в розыгрышах", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")] + ]) + ) + return + + text = "📝 Ваши участия в розыгрышах:\n\n" + + for participation in participations: + lottery = participation.lottery + status = "✅ Завершен" if lottery.is_completed else "🟢 Активен" + text += f"🎯 {lottery.title}\n" + text += f"📊 Статус: {status}\n" + text += f"📅 Участие с: {participation.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")] + ]) + ) + + +# Хэндлеры для работы с номерами счетов + +@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) + + if not user: + await callback.answer("Пользователь не найден", show_alert=True) + return + + text = "💳 **Ваш клиентский счёт**\n\n" + + if user.account_number: + # Показываем маскированный номер для безопасности + from 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 += "ℹ️ Счёт используется для идентификации в розыгрышах" + else: + 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")]) + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="Markdown" + ) + + +@router.callback_query(F.data.in_(["add_account", "change_account"])) +@db_operation() +async def start_account_setup(callback: CallbackQuery, state: FSMContext): + """Начало процесса привязки/изменения счёта""" + await state.set_state(AccountStates.waiting_for_account_number) + + action = "привязки" if callback.data == "add_account" else "изменения" + + text = f"💳 **Процедура {action} счёта**\n\n" + text += "Введите номер вашего клиентского счёта в формате:\n" + text += "`12-34-56-78-90-12-34-56`\n\n" + text += "📝 **Требования:**\n" + text += "• Ровно 16 цифр\n" + text += "• Разделены дефисами через каждые 2 цифры\n" + text += "• Номер должен быть уникальным\n\n" + text += "✉️ Отправьте номер счёта в ответном сообщении" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="❌ Отмена", callback_data="my_account")] + ]), + parse_mode="Markdown" + ) + + +@router.message(StateFilter(AccountStates.waiting_for_account_number)) +@db_operation() +async def process_account_number(message: Message, state: FSMContext): + """Обработка введённого номера счёта""" + account_input = message.text.strip() + + # Форматируем и валидируем номер + formatted_number = format_account_number(account_input) + + if not formatted_number: + await message.answer( + "❌ **Некорректный формат номера счёта**\n\n" + "Номер должен содержать ровно 16 цифр.\n" + "Пример правильного формата: `12-34-56-78-90-12-34-56`\n\n" + "Попробуйте ещё раз:", + parse_mode="Markdown" + ) + return + + async with async_session_maker() as session: + # Проверяем уникальность + existing_user = await UserService.get_user_by_account(session, formatted_number) + if existing_user and existing_user.telegram_id != message.from_user.id: + await message.answer( + "❌ **Номер счёта уже используется**\n\n" + "Данный номер счёта уже привязан к другому пользователю.\n" + "Убедитесь, что вы вводите правильный номер.\n\n" + "Попробуйте ещё раз:" + ) + return + + # Обновляем номер счёта + success = await UserService.set_account_number( + session, message.from_user.id, formatted_number + ) + + if success: + await state.clear() + await message.answer( + f"✅ **Счёт успешно привязан!**\n\n" + f"💳 Номер счёта: `{formatted_number}`\n\n" + f"Теперь вы можете участвовать в розыгрышах.\n" + f"Ваш номер счёта будет использоваться для идентификации.", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_main")] + ]) + ) + else: + await message.answer( + "❌ **Ошибка привязки счёта**\n\n" + "Произошла ошибка при сохранении номера счёта.\n" + "Попробуйте ещё раз или обратитесь к администратору.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 Назад", callback_data="my_account")] + ]) + ) + + +@router.callback_query(F.data == "task_stats") +@admin_async_action() +async def show_task_stats(callback: CallbackQuery): + """Показать статистику задач (только для админов)""" + if not is_admin(callback.from_user.id): + await callback.answer("Доступ запрещён", show_alert=True) + return + + stats_text = await format_task_stats() + + await callback.message.edit_text( + stats_text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔄 Обновить", callback_data="task_stats")], + [InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")] + ]), + parse_mode="Markdown" + ) + + +@router.callback_query(F.data == "back_to_main") +async def back_to_main(callback: CallbackQuery, state: FSMContext): + """Вернуться в главное меню""" + await state.clear() + + is_admin_user = is_admin(callback.from_user.id) + await callback.message.edit_text( + "🏠 Главное меню\n\nВыберите действие:", + reply_markup=get_main_keyboard(is_admin_user) + ) + + +async def set_commands(): + """Установка команд бота""" + commands = [ + BotCommand(command="start", description="🚀 Запустить бота"), + ] + await bot.set_my_commands(commands) + + +async def main(): + """Главная функция""" + # Инициализация базы данных + await init_db() + + # Установка команд + await set_commands() + + # Подключение роутеров + dp.include_router(router) + dp.include_router(admin_router) + + # Обработка сигналов для graceful shutdown + def signal_handler(): + logger.info("Получен сигнал завершения, остановка бота...") + asyncio.create_task(shutdown_task_manager()) + + # Настройка обработчиков сигналов + if sys.platform != "win32": + for sig in (signal.SIGTERM, signal.SIGINT): + asyncio.get_event_loop().add_signal_handler(sig, signal_handler) + + # Запуск бота + logger.info("Бот запущен") + try: + await dp.start_polling(bot) + finally: + # Остановка менеджера задач при завершении + await shutdown_task_manager() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Бот остановлен пользователем") + except Exception as e: + logger.error(f"Критическая ошибка: {e}") + finally: + logger.info("Завершение работы") \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..5068e98 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,94 @@ +import asyncio +from logging.config import fileConfig +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config +from alembic import context +import os +import sys +from pathlib import Path + +# Добавляем корневую директорию проекта в sys.path +ROOT_PATH = Path(__file__).parent.parent +sys.path.append(str(ROOT_PATH)) + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +from models import Base +target_metadata = Base.metadata + +# Импортируем настройки базы данных +from database import DATABASE_URL + +# Обновляем URL базы данных из переменных окружения +config.set_main_option("sqlalchemy.url", DATABASE_URL) + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..37d0cac --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/migrations/versions/001_initial_migration.py b/migrations/versions/001_initial_migration.py new file mode 100644 index 0000000..0645750 --- /dev/null +++ b/migrations/versions/001_initial_migration.py @@ -0,0 +1,82 @@ +"""Initial migration - Create lottery bot tables + +Revision ID: 001 +Revises: +Create Date: 2024-12-11 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import sqlite + +# revision identifiers, used by Alembic. +revision = '001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('telegram_id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=255), nullable=True), + sa.Column('first_name', sa.String(length=255), nullable=True), + sa.Column('last_name', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('is_admin', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_telegram_id'), 'users', ['telegram_id'], unique=True) + + op.create_table('lotteries', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=500), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('start_date', sa.DateTime(), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('is_completed', sa.Boolean(), nullable=True), + sa.Column('prizes', sa.JSON(), nullable=True), + sa.Column('creator_id', sa.Integer(), nullable=False), + sa.Column('manual_winners', sa.JSON(), nullable=True), + sa.Column('draw_results', sa.JSON(), nullable=True), + sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + op.create_table('participations', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('lottery_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['lottery_id'], ['lotteries.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + op.create_table('winners', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('lottery_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('place', sa.Integer(), nullable=False), + sa.Column('prize', sa.String(length=500), nullable=True), + sa.Column('is_manual', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['lottery_id'], ['lotteries.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('winners') + op.drop_table('participations') + op.drop_table('lotteries') + op.drop_index(op.f('ix_users_telegram_id'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### \ No newline at end of file diff --git a/migrations/versions/002_add_account_numbers_and_display_type.py b/migrations/versions/002_add_account_numbers_and_display_type.py new file mode 100644 index 0000000..0be7364 --- /dev/null +++ b/migrations/versions/002_add_account_numbers_and_display_type.py @@ -0,0 +1,45 @@ +"""Add account numbers and winner display type + +Revision ID: 002_add_account_numbers_and_display_type +Revises: 001_initial_migration +Create Date: 2025-11-12 06:57:40.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '002_add_account_numbers_and_display_type' +down_revision = '001' +branch_labels = None +depends_on = None + + +def upgrade(): + """Добавить поля для клиентских счетов и типа отображения победителей""" + + # Добавить поле account_number в таблицу users + op.add_column('users', sa.Column('account_number', sa.String(length=23), nullable=True)) + + # Создать индекс для account_number для быстрого поиска + op.create_index(op.f('ix_users_account_number'), 'users', ['account_number'], unique=True) + + # Добавить поле winner_display_type в таблицу lotteries + op.add_column('lotteries', sa.Column('winner_display_type', sa.String(length=20), nullable=True)) + + # Установить значения по умолчанию для существующих записей + op.execute("UPDATE lotteries SET winner_display_type = 'username' WHERE winner_display_type IS NULL") + + +def downgrade(): + """Удалить добавленные поля""" + + # Удалить поле winner_display_type из таблицы lotteries + op.drop_column('lotteries', 'winner_display_type') + + # Удалить индекс для account_number + op.drop_index(op.f('ix_users_account_number'), table_name='users') + + # Удалить поле account_number из таблицы users + op.drop_column('users', 'account_number') \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..a11d9b8 --- /dev/null +++ b/models.py @@ -0,0 +1,92 @@ +from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON +from sqlalchemy.orm import relationship +from datetime import datetime +from database import Base + + +class User(Base): + """Модель пользователя""" + __tablename__ = "users" + + id = Column(Integer, primary_key=True) + telegram_id = Column(Integer, unique=True, nullable=False, index=True) + username = Column(String(255)) + first_name = Column(String(255)) + last_name = Column(String(255)) + created_at = Column(DateTime, default=datetime.utcnow) + is_admin = Column(Boolean, default=False) + # Клиентский счет в формате: XX-XX-XX-XX-XX-XX-XX-XX (8 пар цифр через дефис) + account_number = Column(String(23), unique=True, nullable=True, index=True) + + # Связи + participations = relationship("Participation", back_populates="user") + + def __repr__(self): + return f"" + + +class Lottery(Base): + """Модель розыгрыша""" + __tablename__ = "lotteries" + + id = Column(Integer, primary_key=True) + title = Column(String(500), nullable=False) + description = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + start_date = Column(DateTime) + end_date = Column(DateTime) + is_active = Column(Boolean, default=True) + is_completed = Column(Boolean, default=False) + prizes = Column(JSON) # Список призов в формате JSON + creator_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + # Настройки для ручного управления победителями + manual_winners = Column(JSON, default=lambda: {}) # {место: telegram_id} + draw_results = Column(JSON) # Результаты розыгрыша + + # Тип отображения победителей: "username", "chat_id", "account_number" + winner_display_type = Column(String(20), default="username") + + # Связи + creator = relationship("User") + participations = relationship("Participation", back_populates="lottery") + + def __repr__(self): + return f"" + + +class Participation(Base): + """Модель участия в розыгрыше""" + __tablename__ = "participations" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + # Связи + user = relationship("User", back_populates="participations") + lottery = relationship("Lottery", back_populates="participations") + + def __repr__(self): + return f"" + + +class Winner(Base): + """Модель победителя розыгрыша""" + __tablename__ = "winners" + + id = Column(Integer, primary_key=True) + lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + place = Column(Integer, nullable=False) # Место (1, 2, 3...) + prize = Column(String(500)) # Описание приза + is_manual = Column(Boolean, default=False) # Был ли установлен вручную + created_at = Column(DateTime, default=datetime.utcnow) + + # Связи + user = relationship("User") + lottery = relationship("Lottery") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d922629 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +aiogram==3.1.1 +sqlalchemy==2.0.23 +alembic==1.8.1 +python-dotenv==1.0.0 +asyncpg==0.28.0 +aiosqlite==0.17.0 \ No newline at end of file diff --git a/services.py b/services.py new file mode 100644 index 0000000..8b0c837 --- /dev/null +++ b/services.py @@ -0,0 +1,630 @@ +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 typing import List, Optional, Dict, Any +from account_utils import validate_account_number, format_account_number +import random + + +class UserService: + """Сервис для работы с пользователями""" + + @staticmethod + async def get_or_create_user(session: AsyncSession, telegram_id: int, + username: str = None, first_name: str = None, + last_name: str = None) -> User: + """Получить или создать пользователя""" + # Пробуем найти существующего пользователя + result = await session.execute( + select(User).where(User.telegram_id == telegram_id) + ) + user = result.scalar_one_or_none() + + if user: + # Обновляем информацию о пользователе + user.username = username + user.first_name = first_name + user.last_name = last_name + await session.commit() + return user + + # Создаем нового пользователя + user = User( + telegram_id=telegram_id, + username=username, + first_name=first_name, + last_name=last_name + ) + session.add(user) + await session.commit() + await session.refresh(user) + return user + + @staticmethod + async def get_user_by_telegram_id(session: AsyncSession, telegram_id: int) -> Optional[User]: + """Получить пользователя по Telegram ID""" + result = await session.execute( + select(User).where(User.telegram_id == telegram_id) + ) + return result.scalar_one_or_none() + + @staticmethod + async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]: + """Получить пользователя по username""" + result = await session.execute(select(User).where(User.username == username)) + return result.scalar_one_or_none() + + @staticmethod + async def get_all_users(session: AsyncSession, limit: int = None, offset: int = 0) -> List[User]: + """Получить всех пользователей""" + query = select(User).order_by(User.created_at.desc()) + + if limit: + query = query.offset(offset).limit(limit) + + result = await session.execute(query) + return result.scalars().all() + + @staticmethod + async def search_users(session: AsyncSession, search_term: str, limit: int = 20) -> List[User]: + """Поиск пользователей по имени или username""" + from sqlalchemy import or_, func + + search_pattern = f"%{search_term.lower()}%" + + result = await session.execute( + select(User).where( + or_( + func.lower(User.first_name).contains(search_pattern), + func.lower(User.last_name).contains(search_pattern) if User.last_name else False, + func.lower(User.username).contains(search_pattern) if User.username else False + ) + ).limit(limit) + ) + return result.scalars().all() + + @staticmethod + async def delete_user(session: AsyncSession, user_id: int) -> bool: + """Удалить пользователя и все связанные данные""" + user = await session.get(User, user_id) + if not user: + return False + + # Удаляем все участия + await session.execute( + delete(Participation).where(Participation.user_id == user_id) + ) + + # Удаляем все победы + await session.execute( + delete(Winner).where(Winner.user_id == user_id) + ) + + # Удаляем пользователя + await session.delete(user) + await session.commit() + return True + + @staticmethod + async def set_admin(session: AsyncSession, telegram_id: int, is_admin: bool = True) -> bool: + """Установить/снять права администратора""" + result = await session.execute( + update(User) + .where(User.telegram_id == telegram_id) + .values(is_admin=is_admin) + ) + await session.commit() + return result.rowcount > 0 + + @staticmethod + async def set_account_number(session: AsyncSession, telegram_id: int, account_number: str) -> bool: + """Установить номер клиентского счета пользователю""" + # Валидируем и форматируем номер + formatted_number = format_account_number(account_number) + if not formatted_number: + return False + + # Проверяем уникальность номера + existing = await session.execute( + select(User).where(User.account_number == formatted_number) + ) + if existing.scalar_one_or_none(): + return False # Номер уже занят + + # Обновляем пользователя + result = await session.execute( + update(User) + .where(User.telegram_id == telegram_id) + .values(account_number=formatted_number) + ) + await session.commit() + return result.rowcount > 0 + + @staticmethod + async def get_user_by_account(session: AsyncSession, account_number: str) -> Optional[User]: + """Получить пользователя по номеру счета""" + formatted_number = format_account_number(account_number) + if not formatted_number: + return None + + result = await session.execute( + select(User).where(User.account_number == formatted_number) + ) + return result.scalar_one_or_none() + + @staticmethod + async def search_by_account(session: AsyncSession, account_pattern: str) -> List[User]: + """Поиск пользователей по части номера счета""" + # Убираем все кроме цифр и дефисов + clean_pattern = ''.join(c for c in account_pattern if c.isdigit() or c == '-') + if not clean_pattern: + return [] + + result = await session.execute( + select(User).where( + User.account_number.like(f'%{clean_pattern}%') + ).limit(20) + ) + return result.scalars().all() + + +class LotteryService: + """Сервис для работы с розыгрышами""" + + @staticmethod + async def create_lottery(session: AsyncSession, title: str, description: str, + prizes: List[str], creator_id: int) -> Lottery: + """Создать новый розыгрыш""" + lottery = Lottery( + title=title, + description=description, + prizes=prizes, + creator_id=creator_id + ) + session.add(lottery) + await session.commit() + await session.refresh(lottery) + return lottery + + @staticmethod + async def get_lottery(session: AsyncSession, lottery_id: int) -> Optional[Lottery]: + """Получить розыгрыш по ID""" + result = await session.execute( + select(Lottery) + .options(selectinload(Lottery.participations).selectinload(Participation.user)) + .where(Lottery.id == lottery_id) + ) + return result.scalar_one_or_none() + + @staticmethod + async def get_active_lotteries(session: AsyncSession) -> List[Lottery]: + """Получить список активных розыгрышей""" + result = await session.execute( + select(Lottery) + .where(Lottery.is_active == True, Lottery.is_completed == False) + .order_by(Lottery.created_at.desc()) + ) + return result.scalars().all() + + @staticmethod + async def get_all_lotteries(session: AsyncSession) -> List[Lottery]: + """Получить список всех розыгрышей""" + result = await session.execute( + select(Lottery) + .order_by(Lottery.created_at.desc()) + ) + return result.scalars().all() + + @staticmethod + async def set_manual_winner(session: AsyncSession, lottery_id: int, + place: int, telegram_id: int) -> bool: + """Установить ручного победителя для определенного места""" + # Получаем пользователя + user = await UserService.get_user_by_telegram_id(session, telegram_id) + if not user: + return False + + # Получаем розыгрыш + lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + return False + + # Обновляем ручных победителей + if not lottery.manual_winners: + lottery.manual_winners = {} + + lottery.manual_winners[str(place)] = telegram_id + await session.commit() + return True + + @staticmethod + async def conduct_draw(session: AsyncSession, lottery_id: int) -> Dict[int, Dict[str, Any]]: + """Провести розыгрыш с учетом ручных победителей""" + lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery or lottery.is_completed: + return {} + + # Получаем всех участников + participants = [p.user for p in lottery.participations] + if not participants: + return {} + + # Определяем количество призовых мест + num_prizes = len(lottery.prizes) if lottery.prizes else 1 + + results = {} + remaining_participants = participants.copy() + manual_winners = lottery.manual_winners or {} + + # Сначала обрабатываем ручных победителей + for place in range(1, num_prizes + 1): + place_str = str(place) + if place_str in manual_winners: + # Находим пользователя среди участников + manual_winner = None + for participant in remaining_participants: + if participant.telegram_id == manual_winners[place_str]: + manual_winner = participant + break + + if manual_winner: + results[place] = { + 'user': manual_winner, + 'prize': lottery.prizes[place - 1] if lottery.prizes and place <= len(lottery.prizes) else f"Приз {place} места", + 'is_manual': True + } + remaining_participants.remove(manual_winner) + + # Заполняем оставшиеся места случайными участниками + for place in range(1, num_prizes + 1): + if place not in results and remaining_participants: + winner = random.choice(remaining_participants) + results[place] = { + 'user': winner, + 'prize': lottery.prizes[place - 1] if lottery.prizes and place <= len(lottery.prizes) else f"Приз {place} места", + 'is_manual': False + } + remaining_participants.remove(winner) + + # Сохраняем победителей в базу данных + for place, winner_info in results.items(): + winner = Winner( + lottery_id=lottery_id, + user_id=winner_info['user'].id, + place=place, + prize=winner_info['prize'], + is_manual=winner_info['is_manual'] + ) + session.add(winner) + + # Обновляем статус розыгрыша + lottery.is_completed = True + lottery.draw_results = { + str(place): { + 'user_id': info['user'].id, + 'telegram_id': info['user'].telegram_id, + 'username': info['user'].username, + 'prize': info['prize'], + 'is_manual': info['is_manual'] + } for place, info in results.items() + } + + await session.commit() + return results + + @staticmethod + async def get_winners(session: AsyncSession, lottery_id: int) -> List[Winner]: + """Получить победителей розыгрыша""" + result = await session.execute( + select(Winner) + .options(selectinload(Winner.user)) + .where(Winner.lottery_id == lottery_id) + .order_by(Winner.place) + ) + return result.scalars().all() + + @staticmethod + async def set_winner_display_type(session: AsyncSession, lottery_id: int, display_type: str) -> bool: + """Установить тип отображения победителей для розыгрыша""" + from winner_display import validate_display_type + + if not validate_display_type(display_type): + return False + + result = await session.execute( + update(Lottery) + .where(Lottery.id == lottery_id) + .values(winner_display_type=display_type) + ) + await session.commit() + return result.rowcount > 0 + + @staticmethod + async def set_lottery_active(session: AsyncSession, lottery_id: int, is_active: bool) -> bool: + """Установить статус активности розыгрыша""" + result = await session.execute( + update(Lottery) + .where(Lottery.id == lottery_id) + .values(is_active=is_active) + ) + await session.commit() + return result.rowcount > 0 + + @staticmethod + async def complete_lottery(session: AsyncSession, lottery_id: int) -> bool: + """Завершить розыгрыш (сделать неактивным и завершенным)""" + from datetime import datetime + + result = await session.execute( + update(Lottery) + .where(Lottery.id == lottery_id) + .values(is_active=False, is_completed=True, end_date=datetime.now()) + ) + await session.commit() + return result.rowcount > 0 + + @staticmethod + async def delete_lottery(session: AsyncSession, lottery_id: int) -> bool: + """Удалить розыгрыш и все связанные данные""" + # Сначала удаляем все связанные данные + # Удаляем победителей + await session.execute( + delete(Winner).where(Winner.lottery_id == lottery_id) + ) + + # Удаляем участников + await session.execute( + delete(Participation).where(Participation.lottery_id == lottery_id) + ) + + # Удаляем сам розыгрыш + result = await session.execute( + delete(Lottery).where(Lottery.id == lottery_id) + ) + + await session.commit() + return result.rowcount > 0 + + +class ParticipationService: + """Сервис для работы с участием в розыгрышах""" + + @staticmethod + async def add_participant(session: AsyncSession, lottery_id: int, user_id: int) -> bool: + """Добавить участника в розыгрыш""" + # Проверяем, не участвует ли уже пользователь + existing = await session.execute( + select(Participation) + .where(Participation.lottery_id == lottery_id, Participation.user_id == user_id) + ) + if existing.scalar_one_or_none(): + return False + + participation = Participation(lottery_id=lottery_id, user_id=user_id) + session.add(participation) + await session.commit() + return True + + @staticmethod + async def remove_participant(session: AsyncSession, lottery_id: int, user_id: int) -> bool: + """Удалить участника из розыгрыша""" + participation = await session.execute( + select(Participation) + .where(Participation.lottery_id == lottery_id, Participation.user_id == user_id) + ) + participation = participation.scalar_one_or_none() + + if not participation: + return False + + await session.delete(participation) + await session.commit() + return True + + @staticmethod + async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]: + """Получить участников розыгрыша""" + query = select(User).join(Participation).where(Participation.lottery_id == lottery_id) + + if limit: + query = query.offset(offset).limit(limit) + + result = await session.execute(query) + return list(result.scalars().all()) + + @staticmethod + async def get_user_participations(session: AsyncSession, user_id: int) -> List[Participation]: + """Получить участие пользователя в розыгрышах""" + result = await session.execute( + select(Participation) + .options(selectinload(Participation.lottery)) + .where(Participation.user_id == user_id) + .order_by(Participation.created_at.desc()) + ) + return list(result.scalars().all()) + + @staticmethod + async def get_participants_count(session: AsyncSession, lottery_id: int) -> int: + """Получить количество участников в розыгрыше""" + result = await session.execute( + select(Participation) + .where(Participation.lottery_id == lottery_id) + ) + return len(result.scalars().all()) + + @staticmethod + async def add_participants_bulk(session: AsyncSession, lottery_id: int, telegram_ids: List[int]) -> Dict[str, Any]: + """Массовое добавление участников""" + results = { + "added": 0, + "skipped": 0, + "errors": [], + "details": [] + } + + for telegram_id in telegram_ids: + try: + # Проверяем, существует ли пользователь + user = await UserService.get_user_by_telegram_id(session, telegram_id) + if not user: + results["errors"].append(f"Пользователь {telegram_id} не найден") + continue + + # Пробуем добавить + if await ParticipationService.add_participant(session, lottery_id, user.id): + results["added"] += 1 + results["details"].append(f"Добавлен: {user.first_name} (@{user.username or 'no_username'})") + else: + results["skipped"] += 1 + results["details"].append(f"Уже участвует: {user.first_name}") + + except Exception as e: + results["errors"].append(f"Ошибка с {telegram_id}: {str(e)}") + + return results + + @staticmethod + async def remove_participants_bulk(session: AsyncSession, lottery_id: int, telegram_ids: List[int]) -> Dict[str, Any]: + """Массовое удаление участников""" + results = { + "removed": 0, + "not_found": 0, + "errors": [], + "details": [] + } + + for telegram_id in telegram_ids: + try: + user = await UserService.get_user_by_telegram_id(session, telegram_id) + if not user: + results["not_found"] += 1 + results["details"].append(f"Не найден: {telegram_id}") + continue + + if await ParticipationService.remove_participant(session, lottery_id, user.id): + results["removed"] += 1 + results["details"].append(f"Удален: {user.first_name}") + else: + results["not_found"] += 1 + results["details"].append(f"Не участвовал: {user.first_name}") + + except Exception as e: + results["errors"].append(f"Ошибка с {telegram_id}: {str(e)}") + + return results + + @staticmethod + async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]: + """Массовое добавление участников по номерам счетов""" + results = { + "added": 0, + "skipped": 0, + "errors": [], + "details": [], + "invalid_accounts": [] + } + + for account_number in account_numbers: + account_number = account_number.strip() + if not account_number: + continue + + try: + # Валидируем и форматируем номер + formatted_account = format_account_number(account_number) + if not formatted_account: + results["invalid_accounts"].append(account_number) + results["errors"].append(f"Неверный формат: {account_number}") + continue + + # Ищем пользователя по номеру счёта + user = await UserService.get_user_by_account(session, formatted_account) + if not user: + results["errors"].append(f"Пользователь с счётом {formatted_account} не найден") + continue + + # Пробуем добавить в розыгрыш + if await ParticipationService.add_participant(session, lottery_id, user.id): + results["added"] += 1 + results["details"].append(f"Добавлен: {user.first_name} ({formatted_account})") + else: + results["skipped"] += 1 + results["details"].append(f"Уже участвует: {user.first_name} ({formatted_account})") + + except Exception as e: + results["errors"].append(f"Ошибка с {account_number}: {str(e)}") + + return results + + @staticmethod + async def remove_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]: + """Массовое удаление участников по номерам счетов""" + results = { + "removed": 0, + "not_found": 0, + "errors": [], + "details": [], + "invalid_accounts": [] + } + + for account_number in account_numbers: + account_number = account_number.strip() + if not account_number: + continue + + try: + # Валидируем и форматируем номер + formatted_account = format_account_number(account_number) + if not formatted_account: + results["invalid_accounts"].append(account_number) + results["errors"].append(f"Неверный формат: {account_number}") + continue + + # Ищем пользователя по номеру счёта + user = await UserService.get_user_by_account(session, formatted_account) + if not user: + results["not_found"] += 1 + results["details"].append(f"Не найден: {formatted_account}") + continue + + # Пробуем удалить из розыгрыша + if await ParticipationService.remove_participant(session, lottery_id, user.id): + results["removed"] += 1 + results["details"].append(f"Удалён: {user.first_name} ({formatted_account})") + else: + results["not_found"] += 1 + results["details"].append(f"Не участвовал: {user.first_name} ({formatted_account})") + + except Exception as e: + results["errors"].append(f"Ошибка с {account_number}: {str(e)}") + + return results + + @staticmethod + async def get_participant_stats(session: AsyncSession, user_id: int) -> Dict[str, Any]: + """Статистика участника""" + from sqlalchemy import func + + # Количество участий + participations_count = await session.scalar( + select(func.count(Participation.id)).where(Participation.user_id == user_id) + ) + + # Количество побед + wins_count = await session.scalar( + select(func.count(Winner.id)).where(Winner.user_id == user_id) + ) + + # Последнее участие + last_participation = await session.execute( + select(Participation).where(Participation.user_id == user_id) + .order_by(Participation.created_at.desc()).limit(1) + ) + last_participation = last_participation.scalar_one_or_none() + + return { + "participations_count": participations_count, + "wins_count": wins_count, + "last_participation": last_participation.created_at if last_participation else None + } \ No newline at end of file diff --git a/simple_draw.py b/simple_draw.py new file mode 100644 index 0000000..fd5a009 --- /dev/null +++ b/simple_draw.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +""" +Простой скрипт для проведения розыгрыша +""" +import asyncio +from database import async_session_maker +from services import LotteryService + +async def conduct_simple_draw(): + """Проводим розыгрыш""" + print("🎲 Проведение розыгрыша") + print("=" * 30) + + lottery_id = 1 # Первый розыгрыш + + async with async_session_maker() as session: + print(f"🎯 Проводим розыгрыш #{lottery_id}") + + try: + # Проводим розыгрыш + winners = await LotteryService.conduct_draw(session, lottery_id) + + print(f"🎉 Розыгрыш проведен!") + print(f"📊 Результат: {winners}") + + except Exception as e: + print(f"❌ Ошибка: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(conduct_simple_draw()) \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..cd756a0 --- /dev/null +++ b/start.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Скрипт запуска бота для проедения розыгрышей + +echo "🚀 Запуск телеграм-бота для розыгрышей" + +# Проверка виртуального окружения +if [[ "$VIRTUAL_ENV" == "" ]]; then + echo "⚠️ Рекомендуется использовать виртуальное окружение" + echo "Создайте его командой: python -m venv venv" + echo "Активируйте: source venv/bin/activate" + echo "" +fi + +# Проверка файла .env +if [ ! -f .env ]; then + echo "❌ Файл .env не найден!" + echo "Скопируйте .env.example в .env и заполните переменные" + echo "cp .env.example .env" + exit 1 +fi + +# Проверка зависимостей +echo "📦 Проверка зависимостей..." +pip install -r requirements.txt + +# Инициализация базы данных +echo "🔄 Инициализация базы данных..." +python utils.py init + +# Установка прав администратора +echo "👑 Настройка администраторов..." +python utils.py setup-admins + +# Запуск бота +echo "🤖 Запуск бота..." +python main.py \ No newline at end of file diff --git a/task_manager.py b/task_manager.py new file mode 100644 index 0000000..3c388d7 --- /dev/null +++ b/task_manager.py @@ -0,0 +1,268 @@ +""" +Система управления многопоточностью и очередями для обработки запросов +""" +import asyncio +import time +from typing import Dict, Any, Optional, Callable +from dataclasses import dataclass +from enum import Enum +import logging + +logger = logging.getLogger(__name__) + + +class TaskPriority(Enum): + """Приоритеты задач""" + LOW = 1 + NORMAL = 2 + HIGH = 3 + CRITICAL = 4 + + +@dataclass +class Task: + """Задача для выполнения""" + id: str + user_id: int + priority: TaskPriority + func: Callable + args: tuple + kwargs: dict + created_at: float + timeout: float = 30.0 + + def __lt__(self, other): + """Сравнение для приоритетной очереди""" + if self.priority.value != other.priority.value: + return self.priority.value > other.priority.value + return self.created_at < other.created_at + + +class AsyncTaskManager: + """Менеджер асинхронных задач с поддержкой приоритетов и ограничений""" + + def __init__(self, max_workers: int = 10, max_user_concurrent: int = 3): + self.max_workers = max_workers + self.max_user_concurrent = max_user_concurrent + + # Очереди и семафоры - будут созданы при запуске + self.task_queue: Optional[asyncio.PriorityQueue] = None + self.worker_semaphore: Optional[asyncio.Semaphore] = None + self.user_semaphores: Dict[int, asyncio.Semaphore] = {} + + # Статистика + self.active_tasks: Dict[str, Task] = {} + self.user_task_counts: Dict[int, int] = {} + self.completed_tasks = 0 + self.failed_tasks = 0 + + # Воркеры + self.workers = [] + self.running = False + + async def start(self): + """Запуск менеджера задач""" + if self.running: + return + + # Создаём asyncio объекты в правильном event loop + self.task_queue = asyncio.PriorityQueue() + self.worker_semaphore = asyncio.Semaphore(self.max_workers) + self.user_semaphores.clear() # Очищаем старые семафоры + + self.running = True + logger.info(f"Запуск {self.max_workers} воркеров для обработки задач") + + # Создаём воркеры + for i in range(self.max_workers): + worker = asyncio.create_task(self._worker(f"worker-{i}")) + self.workers.append(worker) + + async def stop(self): + """Остановка менеджера задач""" + if not self.running: + return + + self.running = False + logger.info("Остановка менеджера задач...") + + # Отменяем всех воркеров + for worker in self.workers: + worker.cancel() + + # Ждём завершения + await asyncio.gather(*self.workers, return_exceptions=True) + self.workers.clear() + + # Очищаем asyncio объекты + self.task_queue = None + self.worker_semaphore = None + self.user_semaphores.clear() + + logger.info("Менеджер задач остановлен") + + async def add_task(self, + task_id: str, + user_id: int, + func: Callable, + *args, + priority: TaskPriority = TaskPriority.NORMAL, + timeout: float = 30.0, + **kwargs) -> str: + """Добавить задачу в очередь""" + + if not self.running or self.task_queue is None: + raise RuntimeError("TaskManager не запущен") + + # Проверяем лимиты пользователя + user_count = self.user_task_counts.get(user_id, 0) + if user_count >= self.max_user_concurrent: + raise ValueError(f"Пользователь {user_id} превысил лимит одновременных задач ({self.max_user_concurrent})") + + # Создаём задачу + task = Task( + id=task_id, + user_id=user_id, + priority=priority, + func=func, + args=args, + kwargs=kwargs, + created_at=time.time(), + timeout=timeout + ) + + # Добавляем в очередь + await self.task_queue.put(task) + + # Обновляем статистику + self.user_task_counts[user_id] = user_count + 1 + + logger.debug(f"Задача {task_id} добавлена в очередь (пользователь: {user_id}, приоритет: {priority.name})") + return task_id + + async def _worker(self, worker_name: str): + """Воркер для выполнения задач""" + logger.debug(f"Воркер {worker_name} запущен") + + while self.running and self.task_queue is not None and self.worker_semaphore is not None: + try: + # Получаем задачу из очереди (с таймаутом) + try: + task = await asyncio.wait_for(self.task_queue.get(), timeout=1.0) + except asyncio.TimeoutError: + continue + + # Получаем семафоры + async with self.worker_semaphore: + user_semaphore = self._get_user_semaphore(task.user_id) + if user_semaphore is not None: + async with user_semaphore: + await self._execute_task(worker_name, task) + else: + await self._execute_task(worker_name, task) + + # Отмечаем задачу как выполненную + self.task_queue.task_done() + + except asyncio.CancelledError: + logger.debug(f"Воркер {worker_name} отменён") + break + except Exception as e: + logger.error(f"Ошибка в воркере {worker_name}: {e}", exc_info=True) + + logger.debug(f"Воркер {worker_name} завершён") + + def _get_user_semaphore(self, user_id: int) -> Optional[asyncio.Semaphore]: + """Получить семафор пользователя""" + if not self.running: + return None + + if user_id not in self.user_semaphores: + self.user_semaphores[user_id] = asyncio.Semaphore(self.max_user_concurrent) + return self.user_semaphores[user_id] + + async def _execute_task(self, worker_name: str, task: Task): + """Выполнить задачу""" + task_start = time.time() + + try: + # Регистрируем активную задачу + self.active_tasks[task.id] = task + + logger.debug(f"Воркер {worker_name} выполняет задачу {task.id}") + + # Выполняем с таймаутом + try: + if asyncio.iscoroutinefunction(task.func): + result = await asyncio.wait_for( + task.func(*task.args, **task.kwargs), + timeout=task.timeout + ) + else: + # Для синхронных функций + result = await asyncio.wait_for( + asyncio.to_thread(task.func, *task.args, **task.kwargs), + timeout=task.timeout + ) + + self.completed_tasks += 1 + execution_time = time.time() - task_start + + logger.debug(f"Задача {task.id} выполнена за {execution_time:.2f}с") + + except asyncio.TimeoutError: + logger.warning(f"Задача {task.id} превысила таймаут {task.timeout}с") + self.failed_tasks += 1 + raise + except Exception as e: + logger.error(f"Ошибка выполнения задачи {task.id}: {e}") + self.failed_tasks += 1 + raise + + finally: + # Убираем из активных и обновляем счётчики + self.active_tasks.pop(task.id, None) + user_count = self.user_task_counts.get(task.user_id, 0) + if user_count > 0: + self.user_task_counts[task.user_id] = user_count - 1 + + def get_stats(self) -> Dict[str, Any]: + """Получить статистику менеджера""" + return { + 'running': self.running, + 'workers_count': len(self.workers), + 'active_tasks': len(self.active_tasks), + 'queue_size': self.task_queue.qsize() if self.task_queue is not None else 0, + 'completed_tasks': self.completed_tasks, + 'failed_tasks': self.failed_tasks, + 'user_tasks': dict(self.user_task_counts) + } + + def get_user_stats(self, user_id: int) -> Dict[str, Any]: + """Получить статистику пользователя""" + active_user_tasks = [ + task for task in self.active_tasks.values() + if task.user_id == user_id + ] + + return { + 'active_tasks': len(active_user_tasks), + 'max_concurrent': self.max_user_concurrent, + 'can_add_task': len(active_user_tasks) < self.max_user_concurrent, + 'task_details': [ + { + 'id': task.id, + 'priority': task.priority.name, + 'created_at': task.created_at, + 'running_time': time.time() - task.created_at + } + for task in active_user_tasks + ] + } + + +# Глобальный экземпляр менеджера задач +task_manager = AsyncTaskManager( + max_workers=15, # Максимум воркеров + max_user_concurrent=5 # Максимум задач на пользователя +) \ No newline at end of file diff --git a/test_admin_improvements.py b/test_admin_improvements.py new file mode 100644 index 0000000..c5380ef --- /dev/null +++ b/test_admin_improvements.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +Тест всех новых функций: пакетного добавления счетов и админских хэндлеров +""" +import asyncio +import sys +import os +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from database import async_session_maker, engine +from services import UserService, LotteryService, ParticipationService +from account_utils import validate_account_number, format_account_number, mask_account_number +from winner_display import format_winner_display +import random + +async def test_comprehensive_features(): + print("🧪 Комплексное тестирование всех функций") + print("="*60) + + async with async_session_maker() as session: + # 1. Создаем тестовых пользователей с номерами счетов + print("\n1. 📝 Создание тестовых пользователей:") + test_users = [] + test_accounts = [] + + for i in range(5): + # Генерируем уникальные данные + unique_id = random.randint(10000000, 99999999) + account = f"{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}" + + user = await UserService.get_or_create_user( + session, + telegram_id=unique_id, + username=f'test_user_{i}_{unique_id}', + first_name=f'Тестовый{i}', + last_name=f'Пользователь{i}' + ) + + # Устанавливаем номер счёта + success = await UserService.set_account_number(session, user.telegram_id, account) + if success: + test_users.append(user) + test_accounts.append(account) + print(f"✅ Пользователь {i+1}: {user.first_name} ({account})") + else: + print(f"❌ Не удалось создать пользователя {i+1}") + + # 2. Создаем тестовый розыгрыш + print(f"\n2. 🎯 Создание тестового розыгрыша:") + lottery = await LotteryService.create_lottery( + session, + title="Тест массовых операций", + description="Розыгрыш для тестирования массовых операций", + prizes=["Приз 1", "Приз 2"], + creator_id=1 + ) + print(f"✅ Розыгрыш создан: {lottery.title}") + + # 3. Тестируем массовое добавление по номерам счетов + print(f"\n3. 🏦 Массовое добавление по номерам счетов:") + accounts_to_add = test_accounts[:3] # Первые 3 + print(f"Добавляем {len(accounts_to_add)} счетов: {', '.join(accounts_to_add)}") + + results = await ParticipationService.add_participants_by_accounts_bulk( + session, lottery.id, accounts_to_add + ) + + print(f"📊 Результат:") + print(f" ✅ Добавлено: {results['added']}") + print(f" ⚠️ Пропущено: {results['skipped']}") + print(f" ❌ Ошибок: {len(results['errors'])}") + + # 4. Проверяем участников + print(f"\n4. 👥 Проверка участников:") + participants = await ParticipationService.get_participants(session, lottery.id) + print(f"Участников в розыгрыше: {len(participants)}") + for participant in participants: + print(f" • {participant.first_name} ({participant.account_number})") + + # 5. Тестируем настройки отображения победителей + print(f"\n5. 🎭 Тестирование настроек отображения:") + for display_type in ["username", "chat_id", "account_number"]: + await LotteryService.set_winner_display_type(session, lottery.id, display_type) + lottery.winner_display_type = display_type # Обновляем локально + + if participants: + test_participant = participants[0] + public_display = format_winner_display(test_participant, lottery, show_sensitive_data=False) + admin_display = format_winner_display(test_participant, lottery, show_sensitive_data=True) + + print(f" 📺 Тип {display_type}:") + print(f" 👥 Публично: {public_display}") + print(f" 🔧 Админ: {admin_display}") + + # 6. Тестируем массовое удаление по счетам + print(f"\n6. 🗑️ Массовое удаление по номерам счетов:") + accounts_to_remove = test_accounts[:2] # Первые 2 + print(f"Удаляем {len(accounts_to_remove)} счетов") + + results = await ParticipationService.remove_participants_by_accounts_bulk( + session, lottery.id, accounts_to_remove + ) + + print(f"📊 Результат:") + print(f" ✅ Удалено: {results['removed']}") + print(f" ⚠️ Не найдено: {results['not_found']}") + print(f" ❌ Ошибок: {len(results['errors'])}") + + # 7. Проверяем оставшихся участников + print(f"\n7. 👥 Проверка оставшихся участников:") + remaining_participants = await ParticipationService.get_participants(session, lottery.id) + print(f"Участников осталось: {len(remaining_participants)}") + for participant in remaining_participants: + print(f" • {participant.first_name} ({participant.account_number})") + + # 8. Тестируем методы управления розыгрышем + print(f"\n8. ⚙️ Тестирование управления розыгрышем:") + + # Деактивируем + success = await LotteryService.set_lottery_active(session, lottery.id, False) + print(f" 🔴 Деактивация: {'✅' if success else '❌'}") + + # Активируем обратно + success = await LotteryService.set_lottery_active(session, lottery.id, True) + print(f" 🟢 Активация: {'✅' if success else '❌'}") + + # Завершаем + success = await LotteryService.complete_lottery(session, lottery.id) + print(f" 🏁 Завершение: {'✅' if success else '❌'}") + + # 9. Тестируем валидацию счетов с неправильными форматами + print(f"\n9. 🔍 Тестирование валидации:") + + invalid_accounts = [ + "invalid-account", + "12345", # Слишком короткий + "12-34-56-78", # Неполный + "ab-cd-ef-gh-12-34-56-78", # С буквами + "12-34-56-78-90-12-34-56-78" # Слишком длинный + ] + + results = await ParticipationService.add_participants_by_accounts_bulk( + session, lottery.id, invalid_accounts + ) + + print(f"📊 Результат добавления невалидных счетов:") + print(f" ✅ Добавлено: {results['added']}") + print(f" 🚫 Неверных форматов: {len(results['invalid_accounts'])}") + print(f" ❌ Ошибок: {len(results['errors'])}") + + if results['invalid_accounts']: + print(f" 🚫 Неверные форматы:") + for invalid in results['invalid_accounts']: + print(f" • {invalid}") + + print(f"\n🎉 Все тесты завершены успешно!") + print(f"✅ Массовое добавление и удаление по счетам работает") + print(f"✅ Настройка отображения победителей работает") + print(f"✅ Управление розыгрышами работает") + print(f"✅ Валидация счетов работает") + +if __name__ == "__main__": + asyncio.run(test_comprehensive_features()) \ No newline at end of file diff --git a/test_basic_features.py b/test_basic_features.py new file mode 100644 index 0000000..3acbe63 --- /dev/null +++ b/test_basic_features.py @@ -0,0 +1,111 @@ +""" +Простой тест основной функциональности без многопоточности +""" +import asyncio +import sys +import os + +# Добавляем путь к проекту +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from database import init_db, async_session_maker +from services import UserService, LotteryService +from account_utils import generate_account_number, validate_account_number, mask_account_number +from winner_display import format_winner_display, validate_display_type + + +async def test_basic_functionality(): + """Простой тест основной функциональности""" + print("🧪 Тестирование основной функциональности") + print("=" * 50) + + # Инициализируем базу данных + await init_db() + + async with async_session_maker() as session: + + # 1. Тест создания пользователя с номером счёта + print("\n1. 👤 Создание пользователя с номером счёта:") + + test_account = "12-34-56-78-90-12-34-56" + user = await UserService.get_or_create_user( + session, + telegram_id=999999999, + username='test_client', + first_name='Тестовый', + last_name='Клиент' + ) + + success = await UserService.set_account_number( + session, user.telegram_id, test_account + ) + + if success: + print(f"✅ Пользователь создан и счёт {test_account} установлен") + else: + print(f"❌ Ошибка установки номера счёта") + + # 2. Тест валидации номеров + print("\n2. 📋 Тестирование валидации номеров:") + + test_cases = [ + ("12-34-56-78-90-12-34-56", True), + ("invalid-number", False), + ("12345678901234567890", False) + ] + + for number, expected in test_cases: + result = validate_account_number(number) + status = "✅" if result == expected else "❌" + print(f"{status} '{number}' -> {result}") + + # 3. Тест маскирования + print("\n3. 🎭 Тестирование маскирования:") + + masked = mask_account_number(test_account, show_last_digits=4) + print(f"Полный номер: {test_account}") + print(f"Маскированный: {masked}") + + # 4. Тест поиска по счёту + print("\n4. 🔍 Тестирование поиска по номеру счёта:") + + found_user = await UserService.get_user_by_account(session, test_account) + if found_user: + print(f"✅ Пользователь найден по номеру счёта: {found_user.first_name}") + else: + print(f"❌ Пользователь не найден") + + # 5. Тест отображения победителей + print("\n5. 🎨 Тестирование отображения победителей:") + + # Создаём тестовый розыгрыш + lottery = await LotteryService.create_lottery( + session, + title="Тест отображения", + description="Тестовый розыгрыш", + prizes=["Приз 1"], + creator_id=1 + ) + + display_types = ['username', 'chat_id', 'account_number'] + + for display_type in display_types: + await LotteryService.set_winner_display_type( + session, lottery.id, display_type + ) + + updated_lottery = await LotteryService.get_lottery(session, lottery.id) + + if updated_lottery: + public = format_winner_display(user, updated_lottery, show_sensitive_data=False) + admin = format_winner_display(user, updated_lottery, show_sensitive_data=True) + + print(f"\n📺 Тип: {display_type}") + print(f" 👥 Публично: {public}") + print(f" 🔧 Админ: {admin}") + + print("\n🎉 Основные тесты завершены успешно!") + + +if __name__ == "__main__": + asyncio.run(test_basic_functionality()) \ No newline at end of file diff --git a/test_clean_features.py b/test_clean_features.py new file mode 100644 index 0000000..c0f8a9f --- /dev/null +++ b/test_clean_features.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Тест функциональности с чистыми данными +""" +import asyncio +import sys +import os +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from database import async_session_maker, engine +from services import UserService, LotteryService +from account_utils import validate_account_number, format_account_number, mask_account_number +from winner_display import format_winner_display +import random + +async def test_features(): + print("🧪 Тестирование с чистыми данными") + print("="*50) + + async with async_session_maker() as session: + # Генерируем уникальные тестовые данные + unique_id = random.randint(1000000, 9999999) + test_account = f"{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}" + + print(f"🆔 Используем уникальный ID: {unique_id}") + print(f"🔢 Используем номер счёта: {test_account}") + + # 1. Создание пользователя + print(f"\n1. 👤 Создание пользователя:") + user = await UserService.get_or_create_user( + session, + telegram_id=unique_id, + username=f'test_user_{unique_id}', + first_name='Тестовый', + last_name='Пользователь' + ) + print(f"✅ Пользователь создан: {user.first_name} {user.last_name}") + + # 2. Установка номера счёта + print(f"\n2. 🔢 Установка номера счёта:") + success = await UserService.set_account_number( + session, user.telegram_id, test_account + ) + + if success: + print(f"✅ Номер счёта {test_account} установлен") + # Обновляем данные пользователя + await session.refresh(user) + print(f"✅ Подтверждено: {user.account_number}") + else: + print(f"❌ Не удалось установить номер счёта") + return + + # 3. Поиск пользователя по номеру + print(f"\n3. 🔍 Поиск по номеру счёта:") + found_user = await UserService.get_user_by_account(session, test_account) + if found_user: + print(f"✅ Пользователь найден: {found_user.first_name} {found_user.last_name}") + else: + print("❌ Пользователь не найден") + + # 4. Тестирование маскирования + print(f"\n4. 🎭 Тестирование маскирования:") + masked = mask_account_number(test_account) + print(f"Полный номер: {test_account}") + print(f"Маскированный: {masked}") + + # 5. Создание розыгрыша с типами отображения + print(f"\n5. 🎨 Тестирование отображения победителей:") + lottery = await LotteryService.create_lottery( + session, + title="Тест отображения", + description="Тестовый розыгрыш", + prizes=["Приз 1"], + creator_id=1 + ) + + # Тестируем все типы отображения + for display_type in ["username", "chat_id", "account_number"]: + await LotteryService.set_winner_display_type(session, lottery.id, display_type) + lottery.winner_display_type = display_type # Обновляем локально + + public_display = format_winner_display(user, lottery, show_sensitive_data=False) + admin_display = format_winner_display(user, lottery, show_sensitive_data=True) + + print(f"\n📺 Тип: {display_type}") + print(f" 👥 Публично: {public_display}") + print(f" 🔧 Админ: {admin_display}") + + print(f"\n🎉 Все тесты завершены успешно!") + +if __name__ == "__main__": + asyncio.run(test_features()) \ No newline at end of file diff --git a/test_display_type.py b/test_display_type.py new file mode 100644 index 0000000..e1ad899 --- /dev/null +++ b/test_display_type.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +""" +Тест для проверки смены типа отображения победителей +""" +import asyncio +from database import async_session_maker +from services import LotteryService +from winner_display import validate_display_type + +async def test_display_type_change(): + """Тестируем смену типа отображения""" + print("🧪 Тестирование смены типа отображения...") + + async with async_session_maker() as session: + # Получаем первый розыгрыш + lottery = await LotteryService.get_lottery(session, 1) + if not lottery: + print("❌ Розыгрыш не найден") + return + + print(f"📋 Розыгрыш: {lottery.title}") + print(f"🎭 Текущий тип: {getattr(lottery, 'winner_display_type', 'username')}") + + # Проверяем валидацию разных типов + test_types = ['username', 'chat_id', 'account_number', 'invalid_type'] + + for test_type in test_types: + print(f"\n🧪 Тестируем тип: {test_type}") + is_valid = validate_display_type(test_type) + print(f" Валидация: {'✅' if is_valid else '❌'}") + + if is_valid: + # Пытаемся изменить тип + success = await LotteryService.set_winner_display_type(session, lottery.id, test_type) + print(f" Сохранение: {'✅' if success else '❌'}") + + if success: + # Проверяем что изменение применилось + updated_lottery = await LotteryService.get_lottery(session, lottery.id) + actual_type = getattr(updated_lottery, 'winner_display_type', 'username') + print(f" Новый тип: {actual_type}") + + if actual_type == test_type: + print(f" Результат: ✅ Тип успешно изменен") + else: + print(f" Результат: ❌ Тип не изменился (ожидали {test_type}, получили {actual_type})") + else: + print(f" Результат: ❌ Ошибка при сохранении") + else: + print(f" Результат: ⚠️ Неверный тип (ожидаемо)") + +if __name__ == "__main__": + asyncio.run(test_display_type_change()) \ No newline at end of file diff --git a/test_new_features.py b/test_new_features.py new file mode 100644 index 0000000..586cdeb --- /dev/null +++ b/test_new_features.py @@ -0,0 +1,277 @@ +""" +Тест новой функциональности: клиентские счета и многопоточность +""" +import asyncio +import sys +import os + +# Добавляем путь к проекту +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from database import init_db, async_session_maker +from services import UserService, LotteryService +from account_utils import generate_account_number, validate_account_number, mask_account_number +from task_manager import task_manager, TaskPriority +from winner_display import format_winner_display, validate_display_type +from async_decorators import get_task_stats, format_task_stats +import time + + +async def test_account_functionality(): + """Тест функций работы со счетами""" + print("🧪 Тестирование функций работы со счетами") + print("=" * 50) + + async with async_session_maker() as session: + # Создаём тестовых пользователей + users_data = [ + { + 'telegram_id': 777000001, + 'username': 'client1', + 'first_name': 'Клиент', + 'last_name': 'Первый', + 'account': '12-34-56-78-90-12-34-56' + }, + { + 'telegram_id': 777000002, + 'username': 'client2', + 'first_name': 'Клиент', + 'last_name': 'Второй', + 'account': '11-22-33-44-55-66-77-88' + }, + { + 'telegram_id': 777000003, + 'username': 'client3', + 'first_name': 'Клиент', + 'last_name': 'Третий', + 'account': None # Без счёта + } + ] + + created_users = [] + + for user_data in users_data: + # Создаём пользователя + user = await UserService.get_or_create_user( + session, + telegram_id=user_data['telegram_id'], + username=user_data['username'], + first_name=user_data['first_name'], + last_name=user_data['last_name'] + ) + + # Устанавливаем номер счёта, если есть + if user_data['account']: + success = await UserService.set_account_number( + session, user.telegram_id, user_data['account'] + ) + if success: + print(f"✅ Создан пользователь {user.first_name} со счётом {user_data['account']}") + else: + print(f"❌ Ошибка установки счёта для {user.first_name}") + else: + print(f"✅ Создан пользователь {user.first_name} без счёта") + + created_users.append(user) + + # Тесты валидации + print("\n📋 Тестирование валидации номеров счетов:") + + test_numbers = [ + ("12-34-56-78-90-12-34-56", True), # Корректный + ("12345678901234567890123456", False), # Без дефисов + ("12-34-56-78-90-12-34", False), # Короткий + ("12-34-56-78-90-12-34-56-78", False), # Длинный + ("ab-cd-ef-gh-ij-kl-mn-op", False), # Буквы + ("", False) # Пустой + ] + + for number, should_be_valid in test_numbers: + is_valid = validate_account_number(number) + status = "✅" if is_valid == should_be_valid else "❌" + print(f"{status} {number} -> {'Корректный' if is_valid else 'Некорректный'}") + + # Тест маскирования + print("\n🎭 Тестирование маскирования номеров:") + test_account = "12-34-56-78-90-12-34-56" + for digits in [4, 6, 8]: + masked = mask_account_number(test_account, show_last_digits=digits) + print(f"Показать последние {digits} цифр: {masked}") + + # Тест поиска по счёту + print("\n🔍 Тестирование поиска по номеру счёта:") + + async with async_session_maker() as session: + # Полный номер + user = await UserService.get_user_by_account(session, "12-34-56-78-90-12-34-56") + if user: + print(f"✅ Найден пользователь по полному номеру: {user.first_name}") + + # Поиск по части номера + users = await UserService.search_by_account(session, "12-34") + print(f"🔍 Найдено пользователей по шаблону '12-34': {len(users)}") + + for user in users: + if user.account_number: + masked = mask_account_number(user.account_number, show_last_digits=6) + print(f" • {user.first_name}: {masked}") + + +async def test_display_types(): + """Тест различных типов отображения победителей""" + print("\n🎨 Тестирование типов отображения победителей") + print("=" * 50) + + async with async_session_maker() as session: + # Создаём розыгрыш + lottery = await LotteryService.create_lottery( + session, + title="Тест отображения победителей", + description="Розыгрыш для тестирования различных типов отображения", + prizes=["Первый приз", "Второй приз"], + creator_id=1 + ) + + # Получаем пользователя со счётом + user = await UserService.get_user_by_account(session, "12-34-56-78-90-12-34-56") + + if user and lottery: + print(f"👤 Тестируем отображение для пользователя: {user.first_name}") + print(f"💳 Номер счёта: {user.account_number}") + + # Тестируем разные типы отображения + display_types = ['username', 'chat_id', 'account_number'] + + for display_type in display_types: + # Устанавливаем тип отображения + success = await LotteryService.set_winner_display_type( + session, lottery.id, display_type + ) + + if success: + # Получаем обновлённый розыгрыш + updated_lottery = await LotteryService.get_lottery(session, lottery.id) + + # Тестируем отображение + public_display = format_winner_display(user, updated_lottery, show_sensitive_data=False) + admin_display = format_winner_display(user, updated_lottery, show_sensitive_data=True) + + print(f"\n📺 Тип отображения: {display_type}") + print(f" 👥 Публичное: {public_display}") + print(f" 🔧 Админское: {admin_display}") + else: + print(f"❌ Ошибка установки типа отображения {display_type}") + else: + print("❌ Не удалось создать тестовые данные") + + +async def test_multithreading(): + """Тест многопоточности""" + print("\n⚡ Тестирование системы многопоточности") + print("=" * 50) + + # Запускаем менеджер задач + await task_manager.start() + + # Функция-заглушка для тестирования + async def test_task(task_name: str, duration: float, user_id_arg: int): + print(f"🔄 Запуск задачи {task_name} для пользователя {user_id_arg}") + await asyncio.sleep(duration) + print(f"✅ Завершена задача {task_name} для пользователя {user_id_arg}") + return f"Результат {task_name}" + + # Добавляем задачи разных приоритетов + tasks = [ + ("critical_task", 2.0, 100, TaskPriority.CRITICAL), + ("high_task_1", 1.5, 200, TaskPriority.HIGH), + ("normal_task_1", 1.0, 300, TaskPriority.NORMAL), + ("normal_task_2", 1.2, 300, TaskPriority.NORMAL), + ("high_task_2", 0.8, 200, TaskPriority.HIGH), + ("low_task", 0.5, 400, TaskPriority.LOW) + ] + + print(f"📤 Добавляем {len(tasks)} задач...") + + for task_name, duration, user_id, priority in tasks: + try: + task_id = await task_manager.add_task( + task_id=f"{task_name}_{int(time.time())}", + user_id=user_id, + func=test_task, + priority=priority, + timeout=10.0, + task_name=task_name, + duration=duration, + user_id_arg=user_id + ) + print(f" ➕ Добавлена задача {task_name} (приоритет: {priority.name})") + except ValueError as e: + print(f" ❌ Ошибка добавления задачи {task_name}: {e}") + + # Показываем статистику + print("\n📊 Начальная статистика:") + stats_text = await format_task_stats() + print(stats_text) + + # Ждём выполнения задач + print("\n⏳ Ожидание выполнения задач...") + await asyncio.sleep(5.0) + + # Показываем финальную статистику + print("\n📈 Финальная статистика:") + final_stats = await format_task_stats() + print(final_stats) + + # Тестируем лимиты пользователя + print("\n🚧 Тестирование лимитов пользователя:") + + # Пробуем добавить много задач одному пользователю + user_id = 999 + added_tasks = 0 + + for i in range(8): # Больше чем лимит (5) + try: + await task_manager.add_task( + task_id=f"limit_test_{i}_{int(time.time())}", + user_id=user_id, + func=test_task, + priority=TaskPriority.NORMAL, + task_name=f"limit_test_{i}", + duration=0.1, + user_id_arg=user_id + ) + added_tasks += 1 + except ValueError as e: + print(f" 🛑 Лимит достигнут после {added_tasks} задач: {e}") + break + + # Ждём немного и останавливаем менеджер + await asyncio.sleep(2.0) + await task_manager.stop() + + +async def main(): + """Главная функция теста""" + print("🚀 Запуск тестирования новой функциональности") + print("=" * 60) + + # Инициализируем базу данных + await init_db() + + try: + # Запускаем тесты + await test_account_functionality() + await test_display_types() + await test_multithreading() + + print("\n🎉 Все тесты завершены успешно!") + print("=" * 60) + + except Exception as e: + print(f"\n❌ Ошибка во время тестирования: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..f6763ac --- /dev/null +++ b/utils.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Утилиты для управления ботом +""" +import asyncio +import sys +from sqlalchemy.ext.asyncio import AsyncSession +from database import async_session_maker, init_db +from services import UserService +from config import ADMIN_IDS + + +async def setup_admin_users(): + """Установить права администратора для пользователей из ADMIN_IDS""" + if not ADMIN_IDS: + print("❌ Список ADMIN_IDS пуст") + return + + async with async_session_maker() as session: + for admin_id in ADMIN_IDS: + success = await UserService.set_admin(session, admin_id, True) + if success: + print(f"✅ Права администратора установлены для ID: {admin_id}") + else: + print(f"⚠️ Пользователь с ID {admin_id} не найден в базе") + + +async def create_sample_lottery(): + """Создать пример розыгрыша для тестирования""" + from services import LotteryService + + async with async_session_maker() as session: + # Берем первого администратора как создателя + if not ADMIN_IDS: + print("❌ Нет администраторов для создания розыгрыша") + return + + admin_user = await UserService.get_user_by_telegram_id(session, ADMIN_IDS[0]) + if not admin_user: + print("❌ Пользователь-администратор не найден в базе") + return + + lottery = await LotteryService.create_lottery( + session, + title="🎉 Тестовый розыгрыш", + description="Это тестовый розыгрыш для демонстрации работы бота", + prizes=[ + "🥇 Главный приз - 10,000 рублей", + "🥈 Второй приз - iPhone 15", + "🥉 Третий приз - AirPods Pro" + ], + creator_id=admin_user.id + ) + + print(f"✅ Создан тестовый розыгрыш с ID: {lottery.id}") + print(f"📝 Название: {lottery.title}") + + +async def init_database(): + """Инициализация базы данных""" + print("🔄 Инициализация базы данных...") + await init_db() + print("✅ База данных инициализирована") + + +async def show_stats(): + """Показать статистику бота""" + from services import LotteryService, ParticipationService + from models import User, Lottery, Participation + from sqlalchemy import select, func + + async with async_session_maker() as session: + # Количество пользователей + result = await session.execute(select(func.count(User.id))) + users_count = result.scalar() + + # Количество розыгрышей + result = await session.execute(select(func.count(Lottery.id))) + lotteries_count = result.scalar() + + # Количество активных розыгрышей + result = await session.execute( + select(func.count(Lottery.id)) + .where(Lottery.is_active == True, Lottery.is_completed == False) + ) + active_lotteries = result.scalar() + + # Количество участий + result = await session.execute(select(func.count(Participation.id))) + participations_count = result.scalar() + + print("\n📊 Статистика бота:") + print(f"👥 Всего пользователей: {users_count}") + print(f"🎲 Всего розыгрышей: {lotteries_count}") + print(f"🟢 Активных розыгрышей: {active_lotteries}") + print(f"🎫 Всего участий: {participations_count}") + + +def main(): + """Главная функция утилиты""" + if len(sys.argv) < 2: + print("Использование:") + print(" python utils.py init - Инициализация базы данных") + print(" python utils.py setup-admins - Установка прав администратора") + print(" python utils.py sample - Создание тестового розыгрыша") + print(" python utils.py stats - Показать статистику") + return + + command = sys.argv[1] + + if command == "init": + asyncio.run(init_database()) + elif command == "setup-admins": + asyncio.run(setup_admin_users()) + elif command == "sample": + asyncio.run(create_sample_lottery()) + elif command == "stats": + asyncio.run(show_stats()) + else: + print(f"❌ Неизвестная команда: {command}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/winner_display.py b/winner_display.py new file mode 100644 index 0000000..289f55b --- /dev/null +++ b/winner_display.py @@ -0,0 +1,116 @@ +""" +Утилиты для отображения информации о победителях в зависимости от настроек розыгрыша +""" +from typing import Dict, Any, Optional +from models import User, Lottery +from account_utils import mask_account_number + + +def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: bool = False) -> str: + """ + Форматирует отображение победителя в зависимости от настроек розыгрыша + + Args: + user: Пользователь-победитель + lottery: Розыгрыш + show_sensitive_data: Показывать ли чувствительные данные (для админов) + + Returns: + str: Отформатированная строка для отображения победителя + """ + display_type = getattr(lottery, 'winner_display_type', 'username') + + if display_type == 'username': + # Отображаем username или имя + if user.username: + return f"@{user.username}" + else: + return user.first_name or f"Пользователь {user.id}" + + elif display_type == 'chat_id': + # Отображаем Telegram ID + return f"ID: {user.telegram_id}" + + elif display_type == 'account_number': + # Отображаем номер клиентского счета + if not user.account_number: + return "Счёт не указан" + + if show_sensitive_data: + # Для админов показываем полный номер + return f"Счёт: {user.account_number}" + else: + # Для публичного показа маскируем номер + masked = mask_account_number(user.account_number, show_last_digits=4) + return f"Счёт: {masked}" + + else: + # Fallback к username/имени + if user.username: + return f"@{user.username}" + else: + return user.first_name or f"Пользователь {user.id}" + + +def format_winner_info(winner_data: Dict[str, Any], show_sensitive_data: bool = False) -> str: + """ + Форматирует информацию о победителе из данных розыгрыша + + Args: + winner_data: Словарь с данными о победителе + show_sensitive_data: Показывать ли чувствительные данные + + Returns: + str: Отформатированная строка для отображения + """ + user = winner_data.get('user') + place = winner_data.get('place', 1) + prize = winner_data.get('prize', f'Приз {place} места') + + if not user: + return f"{place}. Победитель не определен" + + # Пробуем получить lottery из winner_data, если есть + lottery = winner_data.get('lottery') + + if lottery: + winner_display = format_winner_display(user, lottery, show_sensitive_data) + else: + # Fallback если нет данных о розыгрыше + if user.username: + winner_display = f"@{user.username}" + else: + winner_display = user.first_name or f"Пользователь {user.id}" + + return f"{place}. {winner_display}" + + +def get_display_type_name(display_type: str) -> str: + """ + Получить человекочитаемое название типа отображения + + Args: + display_type: Тип отображения + + Returns: + str: Название типа + """ + types = { + 'username': 'Username/Имя', + 'chat_id': 'Telegram ID', + 'account_number': 'Номер счёта' + } + return types.get(display_type, 'Неизвестно') + + +def validate_display_type(display_type: str) -> bool: + """ + Проверяет корректность типа отображения + + Args: + display_type: Тип отображения для проверки + + Returns: + bool: True если тип корректен + """ + return display_type in ['username', 'chat_id', 'account_number'] \ No newline at end of file