main functions fix

This commit is contained in:
2025-11-15 20:03:49 +09:00
parent e0075d91b6
commit 3a25e6a4cb
18 changed files with 1779 additions and 75 deletions

332
ACCOUNT_SYSTEM_GUIDE.md Normal file
View File

@@ -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 пар)
### Счет не добавляется
**Проблема:** Счет уже участвует в розыгрыше
**Решение:** Проверьте список участников розыгрыша
### Ошибка при установке победителя
**Проблема:** Счет не участвует в розыгрыше
**Решение:** Сначала добавьте счет в розыгрыш, затем установите победителем

126
COMMAND_FIX.md Normal file
View File

@@ -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
```
### Обычный текст (должен игнорироваться):
```
Привет, как дела?
Когда будет розыгрыш?
```
Все должно работать корректно!

View File

@@ -29,7 +29,7 @@ help:
install: install:
@echo "📦 Установка зависимостей..." @echo "📦 Установка зависимостей..."
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate && pip install -r requirements.txt . .venv/bin/activate && pip install -r requirements.txt
# Первоначальная настройка # Первоначальная настройка
setup: install setup: install
@@ -38,49 +38,49 @@ setup: install
echo "❌ Файл .env не найден! Скопируйте .env.example в .env"; \ echo "❌ Файл .env не найден! Скопируйте .env.example в .env"; \
exit 1; \ exit 1; \
fi fi
python utils.py init . .venv/bin/activate && python utils.py init
python utils.py setup-admins . .venv/bin/activate && python utils.py setup-admins
@echo "✅ Настройка завершена!" @echo "✅ Настройка завершена!"
# Запуск бота # Запуск бота
run: run:
@echo "🚀 Запуск бота..." @echo "🚀 Запуск бота..."
python main.py . .venv/bin/activate && python main.py
# Создание миграции # Создание миграции
migration: migration:
@echo "📄 Создание новой миграции..." @echo "📄 Создание новой миграции..."
alembic revision --autogenerate -m "$(MSG)" . .venv/bin/activate && alembic revision --autogenerate -m "$(MSG)"
# Применение миграций # Применение миграций
migrate: migrate:
@echo "⬆️ Применение миграций..." @echo "⬆️ Применение миграций..."
alembic upgrade head . .venv/bin/activate && alembic upgrade head
# Тесты и примеры # Тесты и примеры
test: test:
@echo "🧪 Запуск тестов..." @echo "🧪 Запуск тестов..."
python examples.py . .venv/bin/activate && python examples.py
# Создание тестового розыгрыша # Создание тестового розыгрыша
sample: sample:
@echo "🎲 Создание тестового розыгрыша..." @echo "🎲 Создание тестового розыгрыша..."
python utils.py sample . .venv/bin/activate && python utils.py sample
# Статистика # Статистика
stats: stats:
@echo "📊 Статистика бота..." @echo "📊 Статистика бота..."
python utils.py stats . .venv/bin/activate && python utils.py stats
# Демонстрация админ-панели # Демонстрация админ-панели
demo-admin: demo-admin:
@echo "🎪 Демонстрация возможностей админ-панели..." @echo "🎪 Демонстрация возможностей админ-панели..."
python demo_admin.py . .venv/bin/activate && python demo_admin.py
# Тестирование улучшений админки # Тестирование улучшений админки
test-admin: test-admin:
@echo "🧪 Тестирование новых функций админ-панели..." @echo "🧪 Тестирование новых функций админ-панели..."
python test_admin_improvements.py . .venv/bin/activate && python test_admin_improvements.py
# Очистка # Очистка
clean: clean:

160
POSTGRESQL_MIGRATION.md Normal file
View File

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

View File

@@ -16,13 +16,13 @@
## Технологии ## Технологии
- **Python 3.8+** - **Python 3.12+** (рекомендуется Python 3.12.3+)
- **aiogram 3.1+** - для работы с Telegram Bot API - **aiogram 3.16** - для работы с Telegram Bot API
- **SQLAlchemy 2.0** - ORM для работы с базой данных - **SQLAlchemy 2.0.36** - ORM для работы с базой данных
- **Alembic** - миграции базы данных - **Alembic 1.14** - миграции базы данных
- **python-dotenv** - управление переменными окружения - **python-dotenv** - управление переменными окружения
- **asyncpg** - асинхронный драйвер для PostgreSQL - **asyncpg 0.30** - асинхронный драйвер для PostgreSQL
- **aiosqlite** - асинхронный драйвер для SQLite - **aiosqlite 0.20** - асинхронный драйвер для SQLite
## Структура проекта ## Структура проекта

100
UPGRADE_NOTES.md Normal file
View File

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

375
account_handlers.py Normal file
View File

@@ -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"🔍 <b>Обнаружен ввод счет{'а' if count == 1 else 'ов'}</b>\n\n"
f"Найдено: <b>{count}</b>\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(
"📋 <b>Выберите розыгрыш:</b>",
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"<b>Результаты добавления в розыгрыш:</b>\n<i>{lottery.title}</i>\n\n"
text += f"✅ Добавлено: <b>{results['added']}</b>\n"
text += f"⚠️ Пропущено: <b>{results['skipped']}</b>\n\n"
if results['details']:
text += "<b>Детали:</b>\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<b>Ошибки:</b>\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(
"❌ Для установки победителя введите <b>один</b> счет",
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"👑 <b>Установка победителя</b>\n\n"
f"Счет: <code>{account}</code>\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"👑 <b>Установка победителя</b>\n\n"
f"Розыгрыш: <i>{lottery.title}</i>\n"
f"Счет: <code>{account}</code>\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"✅ <b>Победитель установлен!</b>\n\n"
f"Розыгрыш: <i>{lottery.title}</i>\n"
f"Счет: <code>{account}</code>\n"
f"Место: <b>{place}</b>\n"
)
if prize:
text += f"Приз: <i>{prize}</i>"
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()

287
account_services.py Normal file
View File

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

View File

@@ -2,13 +2,13 @@
Утилиты для работы с клиентскими счетами Утилиты для работы с клиентскими счетами
""" """
import re import re
from typing import Optional from typing import Optional, List
def validate_account_number(account_number: str) -> bool: 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: Args:
account_number: Номер счета для проверки account_number: Номер счета для проверки
@@ -19,8 +19,8 @@ def validate_account_number(account_number: str) -> bool:
if not account_number: if not account_number:
return False return False
# Паттерн для 8 пар цифр через дефис # Паттерн для 7 пар цифр через дефис
pattern = r'^\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$' pattern = r'^\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$'
return bool(re.match(pattern, account_number)) 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) digits_only = re.sub(r'\D', '', account_number)
# Проверяем что осталось ровно 16 цифр # Проверяем что осталось ровно 14 цифр
if len(digits_only) != 16: if len(digits_only) != 14:
return None return None
# Форматируем как XX-XX-XX-XX-XX-XX-XX-XX # Форматируем как XX-XX-XX-XX-XX-XX-XX
formatted = '-'.join([digits_only[i:i+2] for i in range(0, 16, 2)]) formatted = '-'.join([digits_only[i:i+2] for i in range(0, 14, 2)])
return formatted return formatted
@@ -60,11 +60,11 @@ def generate_account_number() -> str:
""" """
import random import random
# Генерируем 16 случайных цифр # Генерируем 14 случайных цифр
digits = ''.join([str(random.randint(0, 9)) for _ in range(16)]) 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: 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 "Некорректный номер" return "Некорректный номер"
if show_last_digits <= 0: if show_last_digits <= 0:
return "**-**-**-**-**-**-**-**" return "**-**-**-**-**-**-**"
# Убираем дефисы для работы с цифрами # Убираем дефисы для работы с цифрами
digits = account_number.replace('-', '') 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:] masked_digits = '*' * (len(digits) - show_digits) + digits[-show_digits:]
# Возвращаем отформатированный результат # Возвращаем отформатированный результат (7 пар)
return '-'.join([masked_digits[i:i+2] for i in range(0, 16, 2)]) 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

100
accounts_100.txt Normal file
View File

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

View File

@@ -1184,11 +1184,11 @@ async def choose_accounts_bulk_add(callback: CallbackQuery, state: FSMContext):
text = f"🏦 Массовое добавление в: {lottery.title}\n\n" text = f"🏦 Массовое добавление в: {lottery.title}\n\n"
text += "Введите список номеров счетов через запятую или новую строку:\n\n" text += "Введите список номеров счетов через запятую или новую строку:\n\n"
text += "Примеры:\n" text += "Примеры:\n"
text += "• 12-34-56-78-90-12-34-56\n" text += "• 12-34-56-78-90-12-34\n"
text += "• 98-76-54-32-10-98-76-54, 11-22-33-44-55-66-77-88\n" text += "• 98-76-54-32-10-98-76, 11-22-33-44-55-66-77\n"
text += "• 12345678901234567890 (будет отформатирован)\n\n" text += "• 12345678901234 (будет отформатирован)\n\n"
text += "Формат: XX-XX-XX-XX-XX-XX-XX-XX\n" text += "Формат: XX-XX-XX-XX-XX-XX-XX\n"
text += "Всего 8 пар цифр разделенных дефисами" text += "Всего 7 пар цифр разделенных дефисами"
await callback.message.edit_text( await callback.message.edit_text(
text, text,
@@ -1314,10 +1314,10 @@ async def choose_accounts_bulk_remove(callback: CallbackQuery, state: FSMContext
text = f"🏦 Массовое удаление из: {lottery.title}\n\n" text = f"🏦 Массовое удаление из: {lottery.title}\n\n"
text += "Введите список номеров счетов через запятую или новую строку:\n\n" text += "Введите список номеров счетов через запятую или новую строку:\n\n"
text += "Примеры:\n" text += "Примеры:\n"
text += "• 12-34-56-78-90-12-34-56\n" text += "• 12-34-56-78-90-12-34\n"
text += "• 98-76-54-32-10-98-76-54, 11-22-33-44-55-66-77-88\n" text += "• 98-76-54-32-10-98-76, 11-22-33-44-55-66-77\n"
text += "• 12345678901234567890 (будет отформатирован)\n\n" text += "• 12345678901234 (будет отформатирован)\n\n"
text += "Формат: XX-XX-XX-XX-XX-XX-XX-XX" text += "Формат: XX-XX-XX-XX-XX-XX-XX"
await callback.message.edit_text( await callback.message.edit_text(
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)) 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_")) @admin_router.callback_query(F.data.startswith("admin_choose_winner_lottery_"))
async def choose_winner_place(callback: CallbackQuery, state: FSMContext): 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)
# ====================== # ======================
# СТАТИСТИКА # СТАТИСТИКА
# ====================== # ======================

View File

@@ -55,8 +55,7 @@ version_path_separator = os
# are written from script.py.mako # are written from script.py.mako
# output_encoding = utf-8 # 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]
# post_write_hooks defines scripts or Python functions that are run # post_write_hooks defines scripts or Python functions that are run

View File

@@ -1,6 +1,6 @@
import os import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker 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 from dotenv import load_dotenv
# Загружаем переменные окружения # Загружаем переменные окружения

10
main.py
View File

@@ -17,6 +17,7 @@ from config import BOT_TOKEN, ADMIN_IDS
from database import async_session_maker, init_db from database import async_session_maker, init_db
from services import UserService, LotteryService, ParticipationService from services import UserService, LotteryService, ParticipationService
from admin_panel import admin_router from admin_panel import admin_router
from account_handlers import account_router
from async_decorators import ( from async_decorators import (
async_user_action, admin_async_action, db_operation, async_user_action, admin_async_action, db_operation,
TaskManagerMiddleware, shutdown_task_manager, TaskManagerMiddleware, shutdown_task_manager,
@@ -575,9 +576,9 @@ async def start_account_setup(callback: CallbackQuery, state: FSMContext):
text = f"💳 **Процедура {action} счёта**\n\n" text = f"💳 **Процедура {action} счёта**\n\n"
text += "Введите номер вашего клиентского счёта в формате:\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 += "📝 **Требования:**\n"
text += "• Ровно 16 цифр\n" text += "• Ровно 14 цифр\n"
text += "• Разделены дефисами через каждые 2 цифры\n" text += "• Разделены дефисами через каждые 2 цифры\n"
text += "• Номер должен быть уникальным\n\n" text += "• Номер должен быть уникальным\n\n"
text += "✉️ Отправьте номер счёта в ответном сообщении" text += "✉️ Отправьте номер счёта в ответном сообщении"
@@ -603,8 +604,8 @@ async def process_account_number(message: Message, state: FSMContext):
if not formatted_number: if not formatted_number:
await message.answer( await message.answer(
"❌ **Некорректный формат номера счёта**\n\n" "❌ **Некорректный формат номера счёта**\n\n"
"Номер должен содержать ровно 16 цифр.\n" "Номер должен содержать ровно 14 цифр.\n"
"Пример правильного формата: `12-34-56-78-90-12-34-56`\n\n" "Пример правильного формата: `12-34-56-78-90-12-34`\n\n"
"Попробуйте ещё раз:", "Попробуйте ещё раз:",
parse_mode="Markdown" parse_mode="Markdown"
) )
@@ -699,6 +700,7 @@ async def main():
await set_commands() await set_commands()
# Подключение роутеров # Подключение роутеров
dp.include_router(account_router) # Роутер для работы со счетами (приоритетный)
dp.include_router(router) dp.include_router(router)
dp.include_router(admin_router) dp.include_router(admin_router)

View File

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

View File

@@ -1,6 +1,6 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime, timezone
from database import Base from database import Base
@@ -13,10 +13,10 @@ class User(Base):
username = Column(String(255)) username = Column(String(255))
first_name = Column(String(255)) first_name = Column(String(255))
last_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) is_admin = Column(Boolean, default=False)
# Клиентский счет в формате: XX-XX-XX-XX-XX-XX-XX-XX (8 пар цифр через дефис) # Клиентский счет в формате: XX-XX-XX-XX-XX-XX-XX (7 пар цифр через дефис)
account_number = Column(String(23), unique=True, nullable=True, index=True) account_number = Column(String(20), unique=True, nullable=True, index=True)
# Связи # Связи
participations = relationship("Participation", back_populates="user") participations = relationship("Participation", back_populates="user")
@@ -32,9 +32,9 @@ class Lottery(Base):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
title = Column(String(500), nullable=False) title = Column(String(500), nullable=False)
description = Column(Text) description = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
start_date = Column(DateTime) start_date = Column(DateTime(timezone=True))
end_date = Column(DateTime) end_date = Column(DateTime(timezone=True))
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
is_completed = Column(Boolean, default=False) is_completed = Column(Boolean, default=False)
prizes = Column(JSON) # Список призов в формате JSON prizes = Column(JSON) # Список призов в формате JSON
@@ -60,15 +60,18 @@ class Participation(Base):
__tablename__ = "participations" __tablename__ = "participations"
id = Column(Integer, primary_key=True) 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) 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") user = relationship("User", back_populates="participations")
lottery = relationship("Lottery", back_populates="participations") lottery = relationship("Lottery", back_populates="participations")
def __repr__(self): def __repr__(self):
if self.account_number:
return f"<Participation(account={self.account_number}, lottery_id={self.lottery_id})>"
return f"<Participation(user_id={self.user_id}, lottery_id={self.lottery_id})>" return f"<Participation(user_id={self.user_id}, lottery_id={self.lottery_id})>"
@@ -78,15 +81,18 @@ class Winner(Base):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False) 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...) place = Column(Integer, nullable=False) # Место (1, 2, 3...)
prize = Column(String(500)) # Описание приза prize = Column(String(500)) # Описание приза
is_manual = Column(Boolean, default=False) # Был ли установлен вручную 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") user = relationship("User")
lottery = relationship("Lottery") lottery = relationship("Lottery")
def __repr__(self): def __repr__(self):
if self.account_number:
return f"<Winner(lottery_id={self.lottery_id}, account={self.account_number}, place={self.place})>"
return f"<Winner(lottery_id={self.lottery_id}, user_id={self.user_id}, place={self.place})>" return f"<Winner(lottery_id={self.lottery_id}, user_id={self.user_id}, place={self.place})>"

View File

@@ -1,6 +1,8 @@
aiogram==3.1.1 # Updated for Python 3.12 compatibility
sqlalchemy==2.0.23 aiogram==3.16.0
alembic==1.8.1 aiohttp>=3.11.0
python-dotenv==1.0.0 sqlalchemy==2.0.36
asyncpg==0.28.0 alembic==1.14.0
aiosqlite==0.17.0 python-dotenv==1.0.1
asyncpg==0.30.0
aiosqlite==0.20.0

View File

@@ -198,22 +198,28 @@ class LotteryService:
return result.scalar_one_or_none() return result.scalar_one_or_none()
@staticmethod @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( query = select(Lottery).where(
select(Lottery) Lottery.is_active == True,
.where(Lottery.is_active == True, Lottery.is_completed == False) Lottery.is_completed == False
.order_by(Lottery.created_at.desc()) ).order_by(Lottery.created_at.desc())
)
if limit:
query = query.limit(limit)
result = await session.execute(query)
return result.scalars().all() return result.scalars().all()
@staticmethod @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( query = select(Lottery).order_by(Lottery.created_at.desc())
select(Lottery)
.order_by(Lottery.created_at.desc()) if limit:
) query = query.limit(limit)
result = await session.execute(query)
return result.scalars().all() return result.scalars().all()
@staticmethod @staticmethod