diff --git a/.env.example b/.env.example index 6ead4de..adbc257 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file +# DATABASE_TIMEOUT=30 diff --git a/Makefile b/Makefile index 7a22b7e..ab745db 100644 --- a/Makefile +++ b/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 "� Проверка 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" \ No newline at end of file + @echo " make docker-test-db" diff --git a/docker-compose.yml b/docker-compose.yml index 52d823b..9c33d00 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/main.py b/main.py index b0a32a1..edcbd99 100644 --- a/main.py +++ b/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 == "� Мои логины") +@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}") \ No newline at end of file + logger.error(f"Критическая ошибка: {e}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0a9aa16 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements.txt b/requirements.txt index bb8ef38..539e0c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +# 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 diff --git a/scripts/db_setup.py b/scripts/db_setup.py index bb9a806..4fd23d7 100644 --- a/scripts/db_setup.py +++ b/scripts/db_setup.py @@ -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()) \ No newline at end of file + asyncio.run(main()) diff --git a/scripts/examples.py b/scripts/examples.py index 62637a1..835cd81 100644 --- a/scripts/examples.py +++ b/scripts/examples.py @@ -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 '⚠️ Разрешено'}") # Попытка установить ручного победителя для несуществующего пользователя diff --git a/src/components/ui.py b/src/components/ui.py index 8cb235f..559509c 100644 --- a/src/components/ui.py +++ b/src/components/ui.py @@ -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 \ No newline at end of file + return text diff --git a/src/core/database.py b/src/core/database.py index f07d3b5..84b9d8a 100644 --- a/src/core/database.py +++ b/src/core/database.py @@ -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() \ No newline at end of file + await engine.dispose() diff --git a/src/core/registration_services.py b/src/core/registration_services.py index 4534ab9..47a356a 100644 --- a/src/core/registration_services.py +++ b/src/core/registration_services.py @@ -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() diff --git a/src/core/services.py b/src/core/services.py index 603c20b..0b2da1d 100644 --- a/src/core/services.py +++ b/src/core/services.py @@ -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 - } \ No newline at end of file + } diff --git a/src/display/conduct_draw.py b/src/display/conduct_draw.py index 45fd0f1..825672c 100644 --- a/src/display/conduct_draw.py +++ b/src/display/conduct_draw.py @@ -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()) \ No newline at end of file + asyncio.run(main()) diff --git a/src/display/demo_admin.py b/src/display/demo_admin.py index 1c73dfd..ddca6b1 100644 --- a/src/display/demo_admin.py +++ b/src/display/demo_admin.py @@ -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()) \ No newline at end of file + asyncio.run(demo_admin_features()) diff --git a/src/display/simple_draw.py b/src/display/simple_draw.py index 777c024..608ba0b 100644 --- a/src/display/simple_draw.py +++ b/src/display/simple_draw.py @@ -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()) \ No newline at end of file + asyncio.run(conduct_simple_draw()) diff --git a/src/display/winner_display.py b/src/display/winner_display.py index 6cbeb47..afd79f3 100644 --- a/src/display/winner_display.py +++ b/src/display/winner_display.py @@ -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'] \ No newline at end of file + return display_type in ['username', 'chat_id', 'account_number'] diff --git a/src/handlers/admin_panel.py b/src/handlers/admin_panel.py index 38caa87..64e6641 100644 --- a/src/handlers/admin_panel.py +++ b/src/handlers/admin_panel.py @@ -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="� Статистика рассылок", 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'] \ No newline at end of file +__all__ = ['admin_router'] diff --git a/tests/test_admin_improvements.py b/tests/test_admin_improvements.py index c986ea9..b0bb496 100644 --- a/tests/test_admin_improvements.py +++ b/tests/test_admin_improvements.py @@ -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}") diff --git a/tests/test_basic_features.py b/tests/test_basic_features.py index 0e195f9..7e2a0a3 100644 --- a/tests/test_basic_features.py +++ b/tests/test_basic_features.py @@ -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'] diff --git a/tests/test_clean_features.py b/tests/test_clean_features.py index 83b0dda..e7ba0b1 100644 --- a/tests/test_clean_features.py +++ b/tests/test_clean_features.py @@ -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 ) # Тестируем все типы отображения diff --git a/tests/test_new_features.py b/tests/test_new_features.py index 66f8bdb..af3a612 100644 --- a/tests/test_new_features.py +++ b/tests/test_new_features.py @@ -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}")