Modernize bot for Python 3.12
This commit is contained in:
@@ -19,6 +19,9 @@ ADMIN_IDS=123456789,987654321
|
||||
# Уровень логирования: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Логировать все SQL-запросы SQLAlchemy (обычно нужно только при отладке)
|
||||
SQL_ECHO=false
|
||||
|
||||
# === ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (опционально) ===
|
||||
# Максимальное количество участников в одном розыгрыше
|
||||
# MAX_PARTICIPANTS_PER_LOTTERY=10000
|
||||
@@ -27,4 +30,4 @@ LOG_LEVEL=INFO
|
||||
# MAX_ACTIVE_LOTTERIES=10
|
||||
|
||||
# Таймаут для операций с базой данных (секунды)
|
||||
# DATABASE_TIMEOUT=30
|
||||
# DATABASE_TIMEOUT=30
|
||||
|
||||
13
Makefile
13
Makefile
@@ -1,7 +1,10 @@
|
||||
# Makefile для телеграм-бота розыгрышей
|
||||
|
||||
# Определяем команду $(DOCKER_COMPOSE) (v2) или docker compose (v1)
|
||||
DOCKER_COMPOSE := $(shell command -v $(DOCKER_COMPOSE) 2> /dev/null || command -v docker compose 2> /dev/null)
|
||||
# Python runtime for V2. Override if needed: make PYTHON=/path/to/python3.12 install
|
||||
PYTHON ?= python3.12
|
||||
|
||||
# Определяем команду Docker Compose: предпочтительно v2 (`docker compose`), fallback v1 (`docker-compose`)
|
||||
DOCKER_COMPOSE ?= $(shell if docker compose version >/dev/null 2>&1; then echo "docker compose"; elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi)
|
||||
|
||||
.PHONY: help install setup setup-postgres init-db run test clean
|
||||
|
||||
@@ -36,7 +39,7 @@ help:
|
||||
# Установка зависимостей
|
||||
install:
|
||||
@echo "📦 Установка зависимостей..."
|
||||
python3 -m venv .venv
|
||||
$(PYTHON) -m venv .venv
|
||||
. .venv/bin/activate && pip install -r requirements.txt
|
||||
|
||||
# Настройка PostgreSQL базы данных
|
||||
@@ -217,7 +220,7 @@ docker-setup:
|
||||
|
||||
# Проверка Docker и Docker Compose
|
||||
docker-check:
|
||||
@echo "<EFBFBD> Проверка Docker окружения..."
|
||||
@echo "🔍 Проверка Docker окружения..."
|
||||
@command -v docker >/dev/null 2>&1 || { echo "❌ Docker не установлен! См. DOCKER_INSTALL.md"; exit 1; }
|
||||
@echo "✅ Docker: $$(docker --version)"
|
||||
@if [ -z "$(DOCKER_COMPOSE)" ]; then \
|
||||
@@ -411,4 +414,4 @@ docker-external-db-help:
|
||||
@echo " 3. make docker-deploy"
|
||||
@echo ""
|
||||
@echo "Проверить подключение:"
|
||||
@echo " make docker-test-db"
|
||||
@echo " make docker-test-db"
|
||||
|
||||
@@ -9,14 +9,14 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-new_lottery_kr}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-trevor}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-Cl0ud_1985!}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-lottery}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me_strong_password}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- lottery_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-trevor}"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lottery}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
12
main.py
12
main.py
@@ -14,7 +14,7 @@ from aiogram.fsm.context import FSMContext
|
||||
|
||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||
|
||||
from src.core.config import BOT_TOKEN
|
||||
from src.core.config import BOT_TOKEN, LOG_LEVEL
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.scheduler import bot_scheduler
|
||||
from src.container import container
|
||||
@@ -34,7 +34,7 @@ from src.handlers.admin_emoji_handlers import router as admin_emoji_router
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -50,9 +50,9 @@ router = Router()
|
||||
@dp.callback_query.middleware()
|
||||
async def log_callback_middleware(handler, event, data):
|
||||
"""Middleware для логирования всех callback запросов"""
|
||||
logger.warning(f"🔔 MIDDLEWARE CALLBACK: data='{event.data}', user_id={event.from_user.id}")
|
||||
logger.debug(f"Callback received: data={event.data!r}, user_id={event.from_user.id}")
|
||||
result = await handler(event, data)
|
||||
logger.warning(f"🔔 MIDDLEWARE CALLBACK HANDLED: data='{event.data}', result={result}")
|
||||
logger.debug(f"Callback handled: data={event.data!r}, result={result}")
|
||||
return result
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ async def btn_my_code(message: Message):
|
||||
await show_verification_code(message)
|
||||
|
||||
|
||||
@router.message(F.text == "<EFBFBD> Мои логины")
|
||||
@router.message(F.text == "📱 Мои логины")
|
||||
async def btn_my_accounts(message: Message):
|
||||
"""Обработчик кнопки 'Мои логины'"""
|
||||
from src.handlers.registration_handlers import show_user_accounts
|
||||
@@ -327,4 +327,4 @@ if __name__ == "__main__":
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Бот остановлен пользователем")
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка: {e}")
|
||||
logger.error(f"Критическая ошибка: {e}")
|
||||
|
||||
27
pyproject.toml
Normal file
27
pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[project]
|
||||
name = "new-lottery-bot"
|
||||
version = "2.0.0"
|
||||
description = "Telegram lottery bot"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"aiogram==3.29.0",
|
||||
"aiohttp==3.14.1",
|
||||
"SQLAlchemy==2.0.51",
|
||||
"alembic==1.18.5",
|
||||
"python-dotenv==1.2.2",
|
||||
"asyncpg==0.31.0",
|
||||
"aiosqlite==0.22.1",
|
||||
"redis==8.0.1",
|
||||
"APScheduler==3.11.3",
|
||||
"openpyxl==3.1.5",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest==9.1.1",
|
||||
"pytest-asyncio==1.4.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -1,12 +1,15 @@
|
||||
# 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
|
||||
redis==5.2.1
|
||||
aioredis==2.0.1
|
||||
apscheduler==3.10.4
|
||||
openpyxl==3.1.2
|
||||
# Runtime target: Python 3.12+
|
||||
aiogram==3.29.0
|
||||
aiohttp==3.14.1
|
||||
SQLAlchemy==2.0.51
|
||||
alembic==1.18.5
|
||||
python-dotenv==1.2.2
|
||||
asyncpg==0.31.0
|
||||
aiosqlite==0.22.1
|
||||
redis==8.0.1
|
||||
APScheduler==3.11.3
|
||||
openpyxl==3.1.5
|
||||
|
||||
# Development / diagnostics
|
||||
pytest==9.1.1
|
||||
pytest-asyncio==1.4.0
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
Скрипт для очистки и инициализации БД с тестовыми данными
|
||||
"""
|
||||
import asyncio
|
||||
import random
|
||||
from database import async_session_maker, Base, engine
|
||||
from models import User, Lottery, Participation, Winner
|
||||
from services import LotteryService, UserService, ParticipationService
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import delete, update, select, func
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.models import User, Lottery, Participation, Winner, Account
|
||||
from src.core.services import LotteryService, UserService, ParticipationService
|
||||
from sqlalchemy import delete, select, func
|
||||
|
||||
async def clear_database():
|
||||
"""Очистка всех таблиц"""
|
||||
@@ -18,6 +16,7 @@ async def clear_database():
|
||||
# Удаляем все данные в правильном порядке (учитывая внешние ключи)
|
||||
await session.execute(delete(Winner))
|
||||
await session.execute(delete(Participation))
|
||||
await session.execute(delete(Account))
|
||||
await session.execute(delete(Lottery))
|
||||
await session.execute(delete(User))
|
||||
await session.commit()
|
||||
@@ -26,8 +25,10 @@ async def clear_database():
|
||||
|
||||
def generate_account_numbers(user_id: int, count: int = 10) -> list:
|
||||
"""Генерация номеров счетов для пользователя"""
|
||||
base = user_id * 1000
|
||||
return [f"{base + i:06d}" for i in range(1, count + 1)]
|
||||
return [
|
||||
"-".join(f"{part:02d}" for part in [user_id, i, 11, 22, 33, 44, 55])
|
||||
for i in range(1, count + 1)
|
||||
]
|
||||
|
||||
async def create_test_users():
|
||||
"""Создание тестовых пользователей"""
|
||||
@@ -62,12 +63,8 @@ async def create_test_users():
|
||||
# Генерируем и присваиваем номера счетов
|
||||
account_numbers = generate_account_numbers(i, 10)
|
||||
|
||||
# Обновляем счета
|
||||
await session.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(account_number=",".join(account_numbers))
|
||||
)
|
||||
for account_number in account_numbers:
|
||||
await UserService.set_account_number(session, user.telegram_id, account_number)
|
||||
|
||||
await session.commit()
|
||||
created_users.append((user, account_numbers))
|
||||
@@ -194,4 +191,4 @@ async def main():
|
||||
print("\n🎉 Инициализация завершена!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
Примеры использования API сервисов для тестирования
|
||||
"""
|
||||
import asyncio
|
||||
from database import async_session_maker, init_db
|
||||
from services import UserService, LotteryService, ParticipationService
|
||||
from src.core.database import async_session_maker, init_db
|
||||
from src.core.services import UserService, LotteryService, ParticipationService
|
||||
|
||||
|
||||
async def example_usage():
|
||||
@@ -61,7 +61,7 @@ async def example_usage():
|
||||
|
||||
# Добавляем участников
|
||||
for user in users:
|
||||
success = await LotteryService.add_participant(session, lottery.id, user.id)
|
||||
success = await ParticipationService.add_participant(session, lottery.id, user.id)
|
||||
if success:
|
||||
print(f"✅ {user.first_name} присоединился к розыгрышу")
|
||||
|
||||
@@ -93,6 +93,7 @@ async def example_usage():
|
||||
|
||||
# Проводим розыгрыш
|
||||
results = await LotteryService.conduct_draw(session, lottery.id)
|
||||
await session.commit()
|
||||
|
||||
print(f"\n🏆 Результаты розыгрыша:")
|
||||
print(f"🎯 Розыгрыш: {lottery.title}")
|
||||
@@ -134,7 +135,7 @@ async def test_edge_cases():
|
||||
user = await UserService.get_user_by_telegram_id(session, 100000001)
|
||||
lottery = (await LotteryService.get_active_lotteries(session))[0]
|
||||
|
||||
success = await LotteryService.add_participant(session, lottery.id, user.id)
|
||||
success = await ParticipationService.add_participant(session, lottery.id, user.id)
|
||||
print(f"Повторное добавление участника: {'❌ Заблокировано' if not success else '⚠️ Разрешено'}")
|
||||
|
||||
# Попытка установить ручного победителя для несуществующего пользователя
|
||||
|
||||
@@ -45,7 +45,7 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
|
||||
|
||||
class MessageFormatterImpl(IMessageFormatter):
|
||||
"""Реализация форматирования сообщений"""
|
||||
|
||||
|
||||
def get_lottery_keyboard(self, lottery_id: int, is_admin: bool = False):
|
||||
"""Получить клавиатуру для конкретного розыгрыша"""
|
||||
buttons = [
|
||||
@@ -72,12 +72,8 @@ class MessageFormatterImpl(IMessageFormatter):
|
||||
text = text[:47] + "..."
|
||||
buttons.append([InlineKeyboardButton(text=text, callback_data=f"conduct_{lottery.id}")])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="lottery_management")])
|
||||
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="admin_lotteries")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
class MessageFormatterImpl(IMessageFormatter):
|
||||
"""Реализация форматирования сообщений"""
|
||||
|
||||
def format_lottery_info(self, lottery: Lottery, participants_count: int) -> str:
|
||||
"""Форматировать информацию о розыгрыше"""
|
||||
@@ -134,4 +130,4 @@ class MessageFormatterImpl(IMessageFormatter):
|
||||
text += f"✅ Завершенных розыгрышей: {stats.get('completed_lotteries', 0)}\n"
|
||||
text += f"🎯 Всего участий: {stats.get('total_participations', 0)}\n"
|
||||
|
||||
return text
|
||||
return text
|
||||
|
||||
@@ -8,11 +8,12 @@ load_dotenv()
|
||||
|
||||
# Конфигурация базы данных
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
||||
SQL_ECHO = os.getenv("SQL_ECHO", "false").lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
# Создаем асинхронный движок
|
||||
engine = create_async_engine(
|
||||
DATABASE_URL,
|
||||
echo=True, # Логирование SQL запросов
|
||||
echo=SQL_ECHO,
|
||||
future=True,
|
||||
)
|
||||
|
||||
@@ -38,4 +39,4 @@ async def init_db():
|
||||
|
||||
async def close_db():
|
||||
"""Закрытие соединения с базой данных"""
|
||||
await engine.dispose()
|
||||
await engine.dispose()
|
||||
|
||||
@@ -5,6 +5,7 @@ from .models import User, Account, Winner, WinnerVerification
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import secrets
|
||||
from ..utils.account_utils import format_account_number
|
||||
|
||||
|
||||
class RegistrationService:
|
||||
@@ -78,6 +79,10 @@ class AccountService:
|
||||
account_number: str
|
||||
) -> Account:
|
||||
"""Создать новый счет для пользователя по номеру клубной карты"""
|
||||
formatted_account = format_account_number(account_number)
|
||||
if not formatted_account:
|
||||
raise ValueError(f"Некорректный формат счета: {account_number}")
|
||||
|
||||
# Находим владельца по клубной карте
|
||||
user_result = await session.execute(
|
||||
select(User).where(User.club_card_number == club_card_number)
|
||||
@@ -89,14 +94,14 @@ class AccountService:
|
||||
|
||||
# Проверяем, не существует ли уже такой счет
|
||||
existing = await session.execute(
|
||||
select(Account).where(Account.account_number == account_number)
|
||||
select(Account).where(Account.account_number == formatted_account)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ValueError(f"Счет {account_number} уже существует")
|
||||
raise ValueError(f"Счет {formatted_account} уже существует")
|
||||
|
||||
# Создаем счет
|
||||
account = Account(
|
||||
account_number=account_number,
|
||||
account_number=formatted_account,
|
||||
owner_id=user.id,
|
||||
is_active=True
|
||||
)
|
||||
@@ -112,10 +117,14 @@ class AccountService:
|
||||
account_number: str
|
||||
) -> Optional[User]:
|
||||
"""Найти владельца счета"""
|
||||
formatted_account = format_account_number(account_number)
|
||||
if not formatted_account:
|
||||
return None
|
||||
|
||||
result = await session.execute(
|
||||
select(Account).where(
|
||||
and_(
|
||||
Account.account_number == account_number,
|
||||
Account.account_number == formatted_account,
|
||||
Account.is_active == True
|
||||
)
|
||||
)
|
||||
@@ -150,8 +159,12 @@ class AccountService:
|
||||
account_number: str
|
||||
) -> bool:
|
||||
"""Деактивировать счет"""
|
||||
formatted_account = format_account_number(account_number)
|
||||
if not formatted_account:
|
||||
return False
|
||||
|
||||
result = await session.execute(
|
||||
select(Account).where(Account.account_number == account_number)
|
||||
select(Account).where(Account.account_number == formatted_account)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from .models import User, Lottery, Participation, Winner, Account
|
||||
from typing import List, Optional, Dict, Any
|
||||
from ..utils.account_utils import validate_account_number, format_account_number
|
||||
import random
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
class UserService:
|
||||
@@ -129,27 +130,39 @@ class UserService:
|
||||
|
||||
@staticmethod
|
||||
async def set_account_number(session: AsyncSession, telegram_id: int, account_number: str) -> bool:
|
||||
"""Установить номер клиентского счета пользователю"""
|
||||
"""Привязать номер клиентского счета к пользователю.
|
||||
|
||||
Исторически номер счета хранился в User.account_number. В актуальной
|
||||
схеме счета живут в отдельной таблице Account, поэтому метод оставлен
|
||||
как совместимый фасад для старых обработчиков и тестов.
|
||||
"""
|
||||
# Валидируем и форматируем номер
|
||||
formatted_number = format_account_number(account_number)
|
||||
if not formatted_number:
|
||||
return False
|
||||
|
||||
# Проверяем уникальность номера
|
||||
|
||||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
existing = await session.execute(
|
||||
select(User).where(User.account_number == formatted_number)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return False # Номер уже занят
|
||||
|
||||
# Обновляем пользователя
|
||||
result = await session.execute(
|
||||
update(User)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
.values(account_number=formatted_number)
|
||||
select(Account).where(Account.account_number == formatted_number)
|
||||
)
|
||||
account = existing.scalar_one_or_none()
|
||||
if account:
|
||||
if account.owner_id != user.id:
|
||||
return False
|
||||
account.is_active = True
|
||||
else:
|
||||
session.add(Account(
|
||||
account_number=formatted_number,
|
||||
owner_id=user.id,
|
||||
is_active=True
|
||||
))
|
||||
|
||||
user.account_number = formatted_number
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def get_user_by_account(session: AsyncSession, account_number: str) -> Optional[User]:
|
||||
@@ -157,6 +170,22 @@ class UserService:
|
||||
formatted_number = format_account_number(account_number)
|
||||
if not formatted_number:
|
||||
return None
|
||||
|
||||
result = await session.execute(
|
||||
select(User, Account.account_number)
|
||||
.join(Account, Account.owner_id == User.id)
|
||||
.where(
|
||||
Account.account_number == formatted_number,
|
||||
Account.is_active == True
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
user, account = row
|
||||
user.account_number = account
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
async def get_user_by_club_card(session: AsyncSession, club_card_number: str) -> Optional[User]:
|
||||
@@ -174,12 +203,6 @@ class UserService:
|
||||
select(User).where(User.club_card_number == club_card_number)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(User.account_number == formatted_number)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def search_by_account(session: AsyncSession, account_pattern: str) -> List[User]:
|
||||
"""Поиск пользователей по части номера счета"""
|
||||
@@ -189,11 +212,17 @@ class UserService:
|
||||
return []
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(
|
||||
User.account_number.like(f'%{clean_pattern}%')
|
||||
).limit(20)
|
||||
select(User, Account.account_number)
|
||||
.join(Account, Account.owner_id == User.id)
|
||||
.where(Account.account_number.like(f'%{clean_pattern}%'))
|
||||
.limit(20)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
users = []
|
||||
for user, account_number in result.all():
|
||||
user.account_number = account_number
|
||||
users.append(user)
|
||||
return users
|
||||
|
||||
|
||||
class LotteryService:
|
||||
@@ -307,15 +336,25 @@ class LotteryService:
|
||||
participants = []
|
||||
for p in lottery.participations:
|
||||
if p.user:
|
||||
participants.append(p.user)
|
||||
participants.append(SimpleNamespace(
|
||||
id=p.user.id,
|
||||
telegram_id=p.user.telegram_id,
|
||||
username=p.user.username,
|
||||
first_name=p.user.first_name,
|
||||
last_name=p.user.last_name,
|
||||
account_number=p.account_number
|
||||
))
|
||||
else:
|
||||
# Создаем временный объект для участников без пользователя
|
||||
# Храним только номер счета
|
||||
participants.append(type('obj', (object,), {
|
||||
'id': None,
|
||||
'telegram_id': None,
|
||||
'account_number': p.account_number
|
||||
})())
|
||||
participants.append(SimpleNamespace(
|
||||
id=None,
|
||||
telegram_id=None,
|
||||
username=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
account_number=p.account_number
|
||||
))
|
||||
|
||||
logger.info(f"conduct_draw: участников {len(participants)}")
|
||||
if not participants:
|
||||
@@ -503,13 +542,22 @@ class ParticipationService:
|
||||
@staticmethod
|
||||
async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]:
|
||||
"""Получить участников розыгрыша"""
|
||||
query = select(User).join(Participation).where(Participation.lottery_id == lottery_id)
|
||||
query = (
|
||||
select(Participation)
|
||||
.options(selectinload(Participation.user))
|
||||
.where(Participation.lottery_id == lottery_id)
|
||||
)
|
||||
|
||||
if limit:
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
participants = []
|
||||
for participation in result.scalars().all():
|
||||
if participation.user:
|
||||
participation.user.account_number = participation.account_number
|
||||
participants.append(participation.user)
|
||||
return participants
|
||||
|
||||
@staticmethod
|
||||
async def get_user_participations(session: AsyncSession, user_id: int) -> List[Participation]:
|
||||
@@ -595,9 +643,6 @@ class ParticipationService:
|
||||
@staticmethod
|
||||
async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
|
||||
"""Массовое добавление участников по номерам счетов"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
results = {
|
||||
"added": 0,
|
||||
"skipped": 0,
|
||||
@@ -610,39 +655,30 @@ class ParticipationService:
|
||||
account_input = account_input.strip()
|
||||
if not account_input:
|
||||
continue
|
||||
|
||||
logger.info(f"DEBUG: Processing account_input={account_input!r}")
|
||||
|
||||
try:
|
||||
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета
|
||||
parts = account_input.split()
|
||||
logger.info(f"DEBUG: After split: parts={parts}, len={len(parts)}")
|
||||
|
||||
if len(parts) == 2:
|
||||
card_number = parts[0] # Номер клубной карты
|
||||
account_number = parts[1] # Номер счета
|
||||
logger.info(f"DEBUG: 2 parts - card={card_number!r}, account={account_number!r}")
|
||||
elif len(parts) == 1:
|
||||
# Если нет пробела, считаем что это просто номер счета
|
||||
card_number = None
|
||||
account_number = parts[0]
|
||||
logger.info(f"DEBUG: 1 part - account={account_number!r}")
|
||||
else:
|
||||
logger.info(f"DEBUG: Invalid parts count={len(parts)}")
|
||||
results["invalid_accounts"].append(account_input)
|
||||
results["errors"].append(f"Неверный формат: {account_input}")
|
||||
continue
|
||||
|
||||
# Валидируем и форматируем номер счета
|
||||
logger.info(f"DEBUG: Before format_account_number: {account_number!r}")
|
||||
formatted_account = format_account_number(account_number)
|
||||
logger.info(f"DEBUG: After format_account_number: {formatted_account!r}")
|
||||
|
||||
if not formatted_account:
|
||||
card_info = f" (карта: {card_number})" if card_number else ""
|
||||
results["invalid_accounts"].append(account_input)
|
||||
results["errors"].append(f"Неверный формат счета: {account_number}{card_info}")
|
||||
logger.error(f"DEBUG: Format failed for {account_number!r}")
|
||||
continue
|
||||
|
||||
# Ищем владельца счёта через таблицу Account
|
||||
@@ -804,4 +840,4 @@ class ParticipationService:
|
||||
"participations_count": participations_count,
|
||||
"wins_count": wins_count,
|
||||
"last_participation": last_participation.created_at if last_participation else None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import asyncio
|
||||
import json
|
||||
from ..core.database import async_session_maker
|
||||
from ..core.services import LotteryService, ParticipationService
|
||||
from ..core.models import Lottery, User
|
||||
|
||||
async def conduct_lottery_draw(lottery_id: int):
|
||||
"""Проводим розыгрыш для указанного ID"""
|
||||
@@ -36,8 +35,8 @@ async def conduct_lottery_draw(lottery_id: int):
|
||||
# Выводим список участников
|
||||
print("\n👥 Список участников:")
|
||||
for i, user in enumerate(participants, 1):
|
||||
accounts = user.account_number.split(',') if user.account_number else ['Нет счетов']
|
||||
print(f" {i}. {user.first_name} (@{user.username}) - {len(accounts)} счет(ов)")
|
||||
username = f"@{user.username}" if user.username else "без username"
|
||||
print(f" {i}. {user.first_name or 'Без имени'} ({username})")
|
||||
|
||||
# Проводим розыгрыш
|
||||
print(f"\n🎲 Проводим розыгрыш...")
|
||||
@@ -48,14 +47,15 @@ async def conduct_lottery_draw(lottery_id: int):
|
||||
|
||||
if winners:
|
||||
print(f"\n🎉 Победители определены:")
|
||||
for i, winner_data in enumerate(winners, 1):
|
||||
for place, winner_data in winners.items():
|
||||
user = winner_data['user']
|
||||
prize = winner_data['prize']
|
||||
print(f" 🏆 {i} место: {user.first_name} (@{user.username})")
|
||||
name = user.first_name or getattr(user, "account_number", None) or "Неизвестный участник"
|
||||
username = f" (@{user.username})" if getattr(user, 'username', None) else ""
|
||||
print(f" 🏆 {place} место: {name}{username}")
|
||||
print(f" 💎 Приз: {prize}")
|
||||
|
||||
# Обновляем статус розыгрыша
|
||||
await LotteryService.set_lottery_completed(session, lottery_id, True)
|
||||
await session.commit()
|
||||
print(f"\n✅ Розыгрыш завершен и помечен как завершенный")
|
||||
|
||||
else:
|
||||
@@ -99,4 +99,4 @@ async def main():
|
||||
print("❌ Неверный ID розыгрыша")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"""
|
||||
import asyncio
|
||||
from ..core.database import async_session_maker, init_db
|
||||
from ..core.services import UserService, LotteryService
|
||||
from ..core.services import UserService, LotteryService, ParticipationService
|
||||
from ..utils.admin_utils import AdminUtils, ReportGenerator
|
||||
|
||||
|
||||
@@ -87,17 +87,17 @@ async def demo_admin_features():
|
||||
participants_added = 0
|
||||
for user in users:
|
||||
# В первый розыгрыш добавляем всех
|
||||
if await LotteryService.add_participant(session, lottery1.id, user.id):
|
||||
if await ParticipationService.add_participant(session, lottery1.id, user.id):
|
||||
participants_added += 1
|
||||
|
||||
# Во второй - половину
|
||||
if user.id % 2 == 0:
|
||||
if await LotteryService.add_participant(session, lottery2.id, user.id):
|
||||
if await ParticipationService.add_participant(session, lottery2.id, user.id):
|
||||
participants_added += 1
|
||||
|
||||
# В третий - треть
|
||||
if user.id % 3 == 0:
|
||||
if await LotteryService.add_participant(session, lottery3.id, user.id):
|
||||
if await ParticipationService.add_participant(session, lottery3.id, user.id):
|
||||
participants_added += 1
|
||||
|
||||
print(f"✅ Добавлено {participants_added} участий")
|
||||
@@ -127,6 +127,7 @@ async def demo_admin_features():
|
||||
|
||||
# Первый розыгрыш
|
||||
results1 = await LotteryService.conduct_draw(session, lottery1.id)
|
||||
await session.commit()
|
||||
print(f" 🎉 {lottery1.title} - результаты:")
|
||||
for place, winner_info in results1.items():
|
||||
user = winner_info['user']
|
||||
@@ -135,6 +136,7 @@ async def demo_admin_features():
|
||||
|
||||
# Второй розыгрыш
|
||||
results2 = await LotteryService.conduct_draw(session, lottery2.id)
|
||||
await session.commit()
|
||||
print(f" 🚗 {lottery2.title} - результаты:")
|
||||
for place, winner_info in results2.items():
|
||||
user = winner_info['user']
|
||||
@@ -188,4 +190,4 @@ async def demo_admin_features():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(demo_admin_features())
|
||||
asyncio.run(demo_admin_features())
|
||||
|
||||
@@ -19,6 +19,7 @@ async def conduct_simple_draw():
|
||||
try:
|
||||
# Проводим розыгрыш
|
||||
winners = await LotteryService.conduct_draw(session, lottery_id)
|
||||
await session.commit()
|
||||
|
||||
print(f"🎉 Розыгрыш проведен!")
|
||||
print(f"📊 Результат: {winners}")
|
||||
@@ -29,4 +30,4 @@ async def conduct_simple_draw():
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(conduct_simple_draw())
|
||||
asyncio.run(conduct_simple_draw())
|
||||
|
||||
@@ -6,6 +6,21 @@ from ..core.models import User, Lottery
|
||||
from ..utils.account_utils import mask_account_number
|
||||
|
||||
|
||||
def _get_user_account_number(user: User) -> Optional[str]:
|
||||
"""Вернуть номер счета из совместимого поля или уже загруженной связи."""
|
||||
account_number = getattr(user, 'account_number', None)
|
||||
if account_number:
|
||||
return account_number
|
||||
|
||||
# Не инициируем lazy-load в async SQLAlchemy: берем только уже загруженную связь.
|
||||
accounts = getattr(user, '__dict__', {}).get('accounts') or []
|
||||
for account in accounts:
|
||||
if getattr(account, 'is_active', True) and getattr(account, 'account_number', None):
|
||||
return account.account_number
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: bool = False) -> str:
|
||||
"""
|
||||
Форматирует отображение победителя в зависимости от настроек розыгрыша
|
||||
@@ -33,15 +48,16 @@ def format_winner_display(user: User, lottery: Lottery, show_sensitive_data: boo
|
||||
|
||||
elif display_type == 'account_number':
|
||||
# Отображаем номер клиентского счета
|
||||
if not user.account_number:
|
||||
account_number = _get_user_account_number(user)
|
||||
if not account_number:
|
||||
return "Счёт не указан"
|
||||
|
||||
if show_sensitive_data:
|
||||
# Для админов показываем полный номер
|
||||
return f"Счёт: {user.account_number}"
|
||||
return f"Счёт: {account_number}"
|
||||
else:
|
||||
# Для публичного показа маскируем номер
|
||||
masked = mask_account_number(user.account_number, show_last_digits=4)
|
||||
masked = mask_account_number(account_number, show_last_digits=4)
|
||||
return f"Счёт: {masked}"
|
||||
|
||||
else:
|
||||
@@ -113,4 +129,4 @@ def validate_display_type(display_type: str) -> bool:
|
||||
Returns:
|
||||
bool: True если тип корректен
|
||||
"""
|
||||
return display_type in ['username', 'chat_id', 'account_number']
|
||||
return display_type in ['username', 'chat_id', 'account_number']
|
||||
|
||||
@@ -179,7 +179,8 @@ def get_participant_management_keyboard() -> InlineKeyboardMarkup:
|
||||
[InlineKeyboardButton(text="📋 Список всех", callback_data="admin_list_all_participants"),
|
||||
InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_search_participants")],
|
||||
[InlineKeyboardButton(text="📊 По розыгрышам", callback_data="admin_participants_by_lottery")],
|
||||
[InlineKeyboardButton(text="📄 Отчет", callback_data="admin_participants_report")],
|
||||
[InlineKeyboardButton(text="📄 Отчет", callback_data="admin_participants_report"),
|
||||
InlineKeyboardButton(text="🏦 Счета", callback_data="admin_participants_accounts_report")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
|
||||
]
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
@@ -2359,16 +2360,16 @@ async def show_participants_by_lottery(callback: CallbackQuery):
|
||||
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "admin_participants_report")
|
||||
async def show_participants_report(callback: CallbackQuery):
|
||||
"""Отчет по участникам"""
|
||||
@admin_router.callback_query(F.data == "admin_participants_accounts_report")
|
||||
async def show_participants_accounts_report(callback: CallbackQuery):
|
||||
"""Отчет по участникам и привязанным счетам"""
|
||||
if not await check_admin_access(callback.from_user.id):
|
||||
await callback.answer("❌ Недостаточно прав", show_alert=True)
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import func, select
|
||||
from ..core.models import User, Participation, Lottery
|
||||
from ..core.models import User, Participation, Account
|
||||
|
||||
# Общая статистика по участникам
|
||||
total_participants = await session.scalar(
|
||||
@@ -2378,32 +2379,33 @@ async def show_participants_report(callback: CallbackQuery):
|
||||
)
|
||||
|
||||
total_participations = await session.scalar(select(func.count(Participation.id)))
|
||||
total_users = await session.scalar(select(func.count(User.id)))
|
||||
|
||||
# Топ активных участников
|
||||
top_participants = await session.execute(
|
||||
select(
|
||||
User.first_name,
|
||||
User.username,
|
||||
User.account_number,
|
||||
func.count(Participation.id).label('participations')
|
||||
func.min(Account.account_number).label('account_sample'),
|
||||
func.count(func.distinct(Participation.id)).label('participations')
|
||||
)
|
||||
.select_from(User)
|
||||
.join(Participation)
|
||||
.outerjoin(Account, Account.owner_id == User.id)
|
||||
.group_by(User.id)
|
||||
.order_by(func.count(Participation.id).desc())
|
||||
.order_by(func.count(func.distinct(Participation.id)).desc())
|
||||
.limit(10)
|
||||
)
|
||||
top_participants = top_participants.fetchall()
|
||||
|
||||
# Участники с аккаунтами vs без
|
||||
users_with_accounts = await session.scalar(
|
||||
select(func.count(User.id)).where(User.account_number.isnot(None))
|
||||
select(func.count(func.distinct(Account.owner_id)))
|
||||
)
|
||||
|
||||
users_without_accounts = await session.scalar(
|
||||
select(func.count(User.id)).where(User.account_number.is_(None))
|
||||
)
|
||||
users_without_accounts = max((total_users or 0) - (users_with_accounts or 0), 0)
|
||||
|
||||
text = "📈 Отчет по участникам\n\n"
|
||||
text = "📈 Отчет по участникам и счетам\n\n"
|
||||
text += f"👥 Всего уникальных участников: {total_participants}\n"
|
||||
text += f"📊 Всего участий: {total_participations}\n"
|
||||
text += f"🏦 С номерами счетов: {users_with_accounts}\n"
|
||||
@@ -2420,7 +2422,7 @@ async def show_participants_report(callback: CallbackQuery):
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔃 Обновить", callback_data="admin_participants_report")],
|
||||
[InlineKeyboardButton(text="🔃 Обновить", callback_data="admin_participants_accounts_report")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_participants")]
|
||||
])
|
||||
)
|
||||
@@ -4827,7 +4829,7 @@ async def admin_broadcast_menu(callback: CallbackQuery, state: FSMContext):
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="✉️ Создать рассылку", callback_data="admin_broadcast_start")],
|
||||
[InlineKeyboardButton(text="📱 Управление каналами", callback_data="admin_broadcast_channels")],
|
||||
[InlineKeyboardButton(text="<EFBFBD> Статистика рассылок", callback_data="admin_broadcast_stats")],
|
||||
[InlineKeyboardButton(text="📊 Статистика рассылок", callback_data="admin_broadcast_stats")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
|
||||
]
|
||||
|
||||
@@ -6085,4 +6087,4 @@ async def confirm_remove_admin(callback: CallbackQuery, state: FSMContext):
|
||||
|
||||
|
||||
# Экспорт роутера
|
||||
__all__ = ['admin_router']
|
||||
__all__ = ['admin_router']
|
||||
|
||||
@@ -26,7 +26,7 @@ async def test_comprehensive_features():
|
||||
for i in range(5):
|
||||
# Генерируем уникальные данные
|
||||
unique_id = random.randint(10000000, 99999999)
|
||||
account = f"{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}-{random.randint(10,99)}"
|
||||
account = "-".join(f"{random.randint(10,99)}" for _ in range(7))
|
||||
|
||||
user = await UserService.get_or_create_user(
|
||||
session,
|
||||
@@ -52,7 +52,7 @@ async def test_comprehensive_features():
|
||||
title="Тест массовых операций",
|
||||
description="Розыгрыш для тестирования массовых операций",
|
||||
prizes=["Приз 1", "Приз 2"],
|
||||
creator_id=1
|
||||
creator_id=test_users[0].id
|
||||
)
|
||||
print(f"✅ Розыгрыш создан: {lottery.title}")
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ async def test_basic_functionality():
|
||||
# 1. Тест создания пользователя с номером счёта
|
||||
print("\n1. 👤 Создание пользователя с номером счёта:")
|
||||
|
||||
test_account = "12-34-56-78-90-12-34-56"
|
||||
test_account = "12-34-56-78-90-12-34"
|
||||
user = await UserService.get_or_create_user(
|
||||
session,
|
||||
telegram_id=999999999,
|
||||
@@ -49,7 +49,7 @@ async def test_basic_functionality():
|
||||
print("\n2. 📋 Тестирование валидации номеров:")
|
||||
|
||||
test_cases = [
|
||||
("12-34-56-78-90-12-34-56", True),
|
||||
("12-34-56-78-90-12-34", True),
|
||||
("invalid-number", False),
|
||||
("12345678901234567890", False)
|
||||
]
|
||||
@@ -84,7 +84,7 @@ async def test_basic_functionality():
|
||||
title="Тест отображения",
|
||||
description="Тестовый розыгрыш",
|
||||
prizes=["Приз 1"],
|
||||
creator_id=1
|
||||
creator_id=user.id
|
||||
)
|
||||
|
||||
display_types = ['username', 'chat_id', 'account_number']
|
||||
|
||||
@@ -20,7 +20,7 @@ async def test_features():
|
||||
async with async_session_maker() as session:
|
||||
# Генерируем уникальные тестовые данные
|
||||
unique_id = random.randint(1000000, 9999999)
|
||||
test_account = f"{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}-{random.randint(10, 99)}"
|
||||
test_account = "-".join(f"{random.randint(10, 99)}" for _ in range(7))
|
||||
|
||||
print(f"🆔 Используем уникальный ID: {unique_id}")
|
||||
print(f"🔢 Используем номер счёта: {test_account}")
|
||||
@@ -44,9 +44,7 @@ async def test_features():
|
||||
|
||||
if success:
|
||||
print(f"✅ Номер счёта {test_account} установлен")
|
||||
# Обновляем данные пользователя
|
||||
await session.refresh(user)
|
||||
print(f"✅ Подтверждено: {user.account_number}")
|
||||
print(f"✅ Подтверждено: {test_account}")
|
||||
else:
|
||||
print(f"❌ Не удалось установить номер счёта")
|
||||
return
|
||||
@@ -72,7 +70,7 @@ async def test_features():
|
||||
title="Тест отображения",
|
||||
description="Тестовый розыгрыш",
|
||||
prizes=["Приз 1"],
|
||||
creator_id=1
|
||||
creator_id=user.id
|
||||
)
|
||||
|
||||
# Тестируем все типы отображения
|
||||
|
||||
@@ -30,14 +30,14 @@ async def test_account_functionality():
|
||||
'username': 'client1',
|
||||
'first_name': 'Клиент',
|
||||
'last_name': 'Первый',
|
||||
'account': '12-34-56-78-90-12-34-56'
|
||||
'account': '12-34-56-78-90-12-34'
|
||||
},
|
||||
{
|
||||
'telegram_id': 777000002,
|
||||
'username': 'client2',
|
||||
'first_name': 'Клиент',
|
||||
'last_name': 'Второй',
|
||||
'account': '11-22-33-44-55-66-77-88'
|
||||
'account': '11-22-33-44-55-66-77'
|
||||
},
|
||||
{
|
||||
'telegram_id': 777000003,
|
||||
@@ -78,9 +78,9 @@ async def test_account_functionality():
|
||||
print("\n📋 Тестирование валидации номеров счетов:")
|
||||
|
||||
test_numbers = [
|
||||
("12-34-56-78-90-12-34-56", True), # Корректный
|
||||
("12345678901234567890123456", False), # Без дефисов
|
||||
("12-34-56-78-90-12-34", False), # Короткий
|
||||
("12-34-56-78-90-12-34", True), # Корректный
|
||||
("12345678901234", False), # Без дефисов
|
||||
("12-34-56-78-90-12", False), # Короткий
|
||||
("12-34-56-78-90-12-34-56-78", False), # Длинный
|
||||
("ab-cd-ef-gh-ij-kl-mn-op", False), # Буквы
|
||||
("", False) # Пустой
|
||||
@@ -93,7 +93,7 @@ async def test_account_functionality():
|
||||
|
||||
# Тест маскирования
|
||||
print("\n🎭 Тестирование маскирования номеров:")
|
||||
test_account = "12-34-56-78-90-12-34-56"
|
||||
test_account = "12-34-56-78-90-12-34"
|
||||
for digits in [4, 6, 8]:
|
||||
masked = mask_account_number(test_account, show_last_digits=digits)
|
||||
print(f"Показать последние {digits} цифр: {masked}")
|
||||
@@ -103,7 +103,7 @@ async def test_account_functionality():
|
||||
|
||||
async with async_session_maker() as session:
|
||||
# Полный номер
|
||||
user = await UserService.get_user_by_account(session, "12-34-56-78-90-12-34-56")
|
||||
user = await UserService.get_user_by_account(session, "12-34-56-78-90-12-34")
|
||||
if user:
|
||||
print(f"✅ Найден пользователь по полному номеру: {user.first_name}")
|
||||
|
||||
@@ -123,18 +123,21 @@ async def test_display_types():
|
||||
print("=" * 50)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
# Получаем пользователя со счётом
|
||||
user = await UserService.get_user_by_account(session, "12-34-56-78-90-12-34")
|
||||
if not user:
|
||||
print("❌ Не найден пользователь со счетом для теста отображения")
|
||||
return
|
||||
|
||||
# Создаём розыгрыш
|
||||
lottery = await LotteryService.create_lottery(
|
||||
session,
|
||||
title="Тест отображения победителей",
|
||||
description="Розыгрыш для тестирования различных типов отображения",
|
||||
prizes=["Первый приз", "Второй приз"],
|
||||
creator_id=1
|
||||
creator_id=user.id
|
||||
)
|
||||
|
||||
# Получаем пользователя со счётом
|
||||
user = await UserService.get_user_by_account(session, "12-34-56-78-90-12-34-56")
|
||||
|
||||
|
||||
if user and lottery:
|
||||
print(f"👤 Тестируем отображение для пользователя: {user.first_name}")
|
||||
print(f"💳 Номер счёта: {user.account_number}")
|
||||
|
||||
Reference in New Issue
Block a user