init commit

This commit is contained in:
2025-11-12 20:57:36 +09:00
commit e0075d91b6
40 changed files with 8544 additions and 0 deletions

12
.env.example Normal file
View File

@@ -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

60
.gitignore vendored Normal file
View File

@@ -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

177
ADMIN_CHANGELOG.md Normal file
View File

@@ -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
**Статус**: Протестировано и готово к использованию ✅

502
ADMIN_GUIDE.md Normal file
View File

@@ -0,0 +1,502 @@
# <20> Полное руководство по админ-панели
## 🎯 Обзор
Админ-панель предоставляет полный контроль над ботом через удобный интерфейс в 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 (Чехол) 🎲
```
👑 = Ручной победитель | 🎲 = Случайный
### 📊 Просмотр победителей
- **По розыгрышам** - все победители конкретного розыгрыша
- **История побед** - все победы пользователя
- **Типы побед**: Ручные (👑) и Случайные (🎲)
- **Статистика** по каждому пользователю
---
## 📊 Статистика и отчеты
### <20> Общая статистика
```
👥 Общее количество пользователей: 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
```
Демо создаст:
- Тестовых пользователей
- Несколько розыгрышей
- Установит ручных победителей
- Проведет розыгрыши
- Покажет статистику и отчеты
## 🎉 Готово!
Теперь у вас есть полнофункциональная админ-панель для управления розыгрышами с возможностью **скрытой установки победителей**.
**Никто из участников не узнает о подстройке!** 🎭✨

83
ASYNCIO_FIX_REPORT.md Normal file
View File

@@ -0,0 +1,83 @@
# Отчет об исправлении критической ошибки AsyncIO Event Loop
## Проблема
При запуске бота в продакшене обнаружилась критическая ошибка:
```
RuntimeError: Task <Task pending> got Future <Future pending> 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}
```
Бот успешно запускается и работает стабильно в продакшене.

190
BUILD.md Normal file
View File

@@ -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
```
## ✅ Готово!
Ваш бот для розыгрышей готов к работе!
**Главная фишка**: Теперь вы можете заранее "подстроить" розыгрыш, установив нужных победителей на нужные места, но при этом сохранив видимость честного розыгрыша для остальных участников. 🎯

137
IMPLEMENTATION_REPORT.md Normal file
View File

@@ -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. ✅ Надежная валидация и обработка ошибок
Система готова к полноценному использованию в производственной среде!

98
Makefile Normal file
View File

@@ -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

147
PROJECT_SUMMARY.md Normal file
View File

@@ -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` для тестов
---
**Проект готов к использованию!** 🎉

87
QUICKSTART.md Normal file
View File

@@ -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!

252
README.md Normal file
View File

@@ -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

97
account_utils.py Normal file
View File

@@ -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)])

2304
admin_panel.py Normal file

File diff suppressed because it is too large Load Diff

423
admin_utils.py Normal file
View File

@@ -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

111
alembic.ini Normal file
View File

@@ -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

161
async_decorators.py Normal file
View File

@@ -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("Менеджер задач остановлен")

102
conduct_draw.py Normal file
View File

@@ -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())

29
config.py Normal file
View File

@@ -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 # Максимальное количество активных розыгрышей

41
database.py Normal file
View File

@@ -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()

197
db_setup.py Normal file
View File

@@ -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())

191
demo_admin.py Normal file
View File

@@ -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())

164
examples.py Normal file
View File

@@ -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()

732
main.py Normal file
View File

@@ -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("Завершение работы")

94
migrations/env.py Normal file
View File

@@ -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()

24
migrations/script.py.mako Normal file
View File

@@ -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"}

View File

@@ -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 ###

View File

@@ -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')

92
models.py Normal file
View File

@@ -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"<User(telegram_id={self.telegram_id}, username={self.username})>"
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"<Lottery(id={self.id}, title={self.title})>"
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"<Participation(user_id={self.user_id}, lottery_id={self.lottery_id})>"
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"<Winner(lottery_id={self.lottery_id}, user_id={self.user_id}, place={self.place})>"

6
requirements.txt Normal file
View File

@@ -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

630
services.py Normal file
View File

@@ -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
}

32
simple_draw.py Normal file
View File

@@ -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())

37
start.sh Executable file
View File

@@ -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

268
task_manager.py Normal file
View File

@@ -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 # Максимум задач на пользователя
)

163
test_admin_improvements.py Normal file
View File

@@ -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())

111
test_basic_features.py Normal file
View File

@@ -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())

93
test_clean_features.py Normal file
View File

@@ -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())

53
test_display_type.py Normal file
View File

@@ -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())

277
test_new_features.py Normal file
View File

@@ -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())

124
utils.py Normal file
View File

@@ -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()

116
winner_display.py Normal file
View File

@@ -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']