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

View File

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

View File

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

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

View File

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

View File

@@ -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 '⚠️ Разрешено'}")
# Попытка установить ручного победителя для несуществующего пользователя

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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
)
# Тестируем все типы отображения

View File

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