Modernize bot for Python 3.12

This commit is contained in:
Андрей Цой
2026-07-01 18:24:16 +09:00
parent 733298bf06
commit 3906398136
21 changed files with 263 additions and 161 deletions

View File

@@ -19,6 +19,9 @@ ADMIN_IDS=123456789,987654321
# Уровень логирования: DEBUG, INFO, WARNING, ERROR, CRITICAL # Уровень логирования: DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Логировать все SQL-запросы SQLAlchemy (обычно нужно только при отладке)
SQL_ECHO=false
# === ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (опционально) === # === ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (опционально) ===
# Максимальное количество участников в одном розыгрыше # Максимальное количество участников в одном розыгрыше
# MAX_PARTICIPANTS_PER_LOTTERY=10000 # MAX_PARTICIPANTS_PER_LOTTERY=10000

View File

@@ -1,7 +1,10 @@
# Makefile для телеграм-бота розыгрышей # Makefile для телеграм-бота розыгрышей
# Определяем команду $(DOCKER_COMPOSE) (v2) или docker compose (v1) # Python runtime for V2. Override if needed: make PYTHON=/path/to/python3.12 install
DOCKER_COMPOSE := $(shell command -v $(DOCKER_COMPOSE) 2> /dev/null || command -v docker compose 2> /dev/null) 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 .PHONY: help install setup setup-postgres init-db run test clean
@@ -36,7 +39,7 @@ help:
# Установка зависимостей # Установка зависимостей
install: install:
@echo "📦 Установка зависимостей..." @echo "📦 Установка зависимостей..."
python3 -m venv .venv $(PYTHON) -m venv .venv
. .venv/bin/activate && pip install -r requirements.txt . .venv/bin/activate && pip install -r requirements.txt
# Настройка PostgreSQL базы данных # Настройка PostgreSQL базы данных
@@ -217,7 +220,7 @@ docker-setup:
# Проверка Docker и Docker Compose # Проверка Docker и Docker Compose
docker-check: docker-check:
@echo "<EFBFBD> Проверка Docker окружения..." @echo "🔍 Проверка Docker окружения..."
@command -v docker >/dev/null 2>&1 || { echo "❌ Docker не установлен! См. DOCKER_INSTALL.md"; exit 1; } @command -v docker >/dev/null 2>&1 || { echo "❌ Docker не установлен! См. DOCKER_INSTALL.md"; exit 1; }
@echo "✅ Docker: $$(docker --version)" @echo "✅ Docker: $$(docker --version)"
@if [ -z "$(DOCKER_COMPOSE)" ]; then \ @if [ -z "$(DOCKER_COMPOSE)" ]; then \

View File

@@ -9,14 +9,14 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-new_lottery_kr} POSTGRES_DB: ${POSTGRES_DB:-new_lottery_kr}
POSTGRES_USER: ${POSTGRES_USER:-trevor} POSTGRES_USER: ${POSTGRES_USER:-lottery}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-Cl0ud_1985!} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me_strong_password}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
networks: networks:
- lottery_network - lottery_network
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-trevor}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lottery}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5

10
main.py
View File

