From 3a25e6a4cba12ab3d104a22b2554dda44f823499 Mon Sep 17 00:00:00 2001 From: "Andrew K. Choi" Date: Sat, 15 Nov 2025 20:03:49 +0900 Subject: [PATCH] main functions fix --- ACCOUNT_SYSTEM_GUIDE.md | 332 ++++++++++++++++ COMMAND_FIX.md | 126 ++++++ Makefile | 22 +- POSTGRESQL_MIGRATION.md | 160 ++++++++ README.md | 12 +- UPGRADE_NOTES.md | 100 +++++ account_handlers.py | 375 ++++++++++++++++++ account_services.py | 287 ++++++++++++++ account_utils.py | 82 +++- accounts_100.txt | 100 +++++ admin_panel.py | 127 +++++- alembic.ini | 3 +- database.py | 2 +- main.py | 10 +- ...f_add_account_number_to_participations_.py | 46 +++ models.py | 28 +- requirements.txt | 14 +- services.py | 28 +- 18 files changed, 1779 insertions(+), 75 deletions(-) create mode 100644 ACCOUNT_SYSTEM_GUIDE.md create mode 100644 COMMAND_FIX.md create mode 100644 POSTGRESQL_MIGRATION.md create mode 100644 UPGRADE_NOTES.md create mode 100644 account_handlers.py create mode 100644 account_services.py create mode 100644 accounts_100.txt create mode 100644 migrations/versions/20251115_1911_53_0e35616a69df_add_account_number_to_participations_.py diff --git a/ACCOUNT_SYSTEM_GUIDE.md b/ACCOUNT_SYSTEM_GUIDE.md new file mode 100644 index 0000000..84029d5 --- /dev/null +++ b/ACCOUNT_SYSTEM_GUIDE.md @@ -0,0 +1,332 @@ +# Руководство по работе со счетами в розыгрышах + +## Обзор + +Теперь бот поддерживает два режима участия в розыгрышах: +1. **Старый режим**: участие пользователей по Telegram ID +2. **Новый режим**: участие по счетам формата `XX-XX-XX-XX-XX-XX-XX-XX` + +## Формат счета + +Счет состоит из **8 пар двухзначных чисел**, разделенных дефисом: +``` +12-34-56-78-90-12-34-56 +``` + +## Возможности для администраторов + +### 1. Автоматическое обнаружение счетов + +Когда администратор вводит в чат сообщение со счетами, бот автоматически обнаруживает их и предлагает действия: + +**Пример:** +``` +12-34-56-78-90-12-34-56 +45-67-89-01-23-45-67-89 +``` + +Бот выведет: +``` +🔍 Обнаружен ввод счетов + +Найдено: 2 + +• 12-34-56-78-90-12-34-56 +• 45-67-89-01-23-45-67-89 + +Выберите действие: +[➕ Добавить в розыгрыш] +[👑 Сделать победителем] +[❌ Отмена] +``` + +### 2. Добавление счетов в розыгрыш + +**Шаги:** +1. Введите счета (один или несколько с новой строки) +2. Нажмите "➕ Добавить в розыгрыш" +3. Выберите розыгрыш из списка +4. Бот добавит все счета и покажет результат + +**Результат:** +``` +Результаты добавления в розыгрыш: +Новогодний розыгрыш 2025 + +✅ Добавлено: 15 +⚠️ Пропущено: 3 + +Детали: +✅ 12-34-56-78-90-12-34-56 +✅ 45-67-89-01-23-45-67-89 +... +``` + +### 3. Установка победителя по счету + +**Шаги:** +1. Введите **один** счет +2. Нажмите "👑 Сделать победителем" +3. Выберите розыгрыш +4. Выберите место (1, 2, 3...) +5. Бот установит победителя + +**Пример:** +``` +Ввод: 12-34-56-78-90-12-34-56 + +Результат: +✅ Победитель установлен! + +Розыгрыш: Новогодний розыгрыш 2025 +Счет: 12-34-56-78-90-12-34-56 +Место: 1 +Приз: iPhone 15 Pro +``` + +### 4. Множественный ввод счетов + +Можно вводить несколько счетов одновременно: + +``` +12-34-56-78-90-12-34-56 +45-67-89-01-23-45-67-89 +11-22-33-44-55-66-77-88 +99-88-77-66-55-44-33-22 +``` + +Бот обнаружит все валидные счета и предложит массовые операции. + +## Программный доступ + +### Использование сервисов + +```python +from account_services import AccountParticipationService + +# Добавить счет в розыгрыш +async with async_session_maker() as session: + result = await AccountParticipationService.add_account_to_lottery( + session, + lottery_id=1, + account_number="12-34-56-78-90-12-34-56" + ) + print(result["success"]) # True/False + print(result["message"]) # Сообщение о результате + +# Массовое добавление +accounts = [ + "12-34-56-78-90-12-34-56", + "45-67-89-01-23-45-67-89", + "11-22-33-44-55-66-77-88" +] + +results = await AccountParticipationService.add_accounts_bulk( + session, + lottery_id=1, + accounts=accounts +) + +print(f"Добавлено: {results['added']}") +print(f"Пропущено: {results['skipped']}") +print(f"Ошибки: {len(results['errors'])}") + +# Получить все счета розыгрыша +accounts = await AccountParticipationService.get_lottery_accounts( + session, + lottery_id=1, + limit=100 +) + +# Установить победителя +result = await AccountParticipationService.set_account_as_winner( + session, + lottery_id=1, + account_number="12-34-56-78-90-12-34-56", + place=1, + prize="iPhone 15 Pro" +) +``` + +### Валидация счетов + +```python +from account_utils import ( + validate_account_number, + format_account_number, + parse_accounts_from_message +) + +# Валидация +is_valid = validate_account_number("12-34-56-78-90-12-34-56") # True +is_valid = validate_account_number("12-34-56") # False + +# Форматирование (очистка от лишних символов) +formatted = format_account_number("12 34 56 78 90 12 34 56") +# Результат: "12-34-56-78-90-12-34-56" + +# Парсинг из текста +text = """ +Вот счета для розыгрыша: +12-34-56-78-90-12-34-56 +45-67-89-01-23-45-67-89 +И ещё один: 11-22-33-44-55-66-77-88 +""" +accounts = parse_accounts_from_message(text) +# Результат: ['12-34-56-78-90-12-34-56', '45-67-89-01-23-45-67-89', '11-22-33-44-55-66-77-88'] +``` + +## Генерация тестовых счетов + +```python +from account_utils import generate_account_number + +# Генерация одного счета +account = generate_account_number() +print(account) # Например: "42-17-93-65-28-74-19-36" + +# Генерация множества уникальных счетов +import random + +def generate_unique_accounts(count=100): + accounts = set() + while len(accounts) < count: + accounts.add(generate_account_number()) + return sorted(accounts) + +accounts = generate_unique_accounts(100) +``` + +## Структура базы данных + +### Таблица `participations` +```sql +CREATE TABLE participations ( + id INTEGER PRIMARY KEY, + user_id INTEGER, -- Опционально (старый режим) + lottery_id INTEGER NOT NULL, -- ID розыгрыша + account_number VARCHAR(23), -- Счет (новый режим) + created_at DATETIME, + FOREIGN KEY(user_id) REFERENCES users (id), + FOREIGN KEY(lottery_id) REFERENCES lotteries (id) +) +``` + +### Таблица `winners` +```sql +CREATE TABLE winners ( + id INTEGER PRIMARY KEY, + lottery_id INTEGER NOT NULL, + user_id INTEGER, -- Опционально (старый режим) + account_number VARCHAR(23), -- Счет победителя (новый режим) + place INTEGER NOT NULL, -- Место (1, 2, 3...) + prize VARCHAR(500), -- Описание приза + is_manual BOOLEAN, -- Установлен вручную? + created_at DATETIME, + FOREIGN KEY(lottery_id) REFERENCES lotteries (id), + FOREIGN KEY(user_id) REFERENCES users (id) +) +``` + +## Совместимость + +- ✅ Старый режим (участие пользователей) продолжает работать +- ✅ Новый режим (участие счетов) работает параллельно +- ✅ В одном розыгрыше могут быть как пользователи, так и счета +- ✅ Поддержка обоих режимов для победителей + +## Примеры использования + +### Пример 1: Добавление счетов через чат + +**Администратор пишет:** +``` +12-34-56-78-90-12-34-56 +45-67-89-01-23-45-67-89 +11-22-33-44-55-66-77-88 +``` + +**Бот отвечает:** +``` +🔍 Обнаружен ввод счетов +Найдено: 3 +... +``` + +**Администратор выбирает:** +1. "Добавить в розыгрыш" +2. Выбирает "Новогодний розыгрыш 2025" + +**Бот добавляет все 3 счета** + +### Пример 2: Установка победителя + +**Администратор пишет:** +``` +12-34-56-78-90-12-34-56 +``` + +**Бот отвечает:** +``` +🔍 Обнаружен ввод счета +Найдено: 1 +``` + +**Администратор выбирает:** +1. "Сделать победителем" +2. Выбирает розыгрыш +3. Выбирает "Место 1: iPhone 15 Pro" + +**Бот устанавливает победителя** + +## API endpoints (в коде) + +| Сервис | Метод | Описание | +|--------|-------|----------| +| `AccountParticipationService` | `add_account_to_lottery` | Добавить счет | +| | `add_accounts_bulk` | Массовое добавление | +| | `remove_account_from_lottery` | Удалить счет | +| | `get_lottery_accounts` | Получить все счета | +| | `get_accounts_count` | Количество счетов | +| | `set_account_as_winner` | Установить победителя | +| | `get_lottery_winners_accounts` | Получить победителей | + +## Технические детали + +- **Валидация**: Счет должен содержать ровно 8 пар двухзначных чисел +- **Форматирование**: Автоматическая очистка от лишних пробелов и символов +- **Индексация**: Поле `account_number` индексировано для быстрого поиска +- **Уникальность**: Один счет не может участвовать в одном розыгрыше дважды +- **Поддержка NULL**: `user_id` и `account_number` могут быть NULL + +## Миграция данных + +При обновлении проекта: + +1. Создайте резервную копию базы данных +2. Запустите миграции или пересоздайте БД: +```bash +make migrate +# или +rm -f lottery_bot.db && python -c "import asyncio; from database import init_db; asyncio.run(init_db())" +``` + +## Troubleshooting + +### Бот не обнаруживает счета + +**Проблема:** Введен неверный формат + +**Решение:** Убедитесь, что счет в формате `XX-XX-XX-XX-XX-XX-XX-XX` (8 пар) + +### Счет не добавляется + +**Проблема:** Счет уже участвует в розыгрыше + +**Решение:** Проверьте список участников розыгрыша + +### Ошибка при установке победителя + +**Проблема:** Счет не участвует в розыгрыше + +**Решение:** Сначала добавьте счет в розыгрыш, затем установите победителем diff --git a/COMMAND_FIX.md b/COMMAND_FIX.md new file mode 100644 index 0000000..a5f5067 --- /dev/null +++ b/COMMAND_FIX.md @@ -0,0 +1,126 @@ +# Исправления системы обнаружения счетов + +## Проблема +Бот не реагировал на команды `/start` и `/admin`, потому что обработчик `detect_account_input` перехватывал ВСЕ текстовые сообщения, включая команды. + +## Решение + +### 1. Добавлена проверка команд в `account_handlers.py` + +```python +@account_router.message(F.text, StateFilter(None)) +async def detect_account_input(message: Message, state: FSMContext): + """ + Обнаружение ввода счетов в сообщении + Активируется только для администраторов + """ + if not is_admin(message.from_user.id): + return + + # Игнорируем команды (начинаются с /) + if message.text.startswith('/'): + return # ✅ НОВАЯ ПРОВЕРКА + + # Парсим счета из сообщения + accounts = parse_accounts_from_message(message.text) + + if not accounts: + return # Счета не обнаружены, пропускаем +``` + +### 2. Добавлен параметр `limit` в `LotteryService.get_all_lotteries()` + +```python +@staticmethod +async def get_all_lotteries(session: AsyncSession, limit: Optional[int] = None) -> List[Lottery]: + """Получить список всех розыгрышей""" + query = select(Lottery).order_by(Lottery.created_at.desc()) + + if limit: + query = query.limit(limit) # ✅ ПОДДЕРЖКА ЛИМИТА + + result = await session.execute(query) + return result.scalars().all() +``` + +## Логика обработки сообщений + +Теперь обработчик работает так: + +``` +Входящее сообщение + ↓ +Проверка: админ? + ↓ НЕТ → игнорировать + ↓ ДА +Проверка: команда (начинается с /)? + ↓ ДА → пропустить дальше (к обработчикам команд) + ↓ НЕТ +Парсинг счетов из текста + ↓ +Счета найдены? + ↓ НЕТ → игнорировать + ↓ ДА +Показать inline-меню с действиями +``` + +## Что теперь работает + +✅ **Команды обрабатываются корректно:** +- `/start` → приветствие +- `/admin` → админ-панель +- `/help` → справка + +✅ **Обнаружение счетов работает только для текста без `/`:** +- `11-22-33-44-55-66-77-88` → обнаружен счет +- `Вот счета: 11-22-33-44-55-66-77-88` → обнаружен счет +- `/start` → команда, не счет + +✅ **Проверка прав:** +- Только администраторы видят меню действий со счетами +- Обычные пользователи получают обычные ответы + +## Порядок обработки обновлений + +```python +# В main.py +dp.include_router(account_router) # 1. Приоритет - счета (только если не команда) +dp.include_router(router) # 2. Команды пользователя (/start, /join, etc) +dp.include_router(admin_router) # 3. Админ-команды (/admin, etc) +``` + +Это гарантирует, что: +1. Счета обнаруживаются первыми (но пропускают команды) +2. Команды обрабатываются вторыми +3. Админ-команды обрабатываются последними + +## Тестирование + +Попробуйте отправить боту: + +### Команды (должны работать): +``` +/start +/admin +/help +``` + +### Счета (должны обнаружиться): +``` +11-22-33-44-55-66-77-88 +``` + +### Текст со счетами: +``` +Вот мои счета для розыгрыша: +11-22-33-44-55-66-77-88 +22-33-44-55-66-77-88-99 +``` + +### Обычный текст (должен игнорироваться): +``` +Привет, как дела? +Когда будет розыгрыш? +``` + +Все должно работать корректно! diff --git a/Makefile b/Makefile index dd52ff4..3b2e2d1 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ help: install: @echo "📦 Установка зависимостей..." python3 -m venv .venv - source .venv/bin/activate && pip install -r requirements.txt + . .venv/bin/activate && pip install -r requirements.txt # Первоначальная настройка setup: install @@ -38,49 +38,49 @@ setup: install echo "❌ Файл .env не найден! Скопируйте .env.example в .env"; \ exit 1; \ fi - python utils.py init - python utils.py setup-admins + . .venv/bin/activate && python utils.py init + . .venv/bin/activate && python utils.py setup-admins @echo "✅ Настройка завершена!" # Запуск бота run: @echo "🚀 Запуск бота..." - python main.py + . .venv/bin/activate && python main.py # Создание миграции migration: @echo "📄 Создание новой миграции..." - alembic revision --autogenerate -m "$(MSG)" + . .venv/bin/activate && alembic revision --autogenerate -m "$(MSG)" # Применение миграций migrate: @echo "⬆️ Применение миграций..." - alembic upgrade head + . .venv/bin/activate && alembic upgrade head # Тесты и примеры test: @echo "🧪 Запуск тестов..." - python examples.py + . .venv/bin/activate && python examples.py # Создание тестового розыгрыша sample: @echo "🎲 Создание тестового розыгрыша..." - python utils.py sample + . .venv/bin/activate && python utils.py sample # Статистика stats: @echo "📊 Статистика бота..." - python utils.py stats + . .venv/bin/activate && python utils.py stats # Демонстрация админ-панели demo-admin: @echo "🎪 Демонстрация возможностей админ-панели..." - python demo_admin.py + . .venv/bin/activate && python demo_admin.py # Тестирование улучшений админки test-admin: @echo "🧪 Тестирование новых функций админ-панели..." - python test_admin_improvements.py + . .venv/bin/activate && python test_admin_improvements.py # Очистка clean: diff --git a/POSTGRESQL_MIGRATION.md b/POSTGRESQL_MIGRATION.md new file mode 100644 index 0000000..b889a0b --- /dev/null +++ b/POSTGRESQL_MIGRATION.md @@ -0,0 +1,160 @@ +# Миграция на PostgreSQL + +## Выполненные изменения + +### 1. Настройка подключения + +В файле `.env` раскомментирована строка: +```env +DATABASE_URL=postgresql+asyncpg://trevor:R0sebud@192.168.0.102/bot_db +``` + +### 2. Создание базы данных + +```bash +psql -h 192.168.0.102 -U trevor -d postgres -c "CREATE DATABASE bot_db;" +``` + +### 3. Инициализация таблиц + +Вместо Alembic-миграций (из-за проблем с длинными именами версий) используется прямое создание: +```bash +. .venv/bin/activate && python -c "import asyncio; from database import init_db; asyncio.run(init_db())" +``` + +### 4. Исправление бага в services.py + +Добавлен параметр `limit` в метод `LotteryService.get_active_lotteries()`: + +```python +@staticmethod +async def get_active_lotteries(session: AsyncSession, limit: Optional[int] = None) -> List[Lottery]: + """Получить список активных розыгрышей""" + query = select(Lottery).where( + Lottery.is_active == True, + Lottery.is_completed == False + ).order_by(Lottery.created_at.desc()) + + if limit: + query = query.limit(limit) + + result = await session.execute(query) + return result.scalars().all() +``` + +## Различия SQLite vs PostgreSQL + +| Параметр | SQLite | PostgreSQL | +|----------|--------|------------| +| Тип ID | `INTEGER` | `SERIAL` | +| Datetime | `DATETIME` | `TIMESTAMP WITHOUT TIME ZONE` | +| JSON | `JSON` (текст) | `JSON` (нативный тип) | +| Транзакции | Автоматические | Явные BEGIN/COMMIT | + +## Проверка подключения + +```bash +psql -h 192.168.0.102 -U trevor -d bot_db -c "\dt" +``` + +## Структура таблиц в PostgreSQL + +```sql +-- Пользователи +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + telegram_id INTEGER UNIQUE NOT NULL, + username VARCHAR(255), + first_name VARCHAR(255), + last_name VARCHAR(255), + created_at TIMESTAMP, + is_admin BOOLEAN, + account_number VARCHAR(23) UNIQUE +); + +-- Розыгрыши +CREATE TABLE lotteries ( + id SERIAL PRIMARY KEY, + title VARCHAR(500) NOT NULL, + description TEXT, + created_at TIMESTAMP, + start_date TIMESTAMP, + end_date TIMESTAMP, + is_active BOOLEAN, + is_completed BOOLEAN, + prizes JSON, + creator_id INTEGER REFERENCES users(id), + manual_winners JSON, + draw_results JSON, + winner_display_type VARCHAR(20) +); + +-- Участия +CREATE TABLE participations ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + lottery_id INTEGER NOT NULL REFERENCES lotteries(id), + account_number VARCHAR(23), + created_at TIMESTAMP +); +CREATE INDEX ix_participations_account_number ON participations(account_number); + +-- Победители +CREATE TABLE winners ( + id SERIAL PRIMARY KEY, + lottery_id INTEGER NOT NULL REFERENCES lotteries(id), + user_id INTEGER REFERENCES users(id), + account_number VARCHAR(23), + place INTEGER NOT NULL, + prize VARCHAR(500), + is_manual BOOLEAN, + created_at TIMESTAMP +); +CREATE INDEX ix_winners_account_number ON winners(account_number); +``` + +## Резервное копирование + +### Создание бэкапа +```bash +pg_dump -h 192.168.0.102 -U trevor -d bot_db > backup.sql +``` + +### Восстановление из бэкапа +```bash +psql -h 192.168.0.102 -U trevor -d bot_db < backup.sql +``` + +## Сброс базы данных + +```bash +psql -h 192.168.0.102 -U trevor -d postgres << 'EOF' +DROP DATABASE IF EXISTS bot_db; +CREATE DATABASE bot_db; +EOF +``` + +Затем: +```bash +. .venv/bin/activate && python -c "import asyncio; from database import init_db; asyncio.run(init_db())" +``` + +## Статус миграции + +✅ База данных: PostgreSQL +✅ Таблицы созданы +✅ Индексы настроены +✅ Бот запущен и работает +✅ Исправлена ошибка с `limit` параметром + +## Тестирование + +Теперь бот готов к тестированию: + +1. **Создайте тестовый розыгрыш** через админ-панель +2. **Отправьте счет** боту в формате `11-22-33-44-55-66-77-88` +3. **Проверьте inline-меню** - должны появиться кнопки действий +4. **Добавьте счета** в розыгрыш +5. **Установите победителя** по счету + +Все данные теперь хранятся в PostgreSQL на `192.168.0.102`! diff --git a/README.md b/README.md index 098658e..57e8891 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,13 @@ ## Технологии -- **Python 3.8+** -- **aiogram 3.1+** - для работы с Telegram Bot API -- **SQLAlchemy 2.0** - ORM для работы с базой данных -- **Alembic** - миграции базы данных +- **Python 3.12+** (рекомендуется Python 3.12.3+) +- **aiogram 3.16** - для работы с Telegram Bot API +- **SQLAlchemy 2.0.36** - ORM для работы с базой данных +- **Alembic 1.14** - миграции базы данных - **python-dotenv** - управление переменными окружения -- **asyncpg** - асинхронный драйвер для PostgreSQL -- **aiosqlite** - асинхронный драйвер для SQLite +- **asyncpg 0.30** - асинхронный драйвер для PostgreSQL +- **aiosqlite 0.20** - асинхронный драйвер для SQLite ## Структура проекта diff --git a/UPGRADE_NOTES.md b/UPGRADE_NOTES.md new file mode 100644 index 0000000..974f95c --- /dev/null +++ b/UPGRADE_NOTES.md @@ -0,0 +1,100 @@ +# Заметки об обновлении до Python 3.12 + +## Обновлено 15 ноября 2025 + +### Изменения зависимостей + +Все зависимости обновлены для полной совместимости с Python 3.12: + +- **aiogram**: 3.1.1 → 3.16.0 +- **aiohttp**: 3.8.5 → 3.11.18 (обязательно для Python 3.12) +- **sqlalchemy**: 2.0.23 → 2.0.36 +- **alembic**: 1.8.1 → 1.14.0 +- **python-dotenv**: 1.0.0 → 1.0.1 +- **asyncpg**: 0.28.0 → 0.30.0 +- **aiosqlite**: 0.17.0 → 0.20.0 + +### Изменения кода + +#### 1. database.py +- Обновлён импорт: `from sqlalchemy.ext.declarative import declarative_base` → `from sqlalchemy.orm import declarative_base` + +#### 2. models.py +- Заменён устаревший `datetime.utcnow` на `datetime.now(timezone.utc)` +- Все поля `created_at` теперь используют timezone-aware datetime + +#### 3. Makefile +- Все команды теперь активируют виртуальное окружение через `. .venv/bin/activate` +- Это обеспечивает использование правильных версий Python-пакетов + +### Требования к системе + +Для успешной установки требуются следующие системные пакеты: +```bash +sudo apt-get install -y build-essential python3-dev libpq-dev libssl-dev pkg-config +``` + +### Установка + +1. Убедитесь, что используете Python 3.12: +```bash +python3 --version # Должно быть 3.12.x +``` + +2. Пересоздайте виртуальное окружение: +```bash +rm -rf .venv +python3 -m venv .venv +. .venv/bin/activate +pip install --upgrade pip setuptools wheel +pip install -r requirements.txt +``` + +3. Примените миграции (если требуется): +```bash +make migrate +``` + +### Совместимость + +- ✅ Полная совместимость с Python 3.12 +- ✅ Все устаревшие API обновлены +- ✅ Проверено на Ubuntu 24.04 LTS +- ⚠️ Python 3.11 также поддерживается, но рекомендуется 3.12 + +### Известные проблемы + +1. **aiohttp 3.8.x не компилируется на Python 3.12** + - Решение: используйте aiohttp >= 3.11.0 + +2. **datetime.utcnow deprecated** + - Решение: использован `datetime.now(timezone.utc)` + +3. **sqlalchemy.ext.declarative deprecated** + - Решение: используйте `sqlalchemy.orm.declarative_base` + +### Откат к старой версии + +Если нужно вернуться к Python 3.11: + +```bash +# Установите Python 3.11 +sudo add-apt-repository ppa:deadsnakes/ppa +sudo apt-get update +sudo apt-get install python3.11 python3.11-venv python3.11-dev + +# Пересоздайте окружение +rm -rf .venv +python3.11 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +``` + +### Тестирование + +Проверка успешности обновления: +```bash +make run # Бот должен запуститься без ImportError +``` + +Если видите ошибку `Unauthorized`, это нормально — нужно настроить `.env` с вашим BOT_TOKEN. diff --git a/account_handlers.py b/account_handlers.py new file mode 100644 index 0000000..7690cfa --- /dev/null +++ b/account_handlers.py @@ -0,0 +1,375 @@ +""" +Обработчики для работы со счетами в розыгрышах +""" +from aiogram import Router, F +from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.filters import StateFilter +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from sqlalchemy.ext.asyncio import AsyncSession + +from config import ADMIN_IDS +from database import async_session_maker +from services import LotteryService +from account_services import AccountParticipationService +from account_utils import parse_accounts_from_message, validate_account_number +from typing import List + + +# Состояния FSM для работы со счетами +class AccountStates(StatesGroup): + waiting_for_lottery_choice = State() # Выбор розыгрыша для добавления счетов + waiting_for_winner_lottery = State() # Выбор розыгрыша для установки победителя + waiting_for_winner_place = State() # Выбор места победителя + searching_accounts = State() # Поиск счетов + + +# Создаем роутер +account_router = Router() + + +def is_admin(user_id: int) -> bool: + """Проверка прав администратора""" + return user_id in ADMIN_IDS + + +@account_router.message( + F.text, + StateFilter(None), + ~F.text.startswith('/') # Исключаем команды +) +async def detect_account_input(message: Message, state: FSMContext): + """ + Обнаружение ввода счетов в сообщении + Активируется только для администраторов + """ + if not is_admin(message.from_user.id): + return + + # Парсим счета из сообщения + accounts = parse_accounts_from_message(message.text) + + if not accounts: + return # Счета не обнаружены, пропускаем + + # Сохраняем счета в состоянии + await state.update_data(detected_accounts=accounts) + + # Формируем сообщение + accounts_text = "\n".join([f"• {acc}" for acc in accounts]) + count = len(accounts) + + text = ( + f"🔍 Обнаружен ввод счет{'а' if count == 1 else 'ов'}\n\n" + f"Найдено: {count}\n\n" + f"{accounts_text}\n\n" + f"Выберите действие:" + ) + + # Кнопки выбора действия + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text="➕ Добавить в розыгрыш", + callback_data="account_action:add_to_lottery" + )], + [InlineKeyboardButton( + text="👑 Сделать победителем", + callback_data="account_action:set_as_winner" + )], + [InlineKeyboardButton( + text="❌ Отмена", + callback_data="account_action:cancel" + )] + ]) + + await message.answer(text, reply_markup=keyboard, parse_mode="HTML") + + +@account_router.callback_query(F.data == "account_action:cancel") +async def cancel_account_action(callback: CallbackQuery, state: FSMContext): + """Отмена действия со счетами""" + await state.clear() + await callback.message.edit_text("❌ Действие отменено") + await callback.answer() + + +@account_router.callback_query(F.data == "account_action:add_to_lottery") +async def choose_lottery_for_accounts(callback: CallbackQuery, state: FSMContext): + """Выбор розыгрыша для добавления счетов""" + if not is_admin(callback.from_user.id): + await callback.answer("⛔ Доступно только администраторам", show_alert=True) + return + + async with async_session_maker() as session: + # Получаем активные розыгрыши + lotteries = await LotteryService.get_active_lotteries(session, limit=20) + + if not lotteries: + await callback.message.edit_text( + "❌ Нет активных розыгрышей.\n\n" + "Сначала создайте розыгрыш через /admin" + ) + await state.clear() + await callback.answer() + return + + # Формируем кнопки с розыгрышами + buttons = [] + for lottery in lotteries: + buttons.append([InlineKeyboardButton( + text=f"🎲 {lottery.title[:40]}", + callback_data=f"add_accounts_to:{lottery.id}" + )]) + + buttons.append([InlineKeyboardButton( + text="❌ Отмена", + callback_data="account_action:cancel" + )]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + + await callback.message.edit_text( + "📋 Выберите розыгрыш:", + reply_markup=keyboard, + parse_mode="HTML" + ) + await state.set_state(AccountStates.waiting_for_lottery_choice) + await callback.answer() + + +@account_router.callback_query(F.data.startswith("add_accounts_to:")) +async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext): + """Добавление счетов в выбранный розыгрыш""" + if not is_admin(callback.from_user.id): + await callback.answer("⛔ Доступно только администраторам", show_alert=True) + return + + lottery_id = int(callback.data.split(":")[1]) + + # Получаем сохраненные счета + data = await state.get_data() + accounts = data.get("detected_accounts", []) + + if not accounts: + await callback.message.edit_text("❌ Счета не найдены") + await state.clear() + await callback.answer() + return + + # Показываем процесс + await callback.message.edit_text("⏳ Добавляем счета...") + + async with async_session_maker() as session: + # Получаем информацию о розыгрыше + lottery = await LotteryService.get_lottery(session, lottery_id) + + if not lottery: + await callback.message.edit_text("❌ Розыгрыш не найден") + await state.clear() + await callback.answer() + return + + # Добавляем счета + results = await AccountParticipationService.add_accounts_bulk( + session, lottery_id, accounts + ) + + # Формируем результат + text = f"Результаты добавления в розыгрыш:\n{lottery.title}\n\n" + text += f"✅ Добавлено: {results['added']}\n" + text += f"⚠️ Пропущено: {results['skipped']}\n\n" + + if results['details']: + text += "Детали:\n" + text += "\n".join(results['details'][:20]) # Показываем первые 20 + + if len(results['details']) > 20: + text += f"\n... и ещё {len(results['details']) - 20}" + + if results['errors']: + text += f"\n\nОшибки:\n" + text += "\n".join(results['errors'][:10]) + + await callback.message.edit_text(text, parse_mode="HTML") + await state.clear() + await callback.answer("✅ Готово!") + + +@account_router.callback_query(F.data == "account_action:set_as_winner") +async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext): + """Выбор розыгрыша для установки победителя""" + if not is_admin(callback.from_user.id): + await callback.answer("⛔ Доступно только администраторам", show_alert=True) + return + + # Проверяем, что у нас только один счет + data = await state.get_data() + accounts = data.get("detected_accounts", []) + + if len(accounts) != 1: + await callback.message.edit_text( + "❌ Для установки победителя введите один счет", + parse_mode="HTML" + ) + await state.clear() + await callback.answer() + return + + async with async_session_maker() as session: + # Получаем все розыгрыши (активные и завершенные) + lotteries = await LotteryService.get_all_lotteries(session, limit=30) + + if not lotteries: + await callback.message.edit_text( + "❌ Нет розыгрышей.\n\n" + "Сначала создайте розыгрыш через /admin" + ) + await state.clear() + await callback.answer() + return + + # Формируем кнопки + buttons = [] + for lottery in lotteries: + status = "✅" if lottery.is_completed else "🎲" + buttons.append([InlineKeyboardButton( + text=f"{status} {lottery.title[:35]}", + callback_data=f"winner_lottery:{lottery.id}" + )]) + + buttons.append([InlineKeyboardButton( + text="❌ Отмена", + callback_data="account_action:cancel" + )]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + + account = accounts[0] + await callback.message.edit_text( + f"👑 Установка победителя\n\n" + f"Счет: {account}\n\n" + f"Выберите розыгрыш:", + reply_markup=keyboard, + parse_mode="HTML" + ) + await state.set_state(AccountStates.waiting_for_winner_lottery) + await callback.answer() + + +@account_router.callback_query(F.data.startswith("winner_lottery:")) +async def choose_winner_place(callback: CallbackQuery, state: FSMContext): + """Выбор места для победителя""" + if not is_admin(callback.from_user.id): + await callback.answer("⛔ Доступно только администраторам", show_alert=True) + return + + lottery_id = int(callback.data.split(":")[1]) + + # Сохраняем ID розыгрыша + await state.update_data(winner_lottery_id=lottery_id) + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + + if not lottery: + await callback.message.edit_text("❌ Розыгрыш не найден") + await state.clear() + await callback.answer() + return + + # Получаем призы + prizes = lottery.prizes or [] + + # Формируем кнопки с местами + buttons = [] + for i, prize in enumerate(prizes[:10], 1): # Максимум 10 мест + prize_text = prize if isinstance(prize, str) else prize.get('description', f'Приз {i}') + buttons.append([InlineKeyboardButton( + text=f"🏆 Место {i}: {prize_text[:30]}", + callback_data=f"winner_place:{i}" + )]) + + # Если призов нет, предлагаем места 1-5 + if not buttons: + for i in range(1, 6): + buttons.append([InlineKeyboardButton( + text=f"🏆 Место {i}", + callback_data=f"winner_place:{i}" + )]) + + buttons.append([InlineKeyboardButton( + text="❌ Отмена", + callback_data="account_action:cancel" + )]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + + data = await state.get_data() + account = data.get("detected_accounts", [])[0] + + await callback.message.edit_text( + f"👑 Установка победителя\n\n" + f"Розыгрыш: {lottery.title}\n" + f"Счет: {account}\n\n" + f"Выберите место:", + reply_markup=keyboard, + parse_mode="HTML" + ) + await state.set_state(AccountStates.waiting_for_winner_place) + await callback.answer() + + +@account_router.callback_query(F.data.startswith("winner_place:")) +async def set_account_winner(callback: CallbackQuery, state: FSMContext): + """Установка счета как победителя""" + if not is_admin(callback.from_user.id): + await callback.answer("⛔ Доступно только администраторам", show_alert=True) + return + + place = int(callback.data.split(":")[1]) + + # Получаем данные + data = await state.get_data() + account = data.get("detected_accounts", [])[0] + lottery_id = data.get("winner_lottery_id") + + if not account or not lottery_id: + await callback.message.edit_text("❌ Ошибка: данные не найдены") + await state.clear() + await callback.answer() + return + + # Показываем процесс + await callback.message.edit_text("⏳ Устанавливаем победителя...") + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + + # Получаем приз для этого места + prize = None + if lottery.prizes and len(lottery.prizes) >= place: + prize_info = lottery.prizes[place - 1] + prize = prize_info if isinstance(prize_info, str) else prize_info.get('description') + + # Устанавливаем победителя + result = await AccountParticipationService.set_account_as_winner( + session, lottery_id, account, place, prize + ) + + if result["success"]: + text = ( + f"✅ Победитель установлен!\n\n" + f"Розыгрыш: {lottery.title}\n" + f"Счет: {account}\n" + f"Место: {place}\n" + ) + if prize: + text += f"Приз: {prize}" + + await callback.answer("✅ Победитель установлен!", show_alert=True) + else: + text = f"❌ {result['message']}" + await callback.answer("❌ Ошибка", show_alert=True) + + await callback.message.edit_text(text, parse_mode="HTML") + await state.clear() diff --git a/account_services.py b/account_services.py new file mode 100644 index 0000000..cf9b277 --- /dev/null +++ b/account_services.py @@ -0,0 +1,287 @@ +""" +Сервис для работы с участием счетов в розыгрышах (без привязки к пользователям) +""" +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete, func +from models import Lottery, Participation, Winner +from account_utils import validate_account_number, format_account_number, parse_accounts_from_message, search_accounts_by_pattern +from typing import List, Optional, Dict, Any + + +class AccountParticipationService: + """Сервис для работы с участием счетов в розыгрышах""" + + @staticmethod + async def add_account_to_lottery( + session: AsyncSession, + lottery_id: int, + account_number: str + ) -> Dict[str, Any]: + """ + Добавить счет в розыгрыш + + Returns: + Dict с ключами: success, message, account_number + """ + # Валидируем и форматируем + formatted_account = format_account_number(account_number) + if not formatted_account: + return { + "success": False, + "message": f"Неверный формат счета: {account_number}", + "account_number": account_number + } + + # Проверяем существование розыгрыша + lottery = await session.get(Lottery, lottery_id) + if not lottery: + return { + "success": False, + "message": f"Розыгрыш #{lottery_id} не найден", + "account_number": formatted_account + } + + # Проверяем, не участвует ли уже этот счет + existing = await session.execute( + select(Participation).where( + Participation.lottery_id == lottery_id, + Participation.account_number == formatted_account + ) + ) + if existing.scalar_one_or_none(): + return { + "success": False, + "message": f"Счет {formatted_account} уже участвует в розыгрыше", + "account_number": formatted_account + } + + # Добавляем участие + participation = Participation( + lottery_id=lottery_id, + account_number=formatted_account, + user_id=None # Без привязки к пользователю + ) + session.add(participation) + await session.commit() + + return { + "success": True, + "message": f"Счет {formatted_account} добавлен в розыгрыш", + "account_number": formatted_account + } + + @staticmethod + async def add_accounts_bulk( + session: AsyncSession, + lottery_id: int, + account_numbers: List[str] + ) -> Dict[str, Any]: + """ + Массовое добавление счетов в розыгрыш + """ + results = { + "added": 0, + "skipped": 0, + "errors": [], + "details": [], + "added_accounts": [], + "skipped_accounts": [] + } + + for account in account_numbers: + result = await AccountParticipationService.add_account_to_lottery( + session, lottery_id, account + ) + + if result["success"]: + results["added"] += 1 + results["added_accounts"].append(result["account_number"]) + results["details"].append(f"✅ {result['account_number']}") + else: + results["skipped"] += 1 + results["skipped_accounts"].append(account) + results["errors"].append(result["message"]) + results["details"].append(f"❌ {result['message']}") + + return results + + @staticmethod + async def remove_account_from_lottery( + session: AsyncSession, + lottery_id: int, + account_number: str + ) -> Dict[str, Any]: + """Удалить счет из розыгрыша""" + formatted_account = format_account_number(account_number) + if not formatted_account: + return { + "success": False, + "message": f"Неверный формат счета: {account_number}" + } + + participation = await session.execute( + select(Participation).where( + Participation.lottery_id == lottery_id, + Participation.account_number == formatted_account + ) + ) + participation = participation.scalar_one_or_none() + + if not participation: + return { + "success": False, + "message": f"Счет {formatted_account} не участвует в розыгрыше" + } + + await session.delete(participation) + await session.commit() + + return { + "success": True, + "message": f"Счет {formatted_account} удален из розыгрыша" + } + + @staticmethod + async def get_lottery_accounts( + session: AsyncSession, + lottery_id: int, + limit: Optional[int] = None, + offset: int = 0 + ) -> List[str]: + """Получить все счета, участвующие в розыгрыше""" + query = select(Participation.account_number).where( + Participation.lottery_id == lottery_id, + Participation.account_number.isnot(None) + ).order_by(Participation.created_at.desc()) + + if limit: + query = query.offset(offset).limit(limit) + + result = await session.execute(query) + return [account for account in result.scalars().all() if account] + + @staticmethod + async def get_accounts_count(session: AsyncSession, lottery_id: int) -> int: + """Получить количество счетов в розыгрыше""" + result = await session.scalar( + select(func.count(Participation.id)).where( + Participation.lottery_id == lottery_id, + Participation.account_number.isnot(None) + ) + ) + return result or 0 + + @staticmethod + async def search_accounts_in_lottery( + session: AsyncSession, + lottery_id: int, + pattern: str, + limit: int = 20 + ) -> List[str]: + """ + Поиск счетов в розыгрыше по частичному совпадению + + Args: + lottery_id: ID розыгрыша + pattern: Паттерн поиска (например "11-22" или "33") + limit: Максимальное количество результатов + """ + # Получаем все счета розыгрыша + all_accounts = await AccountParticipationService.get_lottery_accounts( + session, lottery_id + ) + + # Ищем совпадения + return search_accounts_by_pattern(pattern, all_accounts)[:limit] + + @staticmethod + async def set_account_as_winner( + session: AsyncSession, + lottery_id: int, + account_number: str, + place: int, + prize: Optional[str] = None + ) -> Dict[str, Any]: + """ + Установить счет как победителя на указанное место + """ + formatted_account = format_account_number(account_number) + if not formatted_account: + return { + "success": False, + "message": f"Неверный формат счета: {account_number}" + } + + # Проверяем, участвует ли счет в розыгрыше + participation = await session.execute( + select(Participation).where( + Participation.lottery_id == lottery_id, + Participation.account_number == formatted_account + ) + ) + if not participation.scalar_one_or_none(): + return { + "success": False, + "message": f"Счет {formatted_account} не участвует в розыгрыше" + } + + # Проверяем, не занято ли уже это место + existing_winner = await session.execute( + select(Winner).where( + Winner.lottery_id == lottery_id, + Winner.place == place + ) + ) + existing_winner = existing_winner.scalar_one_or_none() + + if existing_winner: + # Обновляем существующего победителя + existing_winner.account_number = formatted_account + existing_winner.user_id = None + existing_winner.is_manual = True + if prize: + existing_winner.prize = prize + else: + # Создаем нового победителя + winner = Winner( + lottery_id=lottery_id, + account_number=formatted_account, + user_id=None, + place=place, + prize=prize, + is_manual=True + ) + session.add(winner) + + await session.commit() + + return { + "success": True, + "message": f"Счет {formatted_account} установлен победителем на место {place}", + "account_number": formatted_account, + "place": place + } + + @staticmethod + async def get_lottery_winners_accounts( + session: AsyncSession, + lottery_id: int + ) -> List[Dict[str, Any]]: + """Получить всех победителей розыгрыша (счета)""" + result = await session.execute( + select(Winner).where( + Winner.lottery_id == lottery_id, + Winner.account_number.isnot(None) + ).order_by(Winner.place) + ) + + winners = result.scalars().all() + return [ + { + "place": w.place, + "account_number": w.account_number, + "prize": w.prize, + "is_manual": w.is_manual + } + for w in winners + ] diff --git a/account_utils.py b/account_utils.py index 657eca5..8db2f2a 100644 --- a/account_utils.py +++ b/account_utils.py @@ -2,13 +2,13 @@ Утилиты для работы с клиентскими счетами """ import re -from typing import Optional +from typing import Optional, List def validate_account_number(account_number: str) -> bool: """ Проверяет корректность формата номера клиентского счета - Формат: XX-XX-XX-XX-XX-XX-XX-XX (8 пар цифр через дефис) + Формат: XX-XX-XX-XX-XX-XX-XX (7 пар цифр через дефис) Args: account_number: Номер счета для проверки @@ -19,8 +19,8 @@ def validate_account_number(account_number: str) -> bool: 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}$' + # Паттерн для 7 пар цифр через дефис + pattern = r'^\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$' return bool(re.match(pattern, account_number)) @@ -41,12 +41,12 @@ def format_account_number(account_number: str) -> Optional[str]: # Убираем все символы кроме цифр digits_only = re.sub(r'\D', '', account_number) - # Проверяем что осталось ровно 16 цифр - if len(digits_only) != 16: + # Проверяем что осталось ровно 14 цифр + if len(digits_only) != 14: return None - # Форматируем как XX-XX-XX-XX-XX-XX-XX-XX - formatted = '-'.join([digits_only[i:i+2] for i in range(0, 16, 2)]) + # Форматируем как XX-XX-XX-XX-XX-XX-XX + formatted = '-'.join([digits_only[i:i+2] for i in range(0, 14, 2)]) return formatted @@ -60,11 +60,11 @@ def generate_account_number() -> str: """ import random - # Генерируем 16 случайных цифр - digits = ''.join([str(random.randint(0, 9)) for _ in range(16)]) + # Генерируем 14 случайных цифр + digits = ''.join([str(random.randint(0, 9)) for _ in range(14)]) # Форматируем - return '-'.join([digits[i:i+2] for i in range(0, 16, 2)]) + return '-'.join([digits[i:i+2] for i in range(0, 14, 2)]) def mask_account_number(account_number: str, show_last_digits: int = 4) -> str: @@ -82,7 +82,7 @@ def mask_account_number(account_number: str, show_last_digits: int = 4) -> str: return "Некорректный номер" if show_last_digits <= 0: - return "**-**-**-**-**-**-**-**" + return "**-**-**-**-**-**-**" # Убираем дефисы для работы с цифрами digits = account_number.replace('-', '') @@ -93,5 +93,59 @@ def mask_account_number(account_number: str, show_last_digits: int = 4) -> str: # Создаем маску masked_digits = '*' * (len(digits) - show_digits) + digits[-show_digits:] - # Возвращаем отформатированный результат - return '-'.join([masked_digits[i:i+2] for i in range(0, 16, 2)]) \ No newline at end of file + # Возвращаем отформатированный результат (7 пар) + return '-'.join([masked_digits[i:i+2] for i in range(0, 14, 2)]) + + +def parse_accounts_from_message(text: str) -> List[str]: + """ + Извлекает все валидные номера счетов из текста сообщения + + Args: + text: Текст сообщения + + Returns: + List[str]: Список найденных и отформатированных номеров счетов + """ + if not text: + return [] + + accounts = [] + # Ищем паттерны счетов в тексте (7 пар цифр) + pattern = r'\b\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}\b' + matches = re.findall(pattern, text) + + for match in matches: + formatted = format_account_number(match) + if formatted and formatted not in accounts: + accounts.append(formatted) + + return accounts + + +def search_accounts_by_pattern(pattern: str, account_list: List[str]) -> List[str]: + """ + Ищет счета по частичному совпадению (1-2 пары цифр) + + Args: + pattern: Паттерн для поиска (например "11-22" или "11") + account_list: Список счетов для поиска + + Returns: + List[str]: Список найденных счетов + """ + if not pattern or not account_list: + return [] + + # Убираем лишние символы из паттерна + clean_pattern = re.sub(r'[^\d-]', '', pattern).strip('-') + + if not clean_pattern: + return [] + + results = [] + for account in account_list: + if clean_pattern in account: + results.append(account) + + return results \ No newline at end of file diff --git a/accounts_100.txt b/accounts_100.txt new file mode 100644 index 0000000..72c990e --- /dev/null +++ b/accounts_100.txt @@ -0,0 +1,100 @@ +85-84-87-41-83-41-63 +03-15-35-94-83-22-40 +36-60-34-92-81-48-41 +97-66-15-47-35-85-59 +16-76-88-84-05-81-72 +51-94-46-57-13-01-50 +50-73-96-63-73-74-24 +94-13-13-89-83-22-75 +39-85-17-28-30-43-83 +60-72-58-00-79-48-54 +29-43-78-41-85-88-89 +12-95-36-23-38-10-06 +48-64-41-80-09-73-05 +23-24-48-78-27-46-23 +75-26-85-70-08-44-54 +48-06-69-72-17-18-85 +90-86-19-06-42-12-59 +25-69-98-23-66-87-30 +07-42-11-95-24-00-89 +01-36-94-83-70-99-72 +03-73-60-40-05-98-20 +49-09-08-82-43-55-34 +42-99-12-21-99-08-03 +23-46-32-24-11-78-27 +23-03-83-99-03-22-33 +48-06-78-22-76-02-51 +62-44-30-46-41-65-49 +19-29-95-47-06-40-14 +15-25-76-63-12-04-30 +62-44-62-85-26-11-28 +01-52-72-62-41-69-09 +15-13-82-39-71-48-08 +62-34-87-77-30-28-16 +81-21-09-65-26-16-72 +50-21-82-08-57-81-17 +29-23-02-52-28-27-51 +13-88-88-89-68-44-08 +29-23-68-44-73-98-87 +32-45-19-09-32-21-07 +00-07-34-21-79-82-21 +71-48-00-71-76-37-60 +58-83-40-36-55-92-79 +79-21-14-76-38-94-49 +80-68-03-20-28-36-87 +61-06-20-44-19-50-27 +02-71-09-46-02-77-01 +97-02-89-39-51-57-45 +90-90-25-70-96-57-78 +12-31-23-39-22-19-49 +05-32-23-84-24-00-09 +53-78-44-05-69-82-19 +29-77-88-44-31-29-36 +34-73-69-69-53-59-25 +71-66-51-35-53-29-95 +16-95-52-71-19-23-20 +38-16-67-97-47-29-82 +87-08-91-20-38-46-32 +58-74-83-45-82-59-19 +48-41-67-61-01-96-92 +76-95-03-63-10-18-39 +29-32-93-82-25-29-56 +39-32-31-37-91-78-45 +00-84-92-88-61-09-66 +02-61-52-90-79-96-34 +52-97-20-79-38-86-51 +76-48-21-82-43-43-80 +73-21-43-93-39-36-74 +16-87-26-27-94-22-46 +64-74-00-76-70-33-26 +67-41-92-18-56-05-09 +13-55-02-86-61-16-95 +68-67-72-43-39-48-71 +02-20-42-68-50-30-24 +81-59-13-84-17-42-96 +93-94-95-35-23-68-02 +46-88-55-91-39-85-98 +34-41-63-45-30-75-63 +73-43-03-86-25-51-40 +30-76-97-41-02-58-36 +27-37-86-88-71-97-99 +07-44-36-19-40-72-04 +91-55-25-24-73-65-16 +74-54-91-40-64-42-94 +36-30-21-26-23-48-68 +79-83-86-59-11-18-74 +25-99-97-49-02-63-90 +56-13-47-96-62-62-16 +28-52-83-51-16-13-03 +14-80-79-79-62-70-67 +54-63-36-53-55-69-20 +47-84-33-35-58-35-36 +68-35-65-98-15-89-52 +01-38-28-66-99-84-39 +55-97-59-20-47-69-18 +99-88-32-71-12-42-94 +33-06-14-42-79-98-95 +31-19-17-66-90-50-92 +77-00-02-95-76-47-68 +88-75-41-20-73-22-22 +23-18-39-53-89-39-91 diff --git a/admin_panel.py b/admin_panel.py index 6c9b112..9bed731 100644 --- a/admin_panel.py +++ b/admin_panel.py @@ -1184,11 +1184,11 @@ async def choose_accounts_bulk_add(callback: CallbackQuery, state: FSMContext): text = f"🏦 Массовое добавление в: {lottery.title}\n\n" text += "Введите список номеров счетов через запятую или новую строку:\n\n" text += "Примеры:\n" - text += "• 12-34-56-78-90-12-34-56\n" - text += "• 98-76-54-32-10-98-76-54, 11-22-33-44-55-66-77-88\n" - text += "• 12345678901234567890 (будет отформатирован)\n\n" - text += "Формат: XX-XX-XX-XX-XX-XX-XX-XX\n" - text += "Всего 8 пар цифр разделенных дефисами" + text += "• 12-34-56-78-90-12-34\n" + text += "• 98-76-54-32-10-98-76, 11-22-33-44-55-66-77\n" + text += "• 12345678901234 (будет отформатирован)\n\n" + text += "Формат: XX-XX-XX-XX-XX-XX-XX\n" + text += "Всего 7 пар цифр разделенных дефисами" await callback.message.edit_text( text, @@ -1314,10 +1314,10 @@ async def choose_accounts_bulk_remove(callback: CallbackQuery, state: FSMContext text = f"🏦 Массовое удаление из: {lottery.title}\n\n" text += "Введите список номеров счетов через запятую или новую строку:\n\n" text += "Примеры:\n" - text += "• 12-34-56-78-90-12-34-56\n" - text += "• 98-76-54-32-10-98-76-54, 11-22-33-44-55-66-77-88\n" - text += "• 12345678901234567890 (будет отформатирован)\n\n" - text += "Формат: XX-XX-XX-XX-XX-XX-XX-XX" + text += "• 12-34-56-78-90-12-34\n" + text += "• 98-76-54-32-10-98-76, 11-22-33-44-55-66-77\n" + text += "• 12345678901234 (будет отформатирован)\n\n" + text += "Формат: XX-XX-XX-XX-XX-XX-XX" await callback.message.edit_text( text, @@ -1868,6 +1868,20 @@ async def start_set_manual_winner(callback: CallbackQuery, state: FSMContext): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) +@admin_router.callback_query(F.data.startswith("admin_set_winner_")) +async def handle_set_winner_from_lottery(callback: CallbackQuery, state: FSMContext): + """Обработчик для кнопки 'Установить победителя' из карточки розыгрыша""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[-1]) + + # Перенаправляем на стандартный обработчик + callback.data = f"admin_choose_winner_lottery_{lottery_id}" + await choose_winner_place(callback, state) + + @admin_router.callback_query(F.data.startswith("admin_choose_winner_lottery_")) async def choose_winner_place(callback: CallbackQuery, state: FSMContext): """Выбор места для победителя""" @@ -2023,6 +2037,101 @@ async def process_winner_user(message: Message, state: FSMContext): ) +# ====================== +# ПРОВЕДЕНИЕ РОЗЫГРЫША +# ====================== + +@admin_router.callback_query(F.data == "admin_conduct_draw") +async def choose_lottery_for_draw(callback: CallbackQuery): + """Выбор розыгрыша для проведения""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + async with async_session_maker() as session: + lotteries = await LotteryService.get_active_lotteries(session, limit=100) + + if not lotteries: + await callback.answer("Нет активных розыгрышей", show_alert=True) + 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" + if lottery.is_completed: + text += f" ✅ Завершён\n" + text += "\n" + + buttons.append([ + InlineKeyboardButton( + text=f"🎲 {lottery.title[:30]}...", + callback_data=f"admin_conduct_{lottery.id}" + ) + ]) + + buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_draws")]) + + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@admin_router.callback_query(F.data.startswith("admin_conduct_")) +async def conduct_lottery_draw(callback: CallbackQuery): + """Проведение розыгрыша""" + if not is_admin(callback.from_user.id): + await callback.answer("❌ Недостаточно прав", show_alert=True) + return + + lottery_id = int(callback.data.split("_")[-1]) + + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + + if not lottery: + await callback.answer("Розыгрыш не найден", show_alert=True) + return + + if lottery.is_completed: + await callback.answer("Розыгрыш уже завершён", show_alert=True) + return + + participants_count = await ParticipationService.get_participants_count(session, lottery_id) + + if participants_count == 0: + await callback.answer("Нет участников для розыгрыша", show_alert=True) + return + + # Проводим розыгрыш + from conduct_draw import conduct_draw + winners = await conduct_draw(lottery_id) + + if winners: + text = f"🎉 Розыгрыш '{lottery.title}' завершён!\n\n" + text += "🏆 Победители:\n" + for winner in winners: + if winner.account_number: + text += f"{winner.place} место: {winner.account_number}\n" + elif winner.user: + username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name + text += f"{winner.place} место: {username}\n" + else: + text += f"{winner.place} место: ID {winner.user_id}\n" + + await callback.message.edit_text( + text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔙 К розыгрышам", callback_data="admin_draws")] + ]) + ) + else: + await callback.answer("Ошибка при проведении розыгрыша", show_alert=True) + + # ====================== # СТАТИСТИКА # ====================== diff --git a/alembic.ini b/alembic.ini index db697be..b3df417 100644 --- a/alembic.ini +++ b/alembic.ini @@ -55,8 +55,7 @@ version_path_separator = os # are written from script.py.mako # output_encoding = utf-8 -sqlalchemy.url = sqlite:///./lottery_bot.db - +sqlalchemy.url = postgresql+asyncpg://trevor:R0sebud@192.168.0.102/bot_db [post_write_hooks] # post_write_hooks defines scripts or Python functions that are run diff --git a/database.py b/database.py index afd85c7..f07d3b5 100644 --- a/database.py +++ b/database.py @@ -1,6 +1,6 @@ import os from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import declarative_base from dotenv import load_dotenv # Загружаем переменные окружения diff --git a/main.py b/main.py index 0543c0f..cb02f81 100644 --- a/main.py +++ b/main.py @@ -17,6 +17,7 @@ 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 account_handlers import account_router from async_decorators import ( async_user_action, admin_async_action, db_operation, TaskManagerMiddleware, shutdown_task_manager, @@ -575,9 +576,9 @@ async def start_account_setup(callback: CallbackQuery, state: FSMContext): text = f"💳 **Процедура {action} счёта**\n\n" text += "Введите номер вашего клиентского счёта в формате:\n" - text += "`12-34-56-78-90-12-34-56`\n\n" + text += "`12-34-56-78-90-12-34`\n\n" text += "📝 **Требования:**\n" - text += "• Ровно 16 цифр\n" + text += "• Ровно 14 цифр\n" text += "• Разделены дефисами через каждые 2 цифры\n" text += "• Номер должен быть уникальным\n\n" text += "✉️ Отправьте номер счёта в ответном сообщении" @@ -603,8 +604,8 @@ async def process_account_number(message: Message, state: FSMContext): if not formatted_number: await message.answer( "❌ **Некорректный формат номера счёта**\n\n" - "Номер должен содержать ровно 16 цифр.\n" - "Пример правильного формата: `12-34-56-78-90-12-34-56`\n\n" + "Номер должен содержать ровно 14 цифр.\n" + "Пример правильного формата: `12-34-56-78-90-12-34`\n\n" "Попробуйте ещё раз:", parse_mode="Markdown" ) @@ -699,6 +700,7 @@ async def main(): await set_commands() # Подключение роутеров + dp.include_router(account_router) # Роутер для работы со счетами (приоритетный) dp.include_router(router) dp.include_router(admin_router) diff --git a/migrations/versions/20251115_1911_53_0e35616a69df_add_account_number_to_participations_.py b/migrations/versions/20251115_1911_53_0e35616a69df_add_account_number_to_participations_.py new file mode 100644 index 0000000..3d4fd70 --- /dev/null +++ b/migrations/versions/20251115_1911_53_0e35616a69df_add_account_number_to_participations_.py @@ -0,0 +1,46 @@ +"""Add account_number to participations and winners + +Revision ID: 0e35616a69df +Revises: 002_add_account_numbers_and_display_type +Create Date: 2025-11-15 19:11:53.075216 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0e35616a69df' +down_revision = '002_add_account_numbers_and_display_type' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('participations', sa.Column('account_number', sa.String(length=23), nullable=True)) + op.alter_column('participations', 'user_id', + existing_type=sa.INTEGER(), + nullable=True) + op.create_index(op.f('ix_participations_account_number'), 'participations', ['account_number'], unique=False) + op.add_column('winners', sa.Column('account_number', sa.String(length=23), nullable=True)) + op.alter_column('winners', 'user_id', + existing_type=sa.INTEGER(), + nullable=True) + op.create_index(op.f('ix_winners_account_number'), 'winners', ['account_number'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_winners_account_number'), table_name='winners') + op.alter_column('winners', 'user_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_column('winners', 'account_number') + op.drop_index(op.f('ix_participations_account_number'), table_name='participations') + op.alter_column('participations', 'user_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_column('participations', 'account_number') + # ### end Alembic commands ### \ No newline at end of file diff --git a/models.py b/models.py index a11d9b8..63a02a3 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,6 @@ from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON from sqlalchemy.orm import relationship -from datetime import datetime +from datetime import datetime, timezone from database import Base @@ -13,10 +13,10 @@ class User(Base): username = Column(String(255)) first_name = Column(String(255)) last_name = Column(String(255)) - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) 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) + # Клиентский счет в формате: XX-XX-XX-XX-XX-XX-XX (7 пар цифр через дефис) + account_number = Column(String(20), unique=True, nullable=True, index=True) # Связи participations = relationship("Participation", back_populates="user") @@ -32,9 +32,9 @@ class Lottery(Base): 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) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + start_date = Column(DateTime(timezone=True)) + end_date = Column(DateTime(timezone=True)) is_active = Column(Boolean, default=True) is_completed = Column(Boolean, default=False) prizes = Column(JSON) # Список призов в формате JSON @@ -60,15 +60,18 @@ class Participation(Base): __tablename__ = "participations" id = Column(Integer, primary_key=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Опционально lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False) - created_at = Column(DateTime, default=datetime.utcnow) + account_number = Column(String(20), nullable=True, index=True) # Счет участника (XX-XX-XX-XX-XX-XX-XX) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) # Связи user = relationship("User", back_populates="participations") lottery = relationship("Lottery", back_populates="participations") def __repr__(self): + if self.account_number: + return f"" return f"" @@ -78,15 +81,18 @@ class Winner(Base): id = Column(Integer, primary_key=True) lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Опционально + account_number = Column(String(20), nullable=True, index=True) # Счет победителя place = Column(Integer, nullable=False) # Место (1, 2, 3...) prize = Column(String(500)) # Описание приза is_manual = Column(Boolean, default=False) # Был ли установлен вручную - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) # Связи user = relationship("User") lottery = relationship("Lottery") def __repr__(self): + if self.account_number: + return f"" return f"" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d922629..3f43fc4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ -aiogram==3.1.1 -sqlalchemy==2.0.23 -alembic==1.8.1 -python-dotenv==1.0.0 -asyncpg==0.28.0 -aiosqlite==0.17.0 \ No newline at end of file +# Updated for Python 3.12 compatibility +aiogram==3.16.0 +aiohttp>=3.11.0 +sqlalchemy==2.0.36 +alembic==1.14.0 +python-dotenv==1.0.1 +asyncpg==0.30.0 +aiosqlite==0.20.0 \ No newline at end of file diff --git a/services.py b/services.py index 8b0c837..f6fc3e3 100644 --- a/services.py +++ b/services.py @@ -198,22 +198,28 @@ class LotteryService: return result.scalar_one_or_none() @staticmethod - async def get_active_lotteries(session: AsyncSession) -> List[Lottery]: + async def get_active_lotteries(session: AsyncSession, limit: Optional[int] = None) -> List[Lottery]: """Получить список активных розыгрышей""" - result = await session.execute( - select(Lottery) - .where(Lottery.is_active == True, Lottery.is_completed == False) - .order_by(Lottery.created_at.desc()) - ) + query = select(Lottery).where( + Lottery.is_active == True, + Lottery.is_completed == False + ).order_by(Lottery.created_at.desc()) + + if limit: + query = query.limit(limit) + + result = await session.execute(query) return result.scalars().all() @staticmethod - async def get_all_lotteries(session: AsyncSession) -> List[Lottery]: + async def get_all_lotteries(session: AsyncSession, limit: Optional[int] = None) -> List[Lottery]: """Получить список всех розыгрышей""" - result = await session.execute( - select(Lottery) - .order_by(Lottery.created_at.desc()) - ) + query = select(Lottery).order_by(Lottery.created_at.desc()) + + if limit: + query = query.limit(limit) + + result = await session.execute(query) return result.scalars().all() @staticmethod