@@ -14,7 +14,7 @@ from aiogram.fsm.context import FSMContext
from src.filters.case_insensitive import CaseInsensitiveCommand 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.database import async_session_maker
from src.core.scheduler import bot_scheduler from src.core.scheduler import bot_scheduler
from src.container import container from src.container import container
@@ -34,7 +34,7 @@ from src.handlers.admin_emoji_handlers import router as admin_emoji_router
# Настройка логирования # Настройка логирования
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -50,9 +50,9 @@ router = Router()
@dp.callback_query.middleware() @dp.callback_query.middleware()
async def log_callback_middleware(handler, event, data): async def log_callback_middleware(handler, event, data):
"""Middleware для логирования всех callback запросов""" """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) 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 return result
@@ -179,7 +179,7 @@ async def btn_my_code(message: Message):
await show_verification_code(message) await show_verification_code(message)
@router.message(F.text == "<EFBFBD> Мои логины") @router.message(F.text == "📱 Мои логины")
async def btn_my_accounts(message: Message): async def btn_my_accounts(message: Message):
"""Обработчик кнопки 'Мои логины'""" """Обработчик кнопки 'Мои логины'"""
from src.handlers.registration_handlers import show_user_accounts from src.handlers.registration_handlers import show_user_accounts

27
pyproject.toml Normal file
View 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"]

View File

@@ -1,12 +1,15 @@
# Updated for Python 3.12 compatibility # Runtime target: Python 3.12+
aiogram==3.16.0 aiogram==3.29.0
aiohttp>=3.11.0 aiohttp==3.14.1
sqlalchemy==2.0.36 SQLAlchemy==2.0.51
alembic==1.14.0 alembic==1.18.5
python-dotenv==1.0.1 python-dotenv==1.2.2
asyncpg==0.30.0 asyncpg==0.31.0
aiosqlite==0.20.0 aiosqlite==0.22.1
redis==5.2.1 redis==8.0.1
aioredis==2.0.1 APScheduler==3.11.3
apscheduler==3.10.4 openpyxl==3.1.5
openpyxl==3.1.2
# Development / diagnostics
pytest==9.1.1
pytest-asyncio==1.4.0

View File

@@ -3,12 +3,10 @@
Скрипт для очистки и инициализации БД с тестовыми данными Скрипт для очистки и инициализации БД с тестовыми данными
""" """
import asyncio import asyncio
import random from src.core.database import async_session_maker
from database import async_session_maker, Base, engine from src.core.models import User, Lottery, Participation, Winner, Account
from models import User, Lottery, Participation, Winner from src.core.services import LotteryService, UserService, ParticipationService
from services import LotteryService, UserService, ParticipationService from sqlalchemy import delete, select, func
from datetime import datetime, timedelta
from sqlalchemy import delete, update, select, func
async def clear_database(): async def clear_database():
"""Очистка всех таблиц""" """Очистка всех таблиц"""
@@ -18,6 +16,7 @@ async def clear_database():
# Удаляем все данные в правильном порядке (учитывая внешние ключи) # Удаляем все данные в правильном порядке (учитывая внешние ключи)
await session.execute(delete(Winner)) await session.execute(delete(Winner))
await session.execute(delete(Participation)) await session.execute(delete(Participation))
await session.execute(delete(Account))
await session.execute(delete(Lottery)) await session.execute(delete(Lottery))
await session.execute(delete(User)) await session.execute(delete(User))
await session.commit() await session.commit()
@@ -26,8 +25,10 @@ async def clear_database():
def generate_account_numbers(user_id: int, count: int = 10) -> list: def generate_account_numbers(user_id: int, count: int = 10) -> list:
"""Генерация номеров счетов для пользователя""" """Генерация номеров счетов для пользователя"""
base = user_id * 1000 return [
return [f"{base + i:06d}" for i in range(1, count + 1)] "-".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(): async def create_test_users():
"""Создание тестовых пользователей""" """Создание тестовых пользователей"""
@@ -62,12 +63,8 @@ async def create_test_users():
# Генерируем и присваиваем номера счетов # Генерируем и присваиваем номера счетов
account_numbers = generate_account_numbers(i, 10) account_numbers = generate_account_numbers(i, 10)
# Обновляем счета for account_number in account_numbers:
await session.execute( await UserService.set_account_number(session, user.telegram_id, account_number)
update(User)
.where(User.id == user.id)
.values(account_number=",".join(account_numbers))
)
await session.commit() await session.commit()
created_users.append((user, account_numbers)) created_users.append((user, account_numbers))

View File

@@ -2,8 +2,8 @@
Примеры использования API сервисов для тестирования Примеры использования API сервисов для тестирования
""" """
import asyncio import asyncio
from database import async_session_maker, init_db from src.core.database import async_session_maker, init_db
from services import UserService, LotteryService, ParticipationService from src.core.services import UserService, LotteryService, ParticipationService
async def example_usage(): async def example_usage():
@@ -61,7 +61,7 @@ async def example_usage():
# Добавляем участников # Добавляем участников
for user in users: 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: if success:
print(f"{user.first_name} присоединился к розыгрышу") print(f"{user.first_name} присоединился к розыгрышу")
@@ -93,6 +93,7 @@ async def example_usage():
# Проводим розыгрыш # Проводим розыгрыш
results = await LotteryService.conduct_draw(session, lottery.id) results = await LotteryService.conduct_draw(session, lottery.id)
await session.commit()
print(f"\n🏆 Результаты розыгрыша:") print(f"\n🏆 Результаты розыгрыша:")
print(f"🎯 Розыгрыш: {lottery.title}") print(f"🎯 Розыгрыш: {lottery.title}")
@@ -134,7 +135,7 @@ async def test_edge_cases():
user = await UserService.get_user_by_telegram_id(session, 100000001) user = await UserService.get_user_by_telegram_id(session, 100000001)
lottery = (await LotteryService.get_active_lotteries(session))[0] 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 '⚠️ Разрешено'}") print(f"Повторное добавление участника: {'❌ Заблокировано' if not success else '⚠️ Разрешено'}")
# Попытка установить ручного победителя для несуществующего пользователя # Попытка установить ручного победителя для несуществующего пользователя

View File

@@ -72,13 +72,9 @@ class MessageFormatterImpl(IMessageFormatter):
text = text[:47] + "..." text = text[:47] + "..."
buttons.append([InlineKeyboardButton(text=text, callback_data=f"conduct_{lottery.id}")]) 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) return InlineKeyboardMarkup(inline_keyboard=buttons)
class MessageFormatterImpl(IMessageFormatter):
"""Реализация форматирования сообщений"""
def format_lottery_info(self, lottery: Lottery, participants_count: int) -> str: def format_lottery_info(self, lottery: Lottery, participants_count: int) -> str:
"""Форматировать информацию о розыгрыше""" """Форматировать информацию о розыгрыше"""
text = f"🎲 **{lottery.title}**\n\n" text = f"🎲 **{lottery.title}**\n\n"

View File

@@ -8,11 +8,12 @@ load_dotenv()
# Конфигурация базы данных # Конфигурация базы данных
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db") 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( engine = create_async_engine(
DATABASE_URL, DATABASE_URL,
echo=True, # Логирование SQL запросов echo=SQL_ECHO,
future=True, future=True,
) )

View File

@@ -5,6 +5,7 @@ from .models import User, Account, Winner, WinnerVerification
from typing import Optional, List from typing import Optional, List
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import secrets import secrets
from ..utils.account_utils import format_account_number
class RegistrationService: class RegistrationService:
@@ -78,6 +79,10 @@ class AccountService:
account_number: str account_number: str
) -> Account: ) -> Account:
"""Создать новый счет для пользователя по номеру клубной карты""" """Создать новый счет для пользователя по номеру клубной карты"""
formatted_account = format_account_number(account_number)
if not formatted_account:
raise ValueError(f"Некорректный формат счета: {account_number}")
# Находим владельца по клубной карте # Находим владельца по клубной карте
user_result = await session.execute( user_result = await session.execute(
select(User).where(User.club_card_number == club_card_number) select(User).where(User.club_card_number == club_card_number)
@@ -89,14 +94,14 @@ class AccountService:
# Проверяем, не существует ли уже такой счет # Проверяем, не существует ли уже такой счет
existing = await session.execute( 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(): if existing.scalar_one_or_none():
raise ValueError(f"Счет {account_number} уже существует") raise ValueError(f"Счет {formatted_account} уже существует")
# Создаем счет # Создаем счет
account = Account( account = Account(
account_number=account_number, account_number=formatted_account,
owner_id=user.id, owner_id=user.id,
is_active=True is_active=True
) )
@@ -112,10 +117,14 @@ class AccountService:
account_number: str account_number: str
) -> Optional[User]: ) -> Optional[User]:
"""Найти владельца счета""" """Найти владельца счета"""
formatted_account = format_account_number(account_number)
if not formatted_account:
return None
result = await session.execute( result = await session.execute(
select(Account).where( select(Account).where(
and_( and_(
Account.account_number == account_number, Account.account_number == formatted_account,
Account.is_active == True Account.is_active == True
) )
) )
@@ -150,8 +159,12 @@ class AccountService:
account_number: str account_number: str
) -> bool: ) -> bool:
"""Деактивировать счет""" """Деактивировать счет"""
formatted_account = format_account_number(account_number)
if not formatted_account:
return False
result = await session.execute( 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() account = result.scalar_one_or_none()

View File

@@ -5,6 +5,7 @@ from .models import User, Lottery, Participation, Winner, Account
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from ..utils.account_utils import validate_account_number, format_account_number from ..utils.account_utils import validate_account_number, format_account_number
import random import random
from types import SimpleNamespace
class UserService: class UserService:
@@ -129,27 +130,39 @@ class UserService:
@staticmethod @staticmethod
async def set_account_number(session: AsyncSession, telegram_id: int, account_number: str) -> bool: 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) formatted_number = format_account_number(account_number)
if not formatted_number: if not formatted_number:
return False return False
# Проверяем уникальность номера user = await UserService.get_user_by_telegram_id(session, telegram_id)
existing = await session.execute( if not user:
select(User).where(User.account_number == formatted_number) return False
)
if existing.scalar_one_or_none():
return False # Номер уже занят
# Обновляем пользователя existing = await session.execute(
result = await session.execute( select(Account).where(Account.account_number == formatted_number)
update(User)
.where(User.telegram_id == telegram_id)
.values(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() await session.commit()
return result.rowcount > 0 return True
@staticmethod @staticmethod
async def get_user_by_account(session: AsyncSession, account_number: str) -> Optional[User]: async def get_user_by_account(session: AsyncSession, account_number: str) -> Optional[User]:
@@ -158,6 +171,22 @@ class UserService:
if not formatted_number: if not formatted_number:
return None 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 @staticmethod
async def get_user_by_club_card(session: AsyncSession, club_card_number: str) -> Optional[User]: 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) select(User).where(User.club_card_number == club_card_number)
) )
return result.scalar_one_or_none() 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 @staticmethod
async def search_by_account(session: AsyncSession, account_pattern: str) -> List[User]: async def search_by_account(session: AsyncSession, account_pattern: str) -> List[User]:
"""Поиск пользователей по части номера счета""" """Поиск пользователей по части номера счета"""
@@ -189,11 +212,17 @@ class UserService:
return [] return []
result = await session.execute( result = await session.execute(
select(User).where( select(User, Account.account_number)
User.account_number.like(f'%{clean_pattern}%') .join(Account, Account.owner_id == User.id)
).limit(20) .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: class LotteryService:
@@ -307,15 +336,25 @@ class LotteryService:
participants = [] participants = []
for p in lottery.participations: for p in lottery.participations:
if p.user: 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: else:
# Создаем временный объект для участников без пользователя # Создаем временный объект для участников без пользователя
# Храним только номер счета # Храним только номер счета
participants.append(type('obj', (object,), { participants.append(SimpleNamespace(
'id': None, id=None,
'telegram_id': None, telegram_id=None,
'account_number': p.account_number username=None,
})()) first_name=None,
last_name=None,
account_number=p.account_number
))
logger.info(f"conduct_draw: участников {len(participants)}") logger.info(f"conduct_draw: участников {len(participants)}")
if not participants: if not participants:
@@ -503,13 +542,22 @@ class ParticipationService:
@staticmethod @staticmethod
async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]: 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: if limit:
query = query.offset(offset).limit(limit) query = query.offset(offset).limit(limit)
result = await session.execute(query) 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 @staticmethod
async def get_user_participations(session: AsyncSession, user_id: int) -> List[Participation]: async def get_user_participations(session: AsyncSession, user_id: int) -> List[Participation]:
@@ -595,9 +643,6 @@ class ParticipationService:
@staticmethod @staticmethod
async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]: 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 = { results = {
"added": 0, "added": 0,
"skipped": 0, "skipped": 0,
@@ -611,38 +656,29 @@ class ParticipationService:
if not account_input: if not account_input:
continue continue
logger.info(f"DEBUG: Processing account_input={account_input!r}")
try: try:
# Разделяем по пробелу: левая часть - номер карты, правая - номер счета # Разделяем по пробелу: левая часть - номер карты, правая - номер счета
parts = account_input.split() parts = account_input.split()
logger.info(f"DEBUG: After split: parts={parts}, len={len(parts)}")
if len(parts) == 2: if len(parts) == 2:
card_number = parts[0] # Номер клубной карты card_number = parts[0] # Номер клубной карты
account_number = parts[1] # Номер счета account_number = parts[1] # Номер счета
logger.info(f"DEBUG: 2 parts - card={card_number!r}, account={account_number!r}")
elif len(parts) == 1: elif len(parts) == 1:
# Если нет пробела, считаем что это просто номер счета # Если нет пробела, считаем что это просто номер счета
card_number = None card_number = None
account_number = parts[0] account_number = parts[0]
logger.info(f"DEBUG: 1 part - account={account_number!r}")
else: else:
logger.info(f"DEBUG: Invalid parts count={len(parts)}")
results["invalid_accounts"].append(account_input) results["invalid_accounts"].append(account_input)
results["errors"].append(f"Неверный формат: {account_input}") results["errors"].append(f"Неверный формат: {account_input}")
continue continue
# Валидируем и форматируем номер счета # Валидируем и форматируем номер счета
logger.info(f"DEBUG: Before format_account_number: {account_number!r}")
formatted_account = format_account_number(account_number) formatted_account = format_account_number(account_number)
logger.info(f"DEBUG: After format_account_number: {formatted_account!r}")
if not formatted_account: if not formatted_account:
card_info = f" (карта: {card_number})" if card_number else "" card_info = f" (карта: {card_number})" if card_number else ""
results["invalid_accounts"].append(account_input) results["invalid_accounts"].append(account_input)
results["errors"].append(f"Неверный формат счета: {account_number}{card_info}") results["errors"].append(f"Неверный формат счета: {account_number}{card_info}")
logger.error(f"DEBUG: Format failed for {account_number!r}")
continue continue
# Ищем владельца счёта через таблицу Account # Ищем владельца счёта через таблицу Account

View File

@@ -6,7 +6,6 @@ import asyncio
import json import json
from ..core.database import async_session_maker from ..core.database import async_session_maker
from ..core.services import LotteryService, ParticipationService from ..core.services import LotteryService, ParticipationService
from ..core.models import Lottery, User
async def conduct_lottery_draw(lottery_id: int): async def conduct_lottery_draw(lottery_id: int):
"""Проводим розыгрыш для указанного ID""" """Проводим розыгрыш для указанного ID"""
@@ -36,8 +35,8 @@ async def conduct_lottery_draw(lottery_id: int):
# Выводим список участников # Выводим список участников
print("\n👥 Список участников:") print("\n👥 Список участников:")
for i, user in enumerate(participants, 1): for i, user in enumerate(participants, 1):
accounts = user.account_number.split(',') if user.account_number else ['Нет счетов'] username = f"@{user.username}" if user.username else "без username"
print(f" {i}. {user.first_name} (@{user.username}) - {len(accounts)} счет(ов)") print(f" {i}. {user.first_name or 'Без имени'} ({username})")
# Проводим розыгрыш # Проводим розыгрыш
print(f"\n🎲 Проводим розыгрыш...") print(f"\n🎲 Проводим розыгрыш...")
@@ -48,14 +47,15 @@ async def conduct_lottery_draw(lottery_id: int):
if winners: if winners:
print(f"\n🎉 Победители определены:") print(f"\n🎉 Победители определены:")
for i, winner_data in enumerate(winners, 1): for place, winner_data in winners.items():
user = winner_data['user'] user = winner_data['user']
prize = winner_data['prize'] 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}") print(f" 💎 Приз: {prize}")
# Обновляем статус розыгрыша await session.commit()
await LotteryService.set_lottery_completed(session, lottery_id, True)
print(f"\n✅ Розыгрыш завершен и помечен как завершенный") print(f"\n✅ Розыгрыш завершен и помечен как завершенный")
else: else:

View File

@@ -3,7 +3,7 @@
""" """
import asyncio import asyncio
from ..core.database import async_session_maker, init_db 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 from ..utils.admin_utils import AdminUtils, ReportGenerator
@@ -87,17 +87,17 @@ async def demo_admin_features():
participants_added = 0 participants_added = 0
for user in users: 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 participants_added += 1
# Во второй - половину # Во второй - половину
if user.id % 2 == 0: 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 participants_added += 1
# В третий - треть # В третий - треть
if user.id % 3 == 0: 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 participants_added += 1
print(f"✅ Добавлено {participants_added} участий") print(f"✅ Добавлено {participants_added} участий")
@@ -127,6 +127,7 @@ async def demo_admin_features():
# Первый розыгрыш # Первый розыгрыш
results1 = await LotteryService.conduct_draw(session, lottery1.id) results1 = await LotteryService.conduct_draw(session, lottery1.id)
await session.commit()
print(f" 🎉 {lottery1.title} - результаты:") print(f" 🎉 {lottery1.title} - результаты:")
for place, winner_info in results1.items(): for place, winner_info in results1.items():
user = winner_info['user'] user = winner_info['user']
@@ -135,6 +136,7 @@ async def demo_admin_features():
# Второй розыгрыш # Второй розыгрыш
results2 = await LotteryService.conduct_draw(session, lottery2.id) results2 = await LotteryService.conduct_draw(session, lottery2.id)
await session.commit()
print(f" 🚗 {lottery2.title} - результаты:") print(f" 🚗 {lottery2.title} - результаты:")
for place, winner_info in results2.items(): for place, winner_info in results2.items():
user = winner_info['user'] user = winner_info['user']

View File

@@ -19,6 +19,7 @@ async def conduct_simple_draw():
try: try:
# Проводим розыгрыш # Проводим розыгрыш
winners = await LotteryService.conduct_draw(session, lottery_id) winners = await LotteryService.conduct_draw(session, lottery_id)
await session.commit()
print(f"🎉 Розыгрыш проведен!") print(f"🎉 Розыгрыш проведен!")
print(f"📊 Результат: {winners}") print(f"📊 Результат: {winners}")

View File

@@ -6,6 +6,21 @@ from ..core.models import User, Lottery
from ..utils.account_utils import mask_account_number 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: 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': elif display_type == 'account_number':
# Отображаем номер клиентского счета # Отображаем номер клиентского счета
if not user.account_number: account_number = _get_user_account_number(user)
if not account_number:
return "Счёт не указан" return "Счёт не указан"
if show_sensitive_data: if show_sensitive_data:
# Для админов показываем полный номер # Для админов показываем полный номер
return f"Счёт: {user.account_number}" return f"Счёт: {account_number}"
else: 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}" return f"Счёт: {masked}"
else: else:

View File

@@ -179,7 +179,8 @@ def get_participant_management_keyboard() -> InlineKeyboardMarkup:
[InlineKeyboardButton(text="📋 Список всех", callback_data="admin_list_all_participants"), [InlineKeyboardButton(text="📋 Список всех", callback_data="admin_list_all_participants"),
InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_search_participants")], InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_search_participants")],
[InlineKeyboardButton(text="📊 По розыгрышам", callback_data="admin_participants_by_lottery")], [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")] [InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
] ]
return InlineKeyboardMarkup(inline_keyboard=buttons) 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)) await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data == "admin_participants_report") @admin_router.callback_query(F.data == "admin_participants_accounts_report")
async def show_participants_report(callback: CallbackQuery): async def show_participants_accounts_report(callback: CallbackQuery):
"""Отчет по участникам""" """Отчет по участникам и привязанным счетам"""
if not await check_admin_access(callback.from_user.id): if not await check_admin_access(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True) await callback.answer("❌ Недостаточно прав", show_alert=True)
return return
async with async_session_maker() as session: async with async_session_maker() as session:
from sqlalchemy import func, select from sqlalchemy import func, select
from ..core.models import User, Participation, Lottery from ..core.models import User, Participation, Account
# Общая статистика по участникам # Общая статистика по участникам
total_participants = await session.scalar( 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_participations = await session.scalar(select(func.count(Participation.id)))
total_users = await session.scalar(select(func.count(User.id)))
# Топ активных участников # Топ активных участников
top_participants = await session.execute( top_participants = await session.execute(
select( select(
User.first_name, User.first_name,
User.username, User.username,
User.account_number, func.min(Account.account_number).label('account_sample'),
func.count(Participation.id).label('participations') func.count(func.distinct(Participation.id)).label('participations')
) )
.select_from(User)
.join(Participation) .join(Participation)
.outerjoin(Account, Account.owner_id == User.id)
.group_by(User.id) .group_by(User.id)
.order_by(func.count(Participation.id).desc()) .order_by(func.count(func.distinct(Participation.id)).desc())
.limit(10) .limit(10)
) )
top_participants = top_participants.fetchall() top_participants = top_participants.fetchall()
# Участники с аккаунтами vs без # Участники с аккаунтами vs без
users_with_accounts = await session.scalar( 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( users_without_accounts = max((total_users or 0) - (users_with_accounts or 0), 0)
select(func.count(User.id)).where(User.account_number.is_(None))
)
text = "📈 Отчет по участникам\n\n" text = "📈 Отчет по участникам и счетам\n\n"
text += f"👥 Всего уникальных участников: {total_participants}\n" text += f"👥 Всего уникальных участников: {total_participants}\n"
text += f"📊 Всего участий: {total_participations}\n" text += f"📊 Всего участий: {total_participations}\n"
text += f"🏦 С номерами счетов: {users_with_accounts}\n" text += f"🏦 С номерами счетов: {users_with_accounts}\n"
@@ -2420,7 +2422,7 @@ async def show_participants_report(callback: CallbackQuery):
await callback.message.edit_text( await callback.message.edit_text(
text, text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[ 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")] [InlineKeyboardButton(text="◀️ Назад", callback_data="admin_participants")]
]) ])
) )
@@ -4827,7 +4829,7 @@ async def admin_broadcast_menu(callback: CallbackQuery, state: FSMContext):
buttons = [ buttons = [
[InlineKeyboardButton(text="✉️ Создать рассылку", callback_data="admin_broadcast_start")], [InlineKeyboardButton(text="✉️ Создать рассылку", callback_data="admin_broadcast_start")],
[InlineKeyboardButton(text="📱 Управление каналами", callback_data="admin_broadcast_channels")], [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")] [InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")]
] ]

View File

@@ -26,7 +26,7 @@ async def test_comprehensive_features():
for i in range(5): for i in range(5):
# Генерируем уникальные данные # Генерируем уникальные данные
unique_id = random.randint(10000000, 99999999) 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( user = await UserService.get_or_create_user(
session, session,
@@ -52,7 +52,7 @@ async def test_comprehensive_features():
title="Тест массовых операций", title="Тест массовых операций",
description="Розыгрыш для тестирования массовых операций", description="Розыгрыш для тестирования массовых операций",
prizes=["Приз 1", "Приз 2"], prizes=["Приз 1", "Приз 2"],
creator_id=1 creator_id=test_users[0].id
) )
print(f"✅ Розыгрыш создан: {lottery.title}") print(f"✅ Розыгрыш создан: {lottery.title}")

View File

@@ -27,7 +27,7 @@ async def test_basic_functionality():
# 1. Тест создания пользователя с номером счёта # 1. Тест создания пользователя с номером счёта
print("\n1. 👤 Создание пользователя с номером счёта:") 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( user = await UserService.get_or_create_user(
session, session,
telegram_id=999999999, telegram_id=999999999,
@@ -49,7 +49,7 @@ async def test_basic_functionality():
print("\n2. 📋 Тестирование валидации номеров:") print("\n2. 📋 Тестирование валидации номеров:")
test_cases = [ test_cases = [
("12-34-56-78-90-12-34-56", True), ("12-34-56-78-90-12-34", True),
("invalid-number", False), ("invalid-number", False),
("12345678901234567890", False) ("12345678901234567890", False)
] ]
@@ -84,7 +84,7 @@ async def test_basic_functionality():
title="Тест отображения", title="Тест отображения",
description="Тестовый розыгрыш", description="Тестовый розыгрыш",
prizes=["Приз 1"], prizes=["Приз 1"],
creator_id=1 creator_id=user.id
) )
display_types = ['username', 'chat_id', 'account_number'] display_types = ['username', 'chat_id', 'account_number']

View File

@@ -20,7 +20,7 @@ async def test_features():
async with async_session_maker() as session: async with async_session_maker() as session:
# Генерируем уникальные тестовые данные # Генерируем уникальные тестовые данные
unique_id = random.randint(1000000, 9999999) 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"🆔 Используем уникальный ID: {unique_id}")
print(f"🔢 Используем номер счёта: {test_account}") print(f"🔢 Используем номер счёта: {test_account}")
@@ -44,9 +44,7 @@ async def test_features():
if success: if success:
print(f"✅ Номер счёта {test_account} установлен") print(f"✅ Номер счёта {test_account} установлен")
# Обновляем данные пользователя print(f"✅ Подтверждено: {test_account}")
await session.refresh(user)
print(f"✅ Подтверждено: {user.account_number}")
else: else:
print(f"Не удалось установить номер счёта") print(f"Не удалось установить номер счёта")
return return
@@ -72,7 +70,7 @@ async def test_features():
title="Тест отображения", title="Тест отображения",
description="Тестовый розыгрыш", description="Тестовый розыгрыш",
prizes=["Приз 1"], prizes=["Приз 1"],
creator_id=1 creator_id=user.id
) )
# Тестируем все типы отображения # Тестируем все типы отображения

View File

@@ -30,14 +30,14 @@ async def test_account_functionality():
'username': 'client1', 'username': 'client1',
'first_name': 'Клиент', 'first_name': 'Клиент',
'last_name': 'Первый', 'last_name': 'Первый',
'account': '12-34-56-78-90-12-34-56' 'account': '12-34-56-78-90-12-34'
}, },
{ {
'telegram_id': 777000002, 'telegram_id': 777000002,
'username': 'client2', 'username': 'client2',
'first_name': 'Клиент', 'first_name': 'Клиент',
'last_name': 'Второй', 'last_name': 'Второй',
'account': '11-22-33-44-55-66-77-88' 'account': '11-22-33-44-55-66-77'
}, },
{ {
'telegram_id': 777000003, 'telegram_id': 777000003,
@@ -78,9 +78,9 @@ async def test_account_functionality():
print("\n📋 Тестирование валидации номеров счетов:") print("\n📋 Тестирование валидации номеров счетов:")
test_numbers = [ test_numbers = [
("12-34-56-78-90-12-34-56", True), # Корректный ("12-34-56-78-90-12-34", True), # Корректный
("12345678901234567890123456", False), # Без дефисов ("12345678901234", False), # Без дефисов
("12-34-56-78-90-12-34", False), # Короткий ("12-34-56-78-90-12", False), # Короткий
("12-34-56-78-90-12-34-56-78", False), # Длинный ("12-34-56-78-90-12-34-56-78", False), # Длинный
("ab-cd-ef-gh-ij-kl-mn-op", False), # Буквы ("ab-cd-ef-gh-ij-kl-mn-op", False), # Буквы
("", False) # Пустой ("", False) # Пустой
@@ -93,7 +93,7 @@ async def test_account_functionality():
# Тест маскирования # Тест маскирования
print("\n🎭 Тестирование маскирования номеров:") 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]: for digits in [4, 6, 8]:
masked = mask_account_number(test_account, show_last_digits=digits) masked = mask_account_number(test_account, show_last_digits=digits)
print(f"Показать последние {digits} цифр: {masked}") print(f"Показать последние {digits} цифр: {masked}")
@@ -103,7 +103,7 @@ async def test_account_functionality():
async with async_session_maker() as session: 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: if user:
print(f"✅ Найден пользователь по полному номеру: {user.first_name}") print(f"✅ Найден пользователь по полному номеру: {user.first_name}")
@@ -123,18 +123,21 @@ async def test_display_types():
print("=" * 50) print("=" * 50)
async with async_session_maker() as session: 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( lottery = await LotteryService.create_lottery(
session, session,
title="Тест отображения победителей", title="Тест отображения победителей",
description="Розыгрыш для тестирования различных типов отображения", description="Розыгрыш для тестирования различных типов отображения",
prizes=["Первый приз", "Второй приз"], 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: if user and lottery:
print(f"👤 Тестируем отображение для пользователя: {user.first_name}") print(f"👤 Тестируем отображение для пользователя: {user.first_name}")
print(f"💳 Номер счёта: {user.account_number}") print(f"💳 Номер счёта: {user.account_number}")