From a9af9cb010716bd28ab06c3556f97b0a063f5f67 Mon Sep 17 00:00:00 2001 From: Trevor1985 Date: Mon, 14 Sep 2026 21:01:56 +0900 Subject: [PATCH] Fix account enrollment and operator callback flows with Redis scenarios --- .drone.yml | 4 + README.md | 1 + docs/ADMIN_GUIDE.md | 4 +- docs/CASHIER_GUIDE.md | 2 + docs/OPERATOR_SCENARIO_AUDIT.md | 29 +++ src/core/services.py | 18 +- src/filters/numeric_callback.py | 21 ++ src/handlers/account_handlers.py | 71 ++++-- src/handlers/account_services.py | 42 +++- src/handlers/admin_account_handlers.py | 13 +- src/handlers/admin_panel.py | 275 ++++++++++++--------- src/handlers/cashier_handlers.py | 8 +- src/handlers/input_fallback.py | 7 +- src/utils/text_output.py | 7 + tests/conftest.py | 13 +- tests/test_operator_scenarios.py | 321 +++++++++++++++++++++++++ tests/test_staff_management.py | 9 +- 17 files changed, 678 insertions(+), 167 deletions(-) create mode 100644 docs/OPERATOR_SCENARIO_AUDIT.md create mode 100644 src/filters/numeric_callback.py create mode 100644 tests/test_operator_scenarios.py diff --git a/.drone.yml b/.drone.yml index 1ebcb03..33d7367 100644 --- a/.drone.yml +++ b/.drone.yml @@ -26,6 +26,7 @@ steps: BOT_TOKEN: "123456:TEST_TOKEN_FOR_ISOLATED_TESTS" DATABASE_URL: "postgresql+asyncpg://lottery_test:lottery_test@postgres:5432/lottery_test" TEST_DATABASE_URL: "postgresql+asyncpg://lottery_test:lottery_test@postgres:5432/lottery_test" + TEST_REDIS_URL: "redis://redis:6379/15" commands: - pip install -r requirements-dev.txt - python scripts/wait_database.py @@ -61,6 +62,9 @@ steps: status: [success] services: + - name: redis + image: redis:7-alpine + - name: postgres image: postgres:16-alpine environment: diff --git a/README.md b/README.md index 1715057..0b4eed7 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Актуальные изменения: [отчёт о стабилизации и известных ограничениях](docs/STABILIZATION_REPORT.md), [настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md). [Проверка текстовых обработчиков и регистрации карты](docs/TEXT_INPUT_AUDIT.md). +[Проверка счетов и сценариев администратора, кассира и клиента](docs/OPERATOR_SCENARIO_AUDIT.md). Инструкции: [администратору](docs/ADMIN_GUIDE.md), [кассиру](docs/CASHIER_GUIDE.md), [системному администратору](docs/SYSTEM_ADMIN_GUIDE.md). Назначение сотрудников: `/staff` системным администратором, с подтверждением роли. diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md index 47d1330..20b94a8 100644 --- a/docs/ADMIN_GUIDE.md +++ b/docs/ADMIN_GUIDE.md @@ -40,7 +40,9 @@ - Перед проведением проверьте «По розыгрышам» / «Отчёт»: состав, количество участников, отсутствие ошибочно добавленных счетов. - Ошибочное участие удаляйте через «Удалить» либо соответствующую массовую операцию до проведения розыгрыша. Повторное добавление того же счёта в один розыгрыш не создаёт второй билет. -Бот поддерживает участие по пользователю и по счёту. Используйте способ, соответствующий опубликованным правилам. Счёт может участвовать без связанного профиля; в этом случае автоматическое личное уведомление владельцу невозможно до установления связи. +Для быстрого добавления отправьте в личный диалог `КАРТА СЧЁТ`, например `0007 11-22-33-44-55-66-77`, затем «Добавить в розыгрыш» и нужный розыгрыш. Если карта известна боту, новый счёт будет привязан к клиенту одновременно с добавлением участия. Ведущие нули карты сохраняются. Если счёт уже принадлежит другому клиенту или деактивирован, бот откажет в добавлении. Повторное добавление покажет пропуск без создания второго билета. + +Бот поддерживает участие по пользователю и по счёту. Используйте способ, соответствующий опубликованным правилам. Счёт без указания карты может участвовать без связанного профиля; в этом случае автоматическое личное уведомление владельцу невозможно до установления связи. ## Проведение и выдача призов diff --git a/docs/CASHIER_GUIDE.md b/docs/CASHIER_GUIDE.md index a824cea..3c6bcf7 100644 --- a/docs/CASHIER_GUIDE.md +++ b/docs/CASHIER_GUIDE.md @@ -38,6 +38,8 @@ 3. За один ввод допускается до 1000 распознанных счетов. 4. Проверьте количества «Добавлено», «Пропущено» и ошибки. Повтор того же счёта в том же розыгрыше не создаёт второго участия. +Чтобы сразу привязать новый счёт клиенту, отправьте строку `КАРТА СЧЁТ`, например `0007 11-22-33-44-55-66-77`. Карта должна быть известна боту. Создание счёта и добавление участия сохраняются вместе. Чужой или неактивный счёт нельзя перепривязать таким вводом; исправьте данные или обратитесь к администратору. + Если розыгрыш уже проведён или закрыт, бот отклонит добавление. Если активных розыгрышей нет, обратитесь к администратору. Кассир не может открыть или создать розыгрыш. Счёт без привязки к клиенту тоже может быть добавлен. Чтобы клиент получил личное уведомление и мог подтвердить выигрыш по коду, сначала проверьте привязку счёта к его зарегистрированному профилю. diff --git a/docs/OPERATOR_SCENARIO_AUDIT.md b/docs/OPERATOR_SCENARIO_AUDIT.md new file mode 100644 index 0000000..63f704c --- /dev/null +++ b/docs/OPERATOR_SCENARIO_AUDIT.md @@ -0,0 +1,29 @@ +# Проверка операций со счетами и сценариев бота — 14 сентября 2026 + +## Что подтвердилось на сервере + +Проверен контейнер `lottery_bot`, исходная версия `f3f8d0f`, деплой №93. Контейнер работал, healthcheck проходил, автоматических перезапусков не было. При этом отдельные действия завершались исключениями: состояние контейнера само по себе не проверяет функциональность меню. + +В логах за сутки найдены `DetachedInstanceError` при добавлении счетов и `ValueError` при выборе «Участники по розыгрышам». Проверка базы во время диагностики показала отсутствие записей счетов и участий с номерами счетов. Поэтому проверены обе причины пропуска: новый счёт с известной картой и повтор уже добавленного счёта. Точный исходный текст клиента в предоставленных логах отсутствует. + +## Исправления + +- При отклонённой операции `rollback()` инвалидировал загруженные ORM-объекты. Итоговый ответ обращался к `lottery.title` после закрытия сессии. Теперь необходимые значения сохраняются до операции; аналогичные обращения исправлены в одиночных и массовых операциях по пользователям. +- Быстрый ввод `КАРТА СЧЁТ` не создавал отсутствующий счёт, хотя предварительный экран находил владельца карты. Теперь известная карта позволяет создать счёт и участие одной транзакцией. Уникальность счёта защищает работу кассиров в разных розыгрышах; владелец существующего счёта не меняется. +- Широкие фильтры кнопок перехватывали отчёты, подтверждения переигровки и удаления, редактирование победителя. Фильтры теперь проверяют полный формат и допустимый числовой ID. Нераспознанная кнопка получает понятный ответ. +- Удалённые розыгрыши и истёкшие данные диалога обрабатываются без `NoneType` и `IndexError`. +- Добавление и удаление счёта из детального меню используют общие транзакционные проверки открытого розыгрыша и владельца. +- Экраны победителей поддерживают участие без Telegram-профиля. При ручном назначении по счёту сохраняется именно выбранный билет; один счёт нельзя назначить на разные призовые места. +- Имена, названия с символами HTML и длинные списки не ломают ответы в проверенных операциях со счетами. Длинные отчёты разделяются, кнопки остаются на последней странице. Завершённый диалог очищается до отправки итогового отчёта. + +## Как воспроизводятся сценарии + +`tests/test_operator_scenarios.py` отправляет сообщения и нажатия через настоящий `main.dp.feed_update`. Используются отдельная база и синтетические Telegram ID. Вместо сети транспорт сохраняет вызовы API и проверяет длину текста, структуру HTML и callback-data. Ошибки, перехваченные middleware, также приводят к падению теста. + +Проверяются: быстрый ввод нового и повторного счёта, команда добавления, массовое добавление, касса, просмотр клиентом своих счетов, проведение розыгрыша и выдача приза, конкурирующие кассиры, создание одного счёта в разных розыгрышах, чужая карта, ручные победители, удалённые объекты, старые и некорректные кнопки, отчёты и длинные списки. + +Полный набор дополнительно проверяет регистрацию, премиум-эмодзи, роли сотрудников, недоступных получателей, таймауты отправки, фоновые рассылки, повторную выдачу, миграции и ограничения базы. + +В Drone тесты выполняются на SQLite и отдельно на PostgreSQL 16 с Redis 7. Для PostgreSQL-прогона `TEST_REDIS_URL` включает настоящий Redis FSM и блокировки диалогов. Тестовая очистка ограничена пространством синтетического бота `fsm:123456:*`. + +Эмуляция не создаёт настоящих розыгрышей и не отправляет сообщения клиентам. Она не заменяет проверку интерфейса приложением Telegram. После CI требуется отдельно проверить установленную версию, healthcheck, heartbeat, Telegram API и новые ошибки контейнера. diff --git a/src/core/services.py b/src/core/services.py index 3ab2d95..1cf34a1 100644 --- a/src/core/services.py +++ b/src/core/services.py @@ -472,16 +472,19 @@ class ParticipationService: results["errors"].append(f"Пользователь {telegram_id} не найден") continue + first_name, username = user.first_name, user.username # Пробуем добавить if await ParticipationService.add_participant(session, lottery_id, user.id): results["added"] += 1 - results["details"].append(f"Добавлен: {user.first_name} (@{user.username or 'no_username'})") + results["details"].append(f"Добавлен: {first_name} (@{username or 'no_username'})") else: results["skipped"] += 1 - results["details"].append(f"Уже участвует: {user.first_name}") + results["details"].append(f"Не добавлен (повтор или розыгрыш закрыт): {first_name}") except Exception as e: - results["errors"].append(f"Ошибка с {telegram_id}: {str(e)}") + await session.rollback() + from src.utils.errors import public_error + results["errors"].append(f"Ошибка с {telegram_id}: {public_error(e)}") return results @@ -503,15 +506,18 @@ class ParticipationService: results["details"].append(f"Не найден: {telegram_id}") continue + first_name = user.first_name if await ParticipationService.remove_participant(session, lottery_id, user.id): results["removed"] += 1 - results["details"].append(f"Удален: {user.first_name}") + results["details"].append(f"Удален: {first_name}") else: results["not_found"] += 1 - results["details"].append(f"Не участвовал: {user.first_name}") + results["details"].append(f"Не удалён (нет участия или розыгрыш закрыт): {first_name}") except Exception as e: - results["errors"].append(f"Ошибка с {telegram_id}: {str(e)}") + await session.rollback() + from src.utils.errors import public_error + results["errors"].append(f"Ошибка с {telegram_id}: {public_error(e)}") return results diff --git a/src/filters/numeric_callback.py b/src/filters/numeric_callback.py new file mode 100644 index 0000000..62795ce --- /dev/null +++ b/src/filters/numeric_callback.py @@ -0,0 +1,21 @@ +"""Match a whole callback ID so one menu cannot consume another menu's action.""" +from aiogram.filters import Filter +from aiogram.types import CallbackQuery + + +class NumericCallback(Filter): + def __init__(self, prefix: str, *, suffixes=(), maximum=2**31 - 1): + self.prefix = prefix + self.suffixes = suffixes + self.maximum = maximum + + async def __call__(self, callback: CallbackQuery): + if not callback.data or not callback.data.startswith(self.prefix): + return False + value = callback.data[len(self.prefix):] + if self.suffixes: + value, separator, suffix = value.partition("_") + if not separator or suffix not in self.suffixes: + return False + return bool(value and len(value) <= 19 and value.isascii() and value.isdigit() + and 0 < int(value) <= self.maximum) diff --git a/src/handlers/account_handlers.py b/src/handlers/account_handlers.py index 9eee78c..6bed61f 100644 --- a/src/handlers/account_handlers.py +++ b/src/handlers/account_handlers.py @@ -1,6 +1,7 @@ """ Обработчики для работы со счетами в розыгрышах """ +from src.filters.numeric_callback import NumericCallback from src.core.access import is_admin from aiogram import Router, F from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup @@ -15,6 +16,9 @@ from ..core.services import LotteryService from .account_services import AccountParticipationService from ..utils.account_utils import parse_accounts_from_message, validate_account_number from typing import List +from html import escape +from ..utils.text_output import answer_plain_pages, edit_plain_pages +from ..utils.account_input import parse_account_records # Состояния FSM для работы со счетами @@ -47,11 +51,14 @@ async def detect_account_input(message: Message, state: FSMContext): raise SkipHandler() # Парсим счета из сообщения - accounts = parse_accounts_from_message(message.text) + accounts, errors = parse_account_records(message.text) if not accounts: from aiogram.dispatcher.event.bases import SkipHandler raise SkipHandler() + if errors: + await answer_plain_pages(message, "Исправьте записи и отправьте список заново:\n" + "\n".join(errors[:10])) + return # Извлекаем номера клубных карт и определяем владельцев from ..core.services import UserService @@ -119,9 +126,9 @@ async def detect_account_input(message: Message, state: FSMContext): owners_found = sum(1 for item in accounts_with_owners if item['owner']) text = ( - f"🔍 Обнаружен ввод счет{'а' if count == 1 else 'ов'}\n\n" - f"Найдено: {count}\n" - f"Владельцев определено: {owners_found}\n\n" + f"🔍 Обнаружен ввод счет{'а' if count == 1 else 'ов'}\n\n" + f"Найдено: {count}\n" + f"Владельцев определено: {owners_found}\n\n" f"{accounts_text}\n\n" f"Выберите действие:" ) @@ -142,7 +149,8 @@ async def detect_account_input(message: Message, state: FSMContext): )] ]) - await message.answer(text, reply_markup=keyboard, parse_mode="HTML") + # Owner names and long pasted lists must not break Telegram markup/limits. + await answer_plain_pages(message, text, reply_markup=keyboard) @account_router.callback_query(F.data == "account_action:cancel") @@ -159,6 +167,9 @@ async def choose_lottery_for_accounts(callback: CallbackQuery, state: FSMContext if not await is_admin(callback.from_user.id): await callback.answer("⛔ Доступно только администраторам", show_alert=True) return + if not (await state.get_data()).get("detected_accounts"): + await callback.answer("Данные устарели. Отправьте счета заново.", show_alert=True) + return async with async_session_maker() as session: # Получаем активные розыгрыши @@ -197,7 +208,7 @@ async def choose_lottery_for_accounts(callback: CallbackQuery, state: FSMContext await callback.answer() -@account_router.callback_query(F.data.startswith("add_accounts_to:")) +@account_router.callback_query(NumericCallback('add_accounts_to:')) async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext): """Добавление счетов в выбранный розыгрыш""" if not await is_admin(callback.from_user.id): @@ -229,29 +240,31 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext): await callback.answer() return + # A skipped account rolls back the session and expires ORM objects. + lottery_title = lottery.title # Добавляем счета results = await AccountParticipationService.add_accounts_bulk( session, lottery_id, accounts ) # Формируем результат - text = f"Результаты добавления в розыгрыш:\n{lottery.title}\n\n" - text += f"✅ Добавлено: {results['added']}\n" - text += f"⚠️ Пропущено: {results['skipped']}\n\n" + await state.clear() + text = f"Результаты добавления в розыгрыш:\n{lottery_title}\n\n" + text += f"✅ Добавлено: {results['added']}\n" + text += f"⚠️ Пропущено: {results['skipped']}\n\n" if results['details']: - text += "Детали:\n" + text += "Детали:\n" text += "\n".join(results['details'][:20]) # Показываем первые 20 if len(results['details']) > 20: text += f"\n... и ещё {len(results['details']) - 20}" if results['errors']: - text += f"\n\nОшибки:\n" + text += f"\n\nОшибки:\n" text += "\n".join(results['errors'][:10]) - await callback.message.edit_text(text, parse_mode="HTML") - await state.clear() + await edit_plain_pages(callback.message, text) await callback.answer("✅ Готово!") @@ -276,8 +289,7 @@ async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext): return async with async_session_maker() as session: - # Получаем все розыгрыши (активные и завершенные) - lotteries = await LotteryService.get_all_lotteries(session, limit=30) + lotteries = await LotteryService.get_active_lotteries(session, limit=30) if not lotteries: await callback.message.edit_text( @@ -316,7 +328,7 @@ async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext): await callback.answer() -@account_router.callback_query(F.data.startswith("winner_lottery:")) +@account_router.callback_query(NumericCallback('winner_lottery:')) async def choose_winner_place(callback: CallbackQuery, state: FSMContext): """Выбор места для победителя""" if not await is_admin(callback.from_user.id): @@ -324,6 +336,10 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext): return lottery_id = int(callback.data.split(":")[1]) + accounts = (await state.get_data()).get("detected_accounts", []) + if len(accounts) != 1: + await callback.answer("Данные устарели. Отправьте один счёт заново.", show_alert=True) + return # Сохраняем ID розыгрыша await state.update_data(winner_lottery_id=lottery_id) @@ -369,7 +385,7 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext): await callback.message.edit_text( f"👑 Установка победителя\n\n" - f"Розыгрыш: {lottery.title}\n" + f"Розыгрыш: {escape(lottery.title)}\n" f"Счет: {account}\n\n" f"Выберите место:", reply_markup=keyboard, @@ -379,7 +395,7 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext): await callback.answer() -@account_router.callback_query(F.data.startswith("winner_place:")) +@account_router.callback_query(F.data.regexp(r"^winner_place:[0-9]{1,3}$")) async def set_account_winner(callback: CallbackQuery, state: FSMContext): """Установка счета как победителя""" if not await is_admin(callback.from_user.id): @@ -390,7 +406,8 @@ async def set_account_winner(callback: CallbackQuery, state: FSMContext): # Получаем данные data = await state.get_data() - account = data.get("detected_accounts", [])[0] + accounts = data.get("detected_accounts", []) + account = accounts[0] if len(accounts) == 1 else None lottery_id = data.get("winner_lottery_id") if not account or not lottery_id: @@ -404,6 +421,14 @@ async def set_account_winner(callback: CallbackQuery, state: FSMContext): async with async_session_maker() as session: lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await state.clear() + await callback.answer("Розыгрыш не найден", show_alert=True) + return + if not 1 <= place <= len(lottery.prizes or [None]): + await callback.answer("Некорректное призовое место", show_alert=True) + return + lottery_title = lottery.title # Получаем приз для этого места prize = None @@ -416,20 +441,20 @@ async def set_account_winner(callback: CallbackQuery, state: FSMContext): session, lottery_id, account, place, prize ) + await state.clear() if result["success"]: text = ( f"✅ Победитель установлен!\n\n" - f"Розыгрыш: {lottery.title}\n" + f"Розыгрыш: {escape(lottery_title)}\n" f"Счет: {account}\n" f"Место: {place}\n" ) if prize: - text += f"Приз: {prize}" + text += f"Приз: {escape(prize)}" await callback.answer("✅ Победитель установлен!", show_alert=True) else: - text = f"❌ {result['message']}" + text = f"❌ {escape(result['message'])}" await callback.answer("❌ Ошибка", show_alert=True) await callback.message.edit_text(text, parse_mode="HTML") - await state.clear() diff --git a/src/handlers/account_services.py b/src/handlers/account_services.py index 263dc3b..829646c 100644 --- a/src/handlers/account_services.py +++ b/src/handlers/account_services.py @@ -3,7 +3,7 @@ """ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete, func -from ..core.models import Lottery, Participation, Winner +from ..core.models import Account, Lottery, Participation, User, Winner from ..utils.account_utils import validate_account_number, format_account_number, parse_accounts_from_message, search_accounts_by_pattern from typing import List, Optional, Dict, Any from ..core.transactions import lock_open_lottery @@ -78,15 +78,22 @@ class AccountParticipationService: "account_number": formatted_account } - # Получаем запись Account и владельца - from ..core.registration_services import AccountService - from ..core.models import Account - - user = await AccountService.get_account_owner(session, formatted_account) - account_record = await session.execute( - select(Account).where(Account.account_number == formatted_account) - ) - account_record = account_record.scalar_one_or_none() + # Register a new ticket for the supplied card in the same transaction. + # Conflict handling also protects two cashiers adding it to different draws. + account_record = await session.scalar(select(Account).where(Account.account_number == formatted_account)) + if card_number and not account_record: + owner_id = await session.scalar(select(User.id).where(User.club_card_number == card_number)) + if owner_id is None: + await session.rollback() + return {"success": False, "message": "Владелец клубной карты не найден. Сначала зарегистрируйте клиента.", + "account_number": formatted_account} + from sqlalchemy.dialects.postgresql import insert as pg_insert + from sqlalchemy.dialects.sqlite import insert as sqlite_insert + insert = pg_insert if session.bind.dialect.name == "postgresql" else sqlite_insert + await session.execute(insert(Account).values(account_number=formatted_account, owner_id=owner_id, is_active=True) + .on_conflict_do_nothing(index_elements=[Account.account_number])) + account_record = await session.scalar(select(Account).where(Account.account_number == formatted_account)) + user = await session.get(User, account_record.owner_id) if account_record else None if account_record and not account_record.is_active: await session.rollback() return {"success": False, "message": "Счет неактивен", "account_number": formatted_account} @@ -298,6 +305,17 @@ class AccountParticipationService: "success": False, "message": f"❌ Счет {formatted_account}{card_info} не участвует в розыгрыше" } + if card_number: + from ..core.registration_services import AccountService + owner = await AccountService.get_account_owner(session, formatted_account) + if not owner or owner.club_card_number != card_number: + await session.rollback() + return {"success": False, "message": "Карта не соответствует владельцу счета"} + if await session.scalar(select(Winner.id).where( + Winner.lottery_id == lottery_id, Winner.account_number == formatted_account, Winner.place != place + ).limit(1)): + await session.rollback() + return {"success": False, "message": "Счет уже назначен на другое призовое место"} # Проверяем, не занято ли уже это место existing_winner = await session.execute( @@ -311,7 +329,7 @@ class AccountParticipationService: if existing_winner: # Обновляем существующего победителя existing_winner.account_number = formatted_account - existing_winner.user_id = None + existing_winner.user_id = participation.user_id existing_winner.is_manual = True if prize: existing_winner.prize = prize @@ -320,7 +338,7 @@ class AccountParticipationService: winner = Winner( lottery_id=lottery_id, account_number=formatted_account, - user_id=None, + user_id=participation.user_id, place=place, prize=prize, is_manual=True diff --git a/src/handlers/admin_account_handlers.py b/src/handlers/admin_account_handlers.py index 0757098..1a0c612 100644 --- a/src/handlers/admin_account_handlers.py +++ b/src/handlers/admin_account_handlers.py @@ -1,6 +1,7 @@ """Админские обработчики для управления счетами и верификации""" +from src.filters.numeric_callback import NumericCallback from src.utils.account_input import parse_account_records -from src.utils.text_output import answer_plain_pages +from src.utils.text_output import answer_plain_pages, edit_plain_pages from src.utils.input_validation import numeric_id from src.filters.dialog_input import NotCommand from src.utils.errors import public_error @@ -332,7 +333,7 @@ async def show_lottery_selection(message: Message, prev_text: str, state: FSMCon ) -@router.callback_query(F.data.startswith("add_to_lottery_")) +@router.callback_query(NumericCallback('add_to_lottery_')) async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext): """Добавить счета в выбранный розыгрыш""" lottery_id = int(callback.data.split("_")[-1]) @@ -356,12 +357,14 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext): await state.clear() return + lottery_title = lottery.title from .account_services import AccountParticipationService result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, [a['account_number'] for a in accounts]) success_count = result['added'] errors = result['errors'] - text = f"📊 **Добавление в розыгрыш '{lottery.title}'**\n\n" + await state.clear() + text = f"📊 Добавление в розыгрыш '{lottery_title}'\n\n" if success_count: text += f"✅ Добавлено счетов: {success_count}\n\n" @@ -371,8 +374,8 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext): for error in errors[:3]: text += f"• {error}\n" - await callback.message.edit_text(text) - await state.clear() + await edit_plain_pages(callback.message, text) + await callback.answer("Готово") @router.callback_query(F.data == "skip_lottery_add") diff --git a/src/handlers/admin_panel.py b/src/handlers/admin_panel.py index f5f30ae..62f40a6 100644 --- a/src/handlers/admin_panel.py +++ b/src/handlers/admin_panel.py @@ -1,6 +1,7 @@ """ Расширенная админ-панель для управления розыгрышами """ +from src.filters.numeric_callback import NumericCallback from src.utils.input_validation import lottery_field, numeric_id from src.utils.text_output import answer_plain_pages from html import escape @@ -33,6 +34,14 @@ from ..core.models import User, Lottery, Participation, Account, ChatMessage, Wi logger = logging.getLogger(__name__) +def _winner_name(winner): + if winner.account_number: + return f"Счет: {winner.account_number}" + if winner.user: + return f"@{winner.user.username}" if winner.user.username else (winner.user.first_name or str(winner.user_id)) + return f"ID: {winner.user_id or 'не привязан'}" + + async def safe_edit_message( callback: CallbackQuery, text: str, @@ -524,7 +533,7 @@ async def list_all_lotteries(callback: CallbackQuery): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_lottery_detail_")) +@admin_router.callback_query(NumericCallback('admin_lottery_detail_')) async def show_lottery_detail(callback: CallbackQuery): """Детальная информация о розыгрыше""" if not await check_admin_access(callback.from_user.id): @@ -578,7 +587,7 @@ async def show_lottery_detail(callback: CallbackQuery): # Безопасная обработка победителя - может быть без user_id if winner.user: - username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name + username = _winner_name(winner) else: # Победитель по номеру счета без связанного пользователя username = f"Счет: {winner.account_number}" @@ -621,7 +630,7 @@ async def show_participant_management(callback: CallbackQuery): await callback.message.edit_text(text, reply_markup=get_participant_management_keyboard()) -@admin_router.callback_query(F.data.startswith("admin_participants_")) +@admin_router.callback_query(NumericCallback('admin_participants_')) async def show_lottery_participants(callback: CallbackQuery): """Показать участников конкретного розыгрыша""" if not await check_admin_access(callback.from_user.id): @@ -738,7 +747,7 @@ async def start_add_participant(callback: CallbackQuery, state: FSMContext): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_add_part_to_")) +@admin_router.callback_query(NumericCallback('admin_add_part_to_')) async def choose_user_to_add(callback: CallbackQuery, state: FSMContext): """Выбор пользователя для добавления""" if not await check_admin_access(callback.from_user.id): @@ -750,6 +759,10 @@ async def choose_user_to_add(callback: CallbackQuery, state: FSMContext): async with async_session_maker() as session: lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await state.clear() + await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True) + return text = f"➕ Добавление в: {lottery.title}\n\n" text += "Введите Telegram ID или username пользователя:\n\n" @@ -794,9 +807,14 @@ async def process_add_participant(message: Message, state: FSMContext): ) return + user_first_name = user.first_name # Добавляем участника success = await ParticipationService.add_participant(session, lottery_id, user.id) lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await state.clear() + await message.answer('Розыгрыш не найден. Откройте меню заново.') + return await state.clear() @@ -814,7 +832,7 @@ async def process_add_participant(message: Message, state: FSMContext): ) else: await message.answer( - f"⚠️ Пользователь {user.first_name} уже участвует в этом розыгрыше", + f"⚠️ Пользователь {user_first_name} уже участвует либо розыгрыш закрыт", reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")] ]) @@ -856,7 +874,7 @@ async def remove_participant_start(callback: CallbackQuery): ) -@admin_router.callback_query(F.data.startswith("admin_remove_part_from_")) +@admin_router.callback_query(NumericCallback('admin_remove_part_from_')) async def remove_participant_select_lottery(callback: CallbackQuery, state: FSMContext): """Выбор розыгрыша для удаления участника""" if not await check_admin_access(callback.from_user.id): @@ -926,12 +944,12 @@ async def process_remove_participant(message: Message, state: FSMContext): await state.clear() return + user_first_name = user.first_name + username = f"@{user.username}" if user.username else "Нет username" removed = await ParticipationService.remove_participant(session, lottery_id, user.id) await state.clear() - username = f"@{user.username}" if user.username else "Нет username" - if removed: await message.answer( f"✅ Участник удалён из розыгрыша!\n\n" @@ -945,7 +963,7 @@ async def process_remove_participant(message: Message, state: FSMContext): ) else: await message.answer( - f"⚠️ Пользователь {user.first_name} не участвует в этом розыгрыше", + f"⚠️ Пользователь {user_first_name} не участвует либо розыгрыш закрыт", reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")] ]) @@ -1254,7 +1272,7 @@ async def start_bulk_add_participant(callback: CallbackQuery, state: FSMContext) await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_bulk_add_to_")) +@admin_router.callback_query(NumericCallback('admin_bulk_add_to_')) async def choose_users_bulk_add(callback: CallbackQuery, state: FSMContext): """Выбор пользователей для массового добавления""" if not await check_admin_access(callback.from_user.id): @@ -1266,6 +1284,10 @@ async def choose_users_bulk_add(callback: CallbackQuery, state: FSMContext): async with async_session_maker() as session: lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await state.clear() + await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True) + return text = f"📥 Массовое добавление в: {lottery.title}\n\n" text += "Введите список Telegram ID или username через запятую:\n\n" @@ -1319,6 +1341,10 @@ async def process_bulk_add_participant(message: Message, state: FSMContext): return results = await ParticipationService.add_participants_bulk(session, lottery_id, telegram_ids) lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await state.clear() + await message.answer('Розыгрыш не найден. Откройте меню заново.') + return await state.clear() @@ -1391,7 +1417,7 @@ async def start_bulk_remove_participant(callback: CallbackQuery, state: FSMConte await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_bulk_remove_from_")) +@admin_router.callback_query(NumericCallback('admin_bulk_remove_from_')) async def choose_users_bulk_remove(callback: CallbackQuery, state: FSMContext): """Выбор пользователей для массового удаления""" if not await check_admin_access(callback.from_user.id): @@ -1403,6 +1429,10 @@ async def choose_users_bulk_remove(callback: CallbackQuery, state: FSMContext): async with async_session_maker() as session: lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await state.clear() + await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True) + return text = f"📤 Массовое удаление из: {lottery.title}\n\n" text += "Введите список Telegram ID или username через запятую:\n\n" @@ -1456,6 +1486,10 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext): return results = await ParticipationService.remove_participants_bulk(session, lottery_id, telegram_ids) lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await state.clear() + await message.answer('Розыгрыш не найден. Откройте меню заново.') + return await state.clear() @@ -1484,7 +1518,7 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext): # ДОБАВЛЕНИЕ/УДАЛЕНИЕ УЧАСТНИКОВ В КОНКРЕТНОМ РОЗЫГРЫШЕ # ====================== -@admin_router.callback_query(F.data.startswith("admin_add_to_")) +@admin_router.callback_query(NumericCallback('admin_add_to_')) async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext): """Добавление участника в конкретный розыгрыш""" if not await is_admin(callback.from_user.id): @@ -1565,36 +1599,15 @@ async def process_add_to_lottery(message: Message, state: FSMContext): await state.clear() return + lottery_title = lottery.title # Добавляем участника if user: - success = await ParticipationService.add_participant(session, lottery_id, user.id) name = f"@{user.username}" if user.username else f"{user.first_name} (ID: {user.telegram_id})" + success = await ParticipationService.add_participant(session, lottery_id, user.id) else: - # Добавление по номеру счета - from sqlalchemy import select - from ..core.models import Participation - - # Проверяем, не добавлен ли уже этот счет - existing = await session.execute( - select(Participation).where( - Participation.lottery_id == lottery_id, - Participation.account_number == account_number - ) - ) - if existing.scalar_one_or_none(): - await message.answer( - f"⚠️ Счет {account_number} уже участвует в этом розыгрыше", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")] - ]) - ) - await state.clear() - return - - participation = Participation(lottery_id=lottery_id, account_number=account_number) - session.add(participation) - await session.commit() - success = True + from .account_services import AccountParticipationService + result = await AccountParticipationService.add_account_to_lottery(session, lottery_id, account_number) + success = result["success"] name = f"Счет: {account_number}" await state.clear() @@ -1603,7 +1616,7 @@ async def process_add_to_lottery(message: Message, state: FSMContext): await message.answer( f"✅ Участник добавлен!\n\n" f"👤 {name}\n" - f"🎯 Розыгрыш: {lottery.title}", + f"🎯 Розыгрыш: {lottery_title}", reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="➕ Добавить ещё", callback_data=f"admin_add_to_{lottery_id}")], [InlineKeyboardButton(text="👥 К участникам", callback_data=f"admin_participants_{lottery_id}")] @@ -1611,14 +1624,14 @@ async def process_add_to_lottery(message: Message, state: FSMContext): ) else: await message.answer( - f"⚠️ Участник уже добавлен в этот розыгрыш", + result["message"] if account_number else "⚠️ Участник уже добавлен либо розыгрыш закрыт", reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")] ]) ) -@admin_router.callback_query(F.data.startswith("admin_remove_from_")) +@admin_router.callback_query(NumericCallback('admin_remove_from_')) async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMContext): """Удаление участника из конкретного розыгрыша""" if not await is_admin(callback.from_user.id): @@ -1671,6 +1684,7 @@ async def process_remove_from_lottery(message: Message, state: FSMContext): await state.clear() return + lottery_title = lottery.title removed = False name = user_input @@ -1679,36 +1693,25 @@ async def process_remove_from_lottery(message: Message, state: FSMContext): username = user_input[1:] user = await UserService.get_user_by_username(session, username) if user: - removed = await ParticipationService.remove_participant(session, lottery_id, user.id) name = f"@{user.username}" if user.username else f"{user.first_name}" + removed = await ParticipationService.remove_participant(session, lottery_id, user.id) elif user_input.isdigit(): # Telegram ID telegram_id = int(user_input) user = await UserService.get_user_by_telegram_id(session, telegram_id) if user: - removed = await ParticipationService.remove_participant(session, lottery_id, user.id) name = f"@{user.username}" if user and user.username else f"ID: {telegram_id}" + removed = await ParticipationService.remove_participant(session, lottery_id, user.id) elif '-' in user_input: # Номер счета - from sqlalchemy import select, delete - from ..core.models import Participation - from src.utils.account_utils import parse_accounts_from_message accounts = parse_accounts_from_message(user_input) if accounts: - account_number = accounts[0] - result = await session.execute( - select(Participation).where( - Participation.lottery_id == lottery_id, - Participation.account_number == account_number - ) - ) - participation = result.scalar_one_or_none() - if participation: - await session.delete(participation) - await session.commit() - removed = True - name = f"Счет: {account_number}" + account_number = accounts[0].split()[-1] + from .account_services import AccountParticipationService + result = await AccountParticipationService.remove_account_from_lottery(session, lottery_id, account_number) + removed = result["success"] + name = f"Счет: {account_number}" await state.clear() @@ -1716,7 +1719,7 @@ async def process_remove_from_lottery(message: Message, state: FSMContext): await message.answer( f"✅ Участник удалён!\n\n" f"👤 {name}\n" - f"🎯 Розыгрыш: {lottery.title}", + f"🎯 Розыгрыш: {lottery_title}", reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="➖ Удалить ещё", callback_data=f"admin_remove_from_{lottery_id}")], [InlineKeyboardButton(text="👥 К участникам", callback_data=f"admin_participants_{lottery_id}")] @@ -1724,7 +1727,7 @@ async def process_remove_from_lottery(message: Message, state: FSMContext): ) else: await message.answer( - f"⚠️ Участник не найден в этом розыгрыше", + "⚠️ Участник не найден либо розыгрыш закрыт", reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")] ]) @@ -1735,7 +1738,7 @@ async def process_remove_from_lottery(message: Message, state: FSMContext): # ПРОВЕРКА ПОБЕДИТЕЛЕЙ И ПОВТОРНЫЙ РОЗЫГРЫШ # ====================== -@admin_router.callback_query(F.data.startswith("admin_check_winners_")) +@admin_router.callback_query(NumericCallback('admin_check_winners_')) async def check_winners(callback: CallbackQuery): """Проверка подтверждения победителей""" if not await is_admin(callback.from_user.id): @@ -1781,7 +1784,7 @@ async def check_winners(callback: CallbackQuery): if winner.account_number: name = f"Счет: {winner.account_number}" elif winner.user: - name = f"@{winner.user.username}" if winner.user.username else winner.user.first_name + name = _winner_name(winner) else: name = f"ID: {winner.user_id}" @@ -1804,7 +1807,7 @@ async def check_winners(callback: CallbackQuery): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_redraw_")) +@admin_router.callback_query(NumericCallback('admin_redraw_')) async def redraw_lottery(callback: CallbackQuery): """Повторный розыгрыш для неподтверждённых призов""" if not await is_admin(callback.from_user.id): @@ -1837,7 +1840,7 @@ async def redraw_lottery(callback: CallbackQuery): if winner.account_number: name = f"Счет: {winner.account_number}" elif winner.user: - name = f"@{winner.user.username}" if winner.user.username else winner.user.first_name + name = _winner_name(winner) else: name = f"ID: {winner.user_id}" @@ -1855,7 +1858,7 @@ async def redraw_lottery(callback: CallbackQuery): ) -@admin_router.callback_query(F.data.startswith("admin_redraw_confirm_")) +@admin_router.callback_query(NumericCallback('admin_redraw_confirm_')) async def confirm_redraw(callback: CallbackQuery): if not await check_admin_access(callback.from_user.id): await callback.answer("Недостаточно прав", show_alert=True) @@ -1880,7 +1883,7 @@ async def confirm_redraw(callback: CallbackQuery): # УДАЛЕНИЕ РОЗЫГРЫША # ====================== -@admin_router.callback_query(F.data.startswith("admin_del_lottery_")) +@admin_router.callback_query(NumericCallback('admin_del_lottery_')) async def delete_lottery_confirm(callback: CallbackQuery): """Подтверждение удаления розыгрыша""" if not await is_admin(callback.from_user.id): @@ -1914,7 +1917,7 @@ async def delete_lottery_confirm(callback: CallbackQuery): ) -@admin_router.callback_query(F.data.startswith("admin_del_lottery_yes_")) +@admin_router.callback_query(NumericCallback('admin_del_lottery_yes_')) async def delete_lottery_execute(callback: CallbackQuery): """Выполнение удаления розыгрыша""" if not await is_admin(callback.from_user.id): @@ -1987,7 +1990,7 @@ async def start_bulk_add_accounts(callback: CallbackQuery, state: FSMContext): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_bulk_add_accounts_to_")) +@admin_router.callback_query(NumericCallback('admin_bulk_add_accounts_to_')) async def choose_accounts_bulk_add(callback: CallbackQuery, state: FSMContext): """Выбор номеров счетов для массового добавления""" if not await check_admin_access(callback.from_user.id): @@ -1999,6 +2002,10 @@ async def choose_accounts_bulk_add(callback: CallbackQuery, state: FSMContext): async with async_session_maker() as session: lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await state.clear() + await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True) + return text = f"🏦 Массовое добавление в: {lottery.title}\n\n" text += "Введите список номеров счетов через запятую или новую строку:\n\n" @@ -2039,6 +2046,10 @@ async def process_bulk_add_accounts(message: Message, state: FSMContext): # Массовое добавление по номерам счетов results = await ParticipationService.add_participants_by_accounts_bulk(session, lottery_id, account_inputs) lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await state.clear() + await message.answer('Розыгрыш не найден. Откройте меню заново.') + return await state.clear() @@ -2116,7 +2127,7 @@ async def start_bulk_remove_accounts(callback: CallbackQuery, state: FSMContext) await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_bulk_remove_accounts_from_")) +@admin_router.callback_query(NumericCallback('admin_bulk_remove_accounts_from_')) async def choose_accounts_bulk_remove(callback: CallbackQuery, state: FSMContext): """Выбор номеров счетов для массового удаления""" if not await check_admin_access(callback.from_user.id): @@ -2128,6 +2139,10 @@ async def choose_accounts_bulk_remove(callback: CallbackQuery, state: FSMContext async with async_session_maker() as session: lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await state.clear() + await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True) + return text = f"🏦 Массовое удаление из: {lottery.title}\n\n" text += "Введите список номеров счетов через запятую или новую строку:\n\n" @@ -2167,6 +2182,10 @@ async def process_bulk_remove_accounts(message: Message, state: FSMContext): # Массовое удаление по номерам счетов results = await ParticipationService.remove_participants_by_accounts_bulk(session, lottery_id, account_inputs) lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await state.clear() + await message.answer('Розыгрыш не найден. Откройте меню заново.') + return await state.clear() @@ -2254,7 +2273,7 @@ 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") +@admin_router.callback_query(F.data == "admin_participants_summary") async def show_participants_report(callback: CallbackQuery): """Отчет по участникам""" if not await check_admin_access(callback.from_user.id): @@ -2358,7 +2377,7 @@ async def start_edit_lottery(callback: CallbackQuery, state: FSMContext): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_edit_field_")) +@admin_router.callback_query(NumericCallback("admin_edit_field_", suffixes=("title", "description", "prizes"))) async def handle_edit_field(callback: CallbackQuery, state: FSMContext): """Обработка выбора поля для редактирования""" if not await check_admin_access(callback.from_user.id): @@ -2399,7 +2418,7 @@ async def handle_edit_field(callback: CallbackQuery, state: FSMContext): await callback.answer() -@admin_router.callback_query(F.data.startswith("admin_edit_")) +@admin_router.callback_query(NumericCallback('admin_edit_')) async def redirect_to_edit_lottery(callback: CallbackQuery, state: FSMContext): """Редирект на редактирование розыгрыша из детального просмотра""" if not await check_admin_access(callback.from_user.id): @@ -2420,7 +2439,7 @@ async def redirect_to_edit_lottery(callback: CallbackQuery, state: FSMContext): await choose_edit_field(callback, state) -@admin_router.callback_query(F.data.startswith("admin_edit_lottery_select_")) +@admin_router.callback_query(NumericCallback('admin_edit_lottery_select_')) async def choose_edit_field(callback: CallbackQuery, state: FSMContext): """Выбор поля для редактирования""" if not await check_admin_access(callback.from_user.id): @@ -2466,7 +2485,7 @@ async def show_edit_fields(message: Message, lottery_id: int, edit=False): await (message.edit_text if edit else message.answer)(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_toggle_active_")) +@admin_router.callback_query(NumericCallback('admin_toggle_active_')) async def toggle_lottery_active(callback: CallbackQuery, state: FSMContext): """Переключить активность розыгрыша""" if not await check_admin_access(callback.from_user.id): @@ -2530,7 +2549,7 @@ async def start_finish_lottery(callback: CallbackQuery): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_confirm_finish_")) +@admin_router.callback_query(NumericCallback('admin_confirm_finish_')) async def confirm_finish_lottery(callback: CallbackQuery): """Подтвердить завершение розыгрыша""" if not await check_admin_access(callback.from_user.id): @@ -2541,6 +2560,9 @@ async def confirm_finish_lottery(callback: CallbackQuery): async with async_session_maker() as session: lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True) + return count = await ParticipationService.get_participants_count(session, lottery_id) text = f"🏁 Завершение розыгрыша\n\n" @@ -2559,7 +2581,7 @@ async def confirm_finish_lottery(callback: CallbackQuery): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_do_finish_")) +@admin_router.callback_query(NumericCallback('admin_do_finish_')) async def do_finish_lottery(callback: CallbackQuery): """Выполнить завершение розыгрыша""" if not await check_admin_access(callback.from_user.id): @@ -2630,7 +2652,7 @@ async def start_delete_lottery(callback: CallbackQuery): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_confirm_delete_")) +@admin_router.callback_query(NumericCallback('admin_confirm_delete_')) async def confirm_delete_lottery(callback: CallbackQuery): """Подтвердить удаление розыгрыша""" if not await check_admin_access(callback.from_user.id): @@ -2641,6 +2663,9 @@ async def confirm_delete_lottery(callback: CallbackQuery): async with async_session_maker() as session: lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True) + return count = await ParticipationService.get_participants_count(session, lottery_id) text = f"🗑️ Удаление розыгрыша\n\n" @@ -2662,7 +2687,7 @@ async def confirm_delete_lottery(callback: CallbackQuery): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_do_delete_")) +@admin_router.callback_query(NumericCallback('admin_do_delete_')) async def do_delete_lottery(callback: CallbackQuery): """Выполнить удаление розыгрыша""" if not await check_admin_access(callback.from_user.id): @@ -2673,6 +2698,9 @@ async def do_delete_lottery(callback: CallbackQuery): async with async_session_maker() as session: lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True) + return lottery_title = lottery.title success = await LotteryService.delete_lottery(session, lottery_id) @@ -2757,7 +2785,7 @@ async def start_set_manual_winner(callback: CallbackQuery, state: FSMContext): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_set_winner_")) +@admin_router.callback_query(NumericCallback('admin_set_winner_')) async def handle_set_winner_from_lottery(callback: CallbackQuery, state: FSMContext): """Обработчик для кнопки 'Установить победителя' из карточки розыгрыша""" if not await check_admin_access(callback.from_user.id): @@ -2771,7 +2799,7 @@ async def handle_set_winner_from_lottery(callback: CallbackQuery, state: FSMCont await choose_winner_place(callback, state) -@admin_router.callback_query(F.data.startswith("admin_choose_winner_lottery_")) +@admin_router.callback_query(NumericCallback('admin_choose_winner_lottery_')) async def choose_winner_place(callback: CallbackQuery, state: FSMContext): """Выбор места для победителя""" if not await check_admin_access(callback.from_user.id): @@ -2929,12 +2957,16 @@ async def process_winner_user(message: Message, state: FSMContext): data = await state.get_data() async with async_session_maker() as session: - success = await LotteryService.set_manual_winner( - session, - data['lottery_id'], - data['place'], - telegram_id - ) + if is_account: + from .account_services import AccountParticipationService + result = await AccountParticipationService.set_account_as_winner( + session, data['lottery_id'], user_input, data['place'] + ) + success = result["success"] + else: + success = await LotteryService.set_manual_winner( + session, data['lottery_id'], data['place'], telegram_id + ) await state.clear() @@ -3008,7 +3040,7 @@ async def list_all_winners(callback: CallbackQuery): text += f"📅 {lottery.created_at.strftime('%d.%m.%Y')}\n" for winner in sorted(winners_list, key=lambda w: w.place): - username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name + username = _winner_name(winner) manual_mark = "🔧" if winner.is_manual else "🎲" text += f" {manual_mark} {winner.place} место: {username} - {winner.prize}\n" @@ -3072,7 +3104,7 @@ async def edit_winner_start(callback: CallbackQuery): ) -@admin_router.callback_query(F.data.startswith("admin_edit_winner_lottery_")) +@admin_router.callback_query(NumericCallback('admin_edit_winner_lottery_')) async def edit_winner_select_place(callback: CallbackQuery, state: FSMContext): """Выбор места победителя для редактирования""" if not await check_admin_access(callback.from_user.id): @@ -3083,6 +3115,10 @@ async def edit_winner_select_place(callback: CallbackQuery, state: FSMContext): async with async_session_maker() as session: lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await state.clear() + await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True) + return winners = await LotteryService.get_winners(session, lottery_id) if not winners: @@ -3093,7 +3129,7 @@ async def edit_winner_select_place(callback: CallbackQuery, state: FSMContext): buttons = [] for winner in winners: - username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name + username = _winner_name(winner) buttons.append([ InlineKeyboardButton( text=f"🏆 {winner.place} место: {username} - {winner.prize}", @@ -3106,7 +3142,7 @@ async def edit_winner_select_place(callback: CallbackQuery, state: FSMContext): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_edit_winner_id_")) +@admin_router.callback_query(NumericCallback('admin_edit_winner_id_')) async def edit_winner_details(callback: CallbackQuery): """Показать детали победителя (пока просто информационное сообщение)""" if not await check_admin_access(callback.from_user.id): @@ -3128,7 +3164,7 @@ async def edit_winner_details(callback: CallbackQuery): await callback.answer("❌ Победитель не найден", show_alert=True) return - username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name + username = _winner_name(winner) manual_mark = "🔧 Установлен вручную" if winner.is_manual else "🎲 Выбран случайно" text = f"📝 Информация о победителе\n\n" @@ -3136,7 +3172,7 @@ async def edit_winner_details(callback: CallbackQuery): text += f"🏆 Место: {winner.place}\n" text += f"💰 Приз: {winner.prize}\n" text += f"👤 Пользователь: {username}\n" - text += f"🆔 ID: {winner.user.telegram_id}\n" + text += f"🆔 ID: {winner.user.telegram_id if winner.user else '-'}\n" text += f"📊 Тип: {manual_mark}\n" text += f"📅 Дата: {winner.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" text += "ℹ️ Редактирование победителей доступно через удаление и повторное добавление." @@ -3195,7 +3231,7 @@ async def remove_winner_start(callback: CallbackQuery): ) -@admin_router.callback_query(F.data.startswith("admin_remove_winner_lottery_")) +@admin_router.callback_query(NumericCallback('admin_remove_winner_lottery_')) async def remove_winner_select_place(callback: CallbackQuery): """Выбор победителя для удаления""" if not await check_admin_access(callback.from_user.id): @@ -3206,6 +3242,9 @@ async def remove_winner_select_place(callback: CallbackQuery): async with async_session_maker() as session: lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True) + return winners = await LotteryService.get_winners(session, lottery_id) if not winners: @@ -3216,7 +3255,7 @@ async def remove_winner_select_place(callback: CallbackQuery): buttons = [] for winner in winners: - username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name + username = _winner_name(winner) buttons.append([ InlineKeyboardButton( text=f"🏆 {winner.place} место: {username} - {winner.prize}", @@ -3229,7 +3268,7 @@ async def remove_winner_select_place(callback: CallbackQuery): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_confirm_remove_winner_")) +@admin_router.callback_query(NumericCallback('admin_confirm_remove_winner_')) async def confirm_remove_winner(callback: CallbackQuery): """Подтверждение удаления победителя""" if not await check_admin_access(callback.from_user.id): @@ -3251,7 +3290,7 @@ async def confirm_remove_winner(callback: CallbackQuery): await callback.answer("❌ Победитель не найден", show_alert=True) return - username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name + username = _winner_name(winner) text = f"⚠️ Подтверждение удаления\n\n" text += f"Вы действительно хотите удалить победителя?\n\n" @@ -3271,7 +3310,7 @@ async def confirm_remove_winner(callback: CallbackQuery): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_do_remove_winner_")) +@admin_router.callback_query(NumericCallback('admin_do_remove_winner_')) async def do_remove_winner(callback: CallbackQuery): """Выполнение удаления победителя""" if not await check_admin_access(callback.from_user.id): @@ -3296,7 +3335,7 @@ async def do_remove_winner(callback: CallbackQuery): return lottery_id = winner.lottery_id - username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name + username = _winner_name(winner) # Удаляем победителя from src.core.models import WinnerVerification @@ -3358,7 +3397,7 @@ async def choose_lottery_for_draw(callback: CallbackQuery): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.regexp(r"^admin_conduct_\d+$")) +@admin_router.callback_query(NumericCallback("admin_conduct_")) async def conduct_lottery_draw_confirm(callback: CallbackQuery): """Запрос подтверждения проведения розыгрыша""" if not await check_admin_access(callback.from_user.id): @@ -3413,7 +3452,7 @@ async def conduct_lottery_draw_confirm(callback: CallbackQuery): await safe_edit_message(callback, text, InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_conduct_confirmed_")) +@admin_router.callback_query(NumericCallback('admin_conduct_confirmed_')) async def conduct_lottery_draw(callback: CallbackQuery): """Проведение розыгрыша после подтверждения""" logger.info(f"🎯 conduct_lottery_draw HANDLER TRIGGERED! data={callback.data}, user={callback.from_user.id}") @@ -3487,7 +3526,7 @@ async def conduct_lottery_draw(callback: CallbackQuery): if winner.account_number: text += f"{winner.place} место: {winner.account_number}\n" elif winner.user: - username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name + username = _winner_name(winner) text += f"{winner.place} место: {username}\n" else: text += f"{winner.place} место: ID {winner.user_id}\n" @@ -3903,7 +3942,7 @@ async def show_winner_display_settings(callback: CallbackQuery, state: FSMContex await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_set_display_")) +@admin_router.callback_query(NumericCallback('admin_set_display_')) async def choose_display_type(callback: CallbackQuery, state: FSMContext): """Выбор типа отображения для конкретного розыгрыша""" if not await check_admin_access(callback.from_user.id): @@ -3915,6 +3954,10 @@ async def choose_display_type(callback: CallbackQuery, state: FSMContext): async with async_session_maker() as session: lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await state.clear() + await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True) + return current_type = getattr(lottery, 'winner_display_type', 'username') @@ -3937,7 +3980,7 @@ async def choose_display_type(callback: CallbackQuery, state: FSMContext): await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) -@admin_router.callback_query(F.data.startswith("admin_apply_display_")) +@admin_router.callback_query(NumericCallback("admin_apply_display_", suffixes=("username", "chat_id", "account_number"))) async def apply_display_type(callback: CallbackQuery, state: FSMContext): """Применить выбранный тип отображения""" import logging @@ -3967,6 +4010,10 @@ async def apply_display_type(callback: CallbackQuery, state: FSMContext): logger.info(f"💾 Результат сохранения: {success}") lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery: + await state.clear() + await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True) + return logger.info(f"📋 Получен розыгрыш: {lottery.title if lottery else 'None'}") if success: @@ -4078,7 +4125,7 @@ async def show_recent_messages(callback: CallbackQuery, page: int = 0): ) -@admin_router.callback_query(F.data.startswith("admin_message_view_")) +@admin_router.callback_query(NumericCallback('admin_message_view_')) async def view_message(callback: CallbackQuery): """Просмотр конкретного сообщения""" if not await check_admin_access(callback.from_user.id): @@ -4164,7 +4211,7 @@ async def view_message(callback: CallbackQuery): ) -@admin_router.callback_query(F.data.startswith("admin_message_delete_")) +@admin_router.callback_query(NumericCallback('admin_message_delete_')) async def delete_message(callback: CallbackQuery): """Удалить сообщение пользователя""" if not await check_admin_access(callback.from_user.id): @@ -4230,7 +4277,7 @@ async def delete_message(callback: CallbackQuery): await show_recent_messages(callback, 0) -@admin_router.callback_query(F.data.startswith("admin_messages_user_")) +@admin_router.callback_query(NumericCallback('admin_messages_user_')) async def show_user_messages(callback: CallbackQuery): """Показать все сообщения конкретного пользователя""" if not await check_admin_access(callback.from_user.id): @@ -4335,7 +4382,7 @@ async def _notify_all_participants_about_results(bot, session: AsyncSession, lot if winner.user: nickname = winner.user.nickname if not nickname: - display_name = f"@{winner.user.username}" if winner.user.username else winner.user.first_name + display_name = _winner_name(winner) elif winner.account_number: from ..core.registration_services import AccountService owner = await AccountService.get_account_owner(session, winner.account_number) @@ -4630,7 +4677,7 @@ async def broadcast_type_channel_or_group(callback: CallbackQuery, state: FSMCon ) -@admin_router.callback_query(F.data.startswith("broadcast_select_channel_")) +@admin_router.callback_query(NumericCallback('broadcast_select_channel_')) async def broadcast_select_channel(callback: CallbackQuery, state: FSMContext): """Выбран канал/группа - запрос сообщения""" if not await check_admin_access(callback.from_user.id): @@ -5315,7 +5362,7 @@ async def admin_users_search_process(message: Message, state: FSMContext): await state.clear() -@admin_router.callback_query(F.data.startswith("admin_users_list:")) +@admin_router.callback_query(NumericCallback('admin_users_list:')) async def admin_users_list(callback: CallbackQuery, state: FSMContext): """Список всех пользователей с пагинацией""" if not await check_admin_access(callback.from_user.id): @@ -5374,7 +5421,7 @@ async def admin_users_list(callback: CallbackQuery, state: FSMContext): ) -@admin_router.callback_query(F.data.startswith("admin_users_banned:")) +@admin_router.callback_query(NumericCallback('admin_users_banned:')) async def admin_users_banned_list(callback: CallbackQuery, state: FSMContext): """Список заблокированных пользователей""" if not await check_admin_access(callback.from_user.id): @@ -5438,7 +5485,7 @@ async def admin_users_banned_list(callback: CallbackQuery, state: FSMContext): ) -@admin_router.callback_query(F.data.startswith("admin_user_view:")) +@admin_router.callback_query(NumericCallback('admin_user_view:')) async def admin_user_view(callback: CallbackQuery, state: FSMContext): """Просмотр информации о пользователе""" if not await check_admin_access(callback.from_user.id): @@ -5482,7 +5529,7 @@ async def admin_user_view(callback: CallbackQuery, state: FSMContext): ) -@admin_router.callback_query(F.data.startswith("admin_user_ban:")) +@admin_router.callback_query(NumericCallback('admin_user_ban:')) async def admin_user_ban(callback: CallbackQuery, state: FSMContext): """Заблокировать пользователя в чате""" if not await check_admin_access(callback.from_user.id): @@ -5504,7 +5551,7 @@ async def admin_user_ban(callback: CallbackQuery, state: FSMContext): await callback.answer("❌ Ошибка блокировки", show_alert=True) -@admin_router.callback_query(F.data.startswith("admin_user_unban:")) +@admin_router.callback_query(NumericCallback('admin_user_unban:')) async def admin_user_unban(callback: CallbackQuery, state: FSMContext): """Разблокировать пользователя в чате""" if not await check_admin_access(callback.from_user.id): diff --git a/src/handlers/cashier_handlers.py b/src/handlers/cashier_handlers.py index 68d6015..a0aa9d3 100644 --- a/src/handlers/cashier_handlers.py +++ b/src/handlers/cashier_handlers.py @@ -1,4 +1,5 @@ """Independent cashier dialogs; staff assignment is restricted to super admins.""" +from src.filters.numeric_callback import NumericCallback from src.filters.dialog_input import NotCommand from aiogram import F, Router from src.filters.case_insensitive import CaseInsensitiveCommand as Command @@ -49,10 +50,15 @@ async def cashier_menu_callback(callback: CallbackQuery, state: FSMContext): await cashier_menu.__wrapped__(callback.message, state) -@cashier_router.callback_query(F.data.startswith("cash_add:")) +@cashier_router.callback_query(NumericCallback('cash_add:')) async def choose_draw(callback: CallbackQuery, state: FSMContext): lottery_id = int(callback.data.split(":")[1]) await state.clear() + async with async_session_maker() as session: + lottery = await LotteryService.get_lottery(session, lottery_id) + if not lottery or not lottery.is_active or lottery.is_completed: + await callback.answer("Розыгрыш закрыт или удалён. Откройте /cashier заново.", show_alert=True) + return await state.update_data(lottery_id=lottery_id) await state.set_state(CashierStates.accounts) await callback.answer() diff --git a/src/handlers/input_fallback.py b/src/handlers/input_fallback.py index e86e0cb..7c824f1 100644 --- a/src/handlers/input_fallback.py +++ b/src/handlers/input_fallback.py @@ -1,11 +1,16 @@ """Explain unmatched input instead of silently dropping it or broadcasting it.""" from aiogram import F, Router from aiogram.fsm.context import FSMContext -from aiogram.types import Message +from aiogram.types import Message, CallbackQuery input_fallback_router = Router(name="input_fallback") +@input_fallback_router.callback_query() +async def unmatched_button(callback: CallbackQuery): + await callback.answer("Кнопка устарела или недоступна. Откройте меню заново через /start.", show_alert=True) + + @input_fallback_router.message(F.text.lstrip().startswith("/")) async def unknown_command(message: Message): await message.answer("Команда не распознана или недоступна на этом шаге. /help — справка, /cancel — отменить текущий ввод.") diff --git a/src/utils/text_output.py b/src/utils/text_output.py index cb013b5..961c48d 100644 --- a/src/utils/text_output.py +++ b/src/utils/text_output.py @@ -10,3 +10,10 @@ async def answer_plain_pages(message, text, **kwargs): await message.answer(text[:end], parse_mode=None) text = text[end:] return await message.answer(text, parse_mode=None, **kwargs) + + +async def edit_plain_pages(message, text, **kwargs): + if len(text) <= 2000: + return await message.edit_text(text, parse_mode=None, **kwargs) + await message.edit_text("Результат операции — в сообщениях ниже.", parse_mode=None) + return await answer_plain_pages(message, text, **kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index 253fef6..4254c27 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ """All tests use disposable databases and a synthetic Telegram token.""" import os import tempfile +import sys from pathlib import Path import pytest_asyncio @@ -12,7 +13,7 @@ os.environ["DATABASE_URL"] = os.getenv("TEST_DATABASE_URL") or ( os.environ["BOT_TOKEN"] = "123456:TEST_TOKEN_FOR_ISOLATED_TESTS" os.environ["ADMIN_IDS"] = "900001" os.environ["CASHIER_IDS"] = "900002" -os.environ["REDIS_URL"] = "" +os.environ["REDIS_URL"] = os.getenv("TEST_REDIS_URL", "") from src.core.database import Base, engine, async_session_maker from src.core import models @@ -20,8 +21,18 @@ from src.core import models @pytest_asyncio.fixture(autouse=True) async def database(): + if os.getenv("TEST_REDIS_URL"): + from redis.asyncio import Redis + redis = Redis.from_url(os.environ["TEST_REDIS_URL"]) + # Only the synthetic bot's FSM namespace in the disposable test service. + keys = [key async for key in redis.scan_iter(match="fsm:123456:*")] + if keys: + await redis.delete(*keys) + await redis.aclose() async with engine.begin() as connection: await connection.run_sync(Base.metadata.drop_all) await connection.run_sync(Base.metadata.create_all) yield async_session_maker + if os.getenv("TEST_REDIS_URL") and "main" in sys.modules: + await sys.modules["main"].dp.storage.close() await engine.dispose() diff --git a/tests/test_operator_scenarios.py b/tests/test_operator_scenarios.py new file mode 100644 index 0000000..9f210f6 --- /dev/null +++ b/tests/test_operator_scenarios.py @@ -0,0 +1,321 @@ +"""Drive real routers with disposable data; never call the Telegram network.""" +import asyncio +import logging +from html.parser import HTMLParser + +from aiogram import Bot +from sqlalchemy import func, select +import pytest + +from src.core.database import async_session_maker +from src.core.models import Account, Lottery, Participation, User, Winner +from src.core.services import LotteryService, UserService +from src.handlers.account_services import AccountParticipationService +from test_dispatcher import TelegramStub, event +from test_text_inputs import context + +ADMIN = 900001 +CLIENT = 960001 +ACCOUNT = "11-22-33-44-55-66-77" +SECOND = "88-99-00-11-22-33-44" + + +class TelegramHTML(HTMLParser): + def __init__(self): + super().__init__() + self.stack = [] + + def handle_starttag(self, tag, attrs): + assert tag in {"b", "strong", "i", "em", "u", "ins", "s", "strike", "del", "span", + "tg-spoiler", "a", "code", "pre", "blockquote", "tg-emoji"}, tag + self.stack.append(tag) + + def handle_endtag(self, tag): + assert self.stack and self.stack.pop() == tag, tag + + +class CheckedTelegram(TelegramStub): + async def make_request(self, bot, method, timeout=None): + text = getattr(method, "text", None) + if text and method.__api_method__ in {"sendMessage", "editMessageText"}: + assert len(text.encode("utf-16-le")) // 2 <= 4096, "Telegram text exceeds 4096" + if getattr(method, "parse_mode", None) == "HTML": + parser = TelegramHTML() + parser.feed(text) + assert not parser.stack + markup = getattr(method, "reply_markup", None) + if markup and hasattr(markup, "inline_keyboard"): + for row in markup.inline_keyboard: + for button in row: + if button.callback_data: + assert 1 <= len(button.callback_data.encode()) <= 64 + return await super().make_request(bot, method, timeout) + + +async def send(actor=ADMIN, *, text=None, callback=None, stub=None): + import main + if not main.dp.sub_routers: + main.configure_dispatcher() + transport = stub or CheckedTelegram() + bot = Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS", session=transport) + await main.dp.feed_update(bot, event(actor, text=text, callback_data=callback)) + return transport.calls + + +def output(calls): + return "\n".join(getattr(call, "text", "") or "" for call in calls) + + +def assert_clean(caplog): + failures = [r for r in caplog.records if r.levelno >= logging.ERROR] + assert not failures, [(r.message, str(r.exc_info[1]) if r.exc_info else "") for r in failures] + + +async def seed(*, title="Test draw", participants=True): + await context(ADMIN).clear() + await context(CLIENT).clear() + await context(900002).clear() + async with async_session_maker() as session: + admin = await UserService.get_or_create_user(session, ADMIN, first_name="Admin") + client = await UserService.get_or_create_user(session, CLIENT, first_name="Client") + client.club_card_number = "0007" + client.is_registered = True + client.verification_code = "TEST1234" + await session.commit() + lottery = await LotteryService.create_lottery(session, title, "Description", ["Prize A", "Prize B"], admin.id) + lottery_id, client_id = lottery.id, client.id + session.add_all([Account(account_number=ACCOUNT, owner_id=client_id), + Account(account_number=SECOND, owner_id=client_id)]) + await session.commit() + if participants: + await AccountParticipationService.add_accounts_bulk(session, lottery_id, [ACCOUNT, SECOND]) + return lottery_id, client_id + + +@pytest.mark.parametrize("route", ["detected", "command", "cashier", "bulk"]) +async def test_duplicate_account_reports_and_finishes_dialog(route, caplog): + lottery_id, _ = await seed(title="Draw & friends") + actor = 900002 if route == "cashier" else ADMIN + if route == "detected": + await send(text=f"0007 {ACCOUNT}") + await send(callback="account_action:add_to_lottery") + calls = await send(callback=f"add_accounts_to:{lottery_id}") + elif route == "command": + await context(actor).update_data(accounts=[{"account_number": ACCOUNT}]) + calls = await send(callback=f"add_to_lottery_{lottery_id}") + elif route == "cashier": + await send(actor, text="/cashier") + await send(actor, callback=f"cash_add:{lottery_id}") + calls = await send(actor, text=ACCOUNT) + else: + await send(callback=f"admin_bulk_add_accounts_to_{lottery_id}") + calls = await send(text=ACCOUNT) + assert_clean(caplog) + assert "уже участвует" in output(calls) + assert await context(actor).get_state() is None + assert not await context(actor).get_data() + async with async_session_maker() as session: + assert await session.scalar(select(func.count(Participation.id))) == 2 + + +@pytest.mark.parametrize("callback", [ + "admin_participants_by_lottery", "admin_participants_report", "admin_edit_winner", + "admin_edit_winner_lottery_{id}", "admin_edit_lottery_select_{id}", + "admin_lottery_detail_{id}", "admin_participants_{id}", "admin_stats", + "admin_list_all_participants", "admin_users_list:1", "admin_winners", + "admin_system_info", "admin_broadcast", "admin_settings", +]) +async def test_admin_menu_routes(callback, caplog): + lottery_id, _ = await seed() + calls = await send(callback=callback.format(id=lottery_id)) + assert_clean(caplog) + assert calls, callback + assert "Кнопка устарела" not in output(calls) + + +async def test_delete_confirmation_executes_instead_of_reopening(caplog): + lottery_id, _ = await seed() + await send(callback=f"admin_del_lottery_{lottery_id}") + await send(callback=f"admin_del_lottery_yes_{lottery_id}") + assert_clean(caplog) + async with async_session_maker() as session: + assert await session.get(Lottery, lottery_id) is None + + +async def test_redraw_confirmation_reaches_execution(caplog): + lottery_id, _ = await seed() + async with async_session_maker() as session: + await LotteryService.conduct_draw(session, lottery_id) + calls = await send(callback=f"admin_redraw_confirm_{lottery_id}") + assert_clean(caplog) + assert "Нет просроченных" in output(calls) + + +@pytest.mark.parametrize("callback", ["winner_lottery:1", "winner_place:1", "add_accounts_to:1"]) +async def test_expired_account_buttons_recover_without_error(callback, caplog): + await seed() + calls = await send(callback=callback) + assert_clean(caplog) + assert calls + + +async def test_client_account_registration_to_cashier_prize_claim(caplog): + lottery_id, _ = await seed(participants=False) + await send(CLIENT, text="/start") + calls = await send(CLIENT, text="/my_accounts") + assert ACCOUNT in output(calls) + await send(900002, callback=f"cash_add:{lottery_id}") + await send(900002, text=f"0007 {ACCOUNT}\n0007 {SECOND}") + await send(callback=f"admin_conduct_confirmed_{lottery_id}") + calls = await send(900002, text=f"/verify_winner TEST1234 {lottery_id}") + assert "подтвержден" in output(calls) + assert_clean(caplog) + async with async_session_maker() as session: + assert await session.scalar(select(func.count(Winner.id)).where(Winner.is_claimed.is_(True))) == 1 + + +async def test_two_cashiers_share_draw_without_duplicate_tickets(caplog): + lottery_id, _ = await seed(participants=False) + async with async_session_maker() as session: + cashier = await UserService.get_or_create_user(session, 960002) + cashier.is_cashier = True + await session.commit() + for actor in (900002, 960002): + await send(actor, callback=f"cash_add:{lottery_id}") + calls = await asyncio.gather(send(900002, text=ACCOUNT), send(960002, text=ACCOUNT), send(CLIENT, text="/start")) + assert_clean(caplog) + assert sum("Добавлено: 1" in output(result) for result in calls) == 1 + async with async_session_maker() as session: + assert await session.scalar(select(func.count(Participation.id))) == 1 + + +async def test_new_card_ticket_is_created_and_visible_to_client(caplog): + lottery_id, client_id = await seed(participants=False) + new_account = "01-02-03-04-05-06-07" + await send(text=f"0007 {new_account}") + await send(callback="account_action:add_to_lottery") + calls = await send(callback=f"add_accounts_to:{lottery_id}") + assert "Добавлено: 1" in output(calls) + assert new_account in output(await send(CLIENT, text="/my_accounts")) + async with async_session_maker() as session: + account = await session.scalar(select(Account).where(Account.account_number == new_account)) + participation = await session.scalar(select(Participation).where(Participation.account_number == new_account)) + assert account.owner_id == participation.user_id == client_id + assert participation.account_id == account.id + assert_clean(caplog) + + +@pytest.mark.parametrize("route", ["admin_add_part_to_{id}", "admin_add_to_{id}", "admin_bulk_add_to_{id}"]) +async def test_existing_user_addition_can_be_repeated(route, caplog): + lottery_id, _ = await seed() + for _ in range(2): + await send(callback=route.format(id=lottery_id)) + await send(text=str(CLIENT)) + assert_clean(caplog) + + +@pytest.mark.parametrize("action", ["add", "remove"]) +async def test_detail_account_operations_cannot_change_closed_draw(action, caplog): + lottery_id, _ = await seed() + async with async_session_maker() as session: + await LotteryService.conduct_draw(session, lottery_id) + await send(callback=f"admin_{'add_to' if action == 'add' else 'remove_from'}_{lottery_id}") + await send(text="01-02-03-04-05-06-07" if action == "add" else ACCOUNT) + assert_clean(caplog) + async with async_session_maker() as session: + assert await session.scalar(select(func.count(Participation.id))) == 2 + + +@pytest.mark.parametrize("callback", [ + "admin_add_part_to_99999", "admin_bulk_add_to_99999", "admin_bulk_remove_from_99999", + "admin_bulk_add_accounts_to_99999", "admin_bulk_remove_accounts_from_99999", + "admin_confirm_finish_99999", "admin_confirm_delete_99999", "admin_set_display_99999", +]) +async def test_deleted_lottery_buttons_recover(callback, caplog): + await seed() + assert output(await send(callback=callback)) + assert_clean(caplog) + + +async def test_anonymous_winner_admin_pages(caplog): + lottery_id, _ = await seed() + async with async_session_maker() as session: + winner = Winner(lottery_id=lottery_id, account_number="01-02-03-04-05-06-07", place=1, prize="Prize") + session.add(winner) + await session.commit() + winner_id = winner.id + for callback in (f"admin_edit_winner_lottery_{lottery_id}", f"admin_edit_winner_id_{winner_id}", + f"admin_remove_winner_lottery_{lottery_id}", f"admin_confirm_remove_winner_{winner_id}", + f"admin_do_remove_winner_{winner_id}"): + await send(callback=callback) + assert_clean(caplog) + + +async def test_manual_winner_preserves_exact_account_and_rejects_second_place(caplog): + lottery_id, _ = await seed() + await send(text=f"0007 {SECOND}") + await send(callback="account_action:set_as_winner") + await send(callback=f"winner_lottery:{lottery_id}") + await send(callback="winner_place:1") + await send(text=f"0007 {SECOND}") + await send(callback="account_action:set_as_winner") + await send(callback=f"winner_lottery:{lottery_id}") + calls = await send(callback="winner_place:2") + assert "другое призовое место" in output(calls) + await send(callback=f"admin_conduct_confirmed_{lottery_id}") + assert_clean(caplog) + async with async_session_maker() as session: + winner = await session.scalar(select(Winner).where(Winner.place == 1)) + assert winner.account_number == SECOND + + +async def test_registered_card_cannot_take_another_owner_account(caplog): + lottery_id, _ = await seed(participants=False) + async with async_session_maker() as session: + user = await UserService.get_or_create_user(session, 960003) + user.club_card_number = "0008" + await session.commit() + await send(text=f"0008 {ACCOUNT}") + await send(callback="account_action:add_to_lottery") + calls = await send(callback=f"add_accounts_to:{lottery_id}") + assert "не соответствует" in output(calls) + assert_clean(caplog) + async with async_session_maker() as session: + assert await session.scalar(select(func.count(Participation.id))) == 0 + + +async def test_malformed_callback_ids_do_not_reach_database(caplog): + await seed() + for prefix in ("admin_participants_", "admin_edit_", "admin_del_lottery_", "admin_redraw_", + "add_accounts_to:", "winner_lottery:", "add_to_lottery_", "cash_add:", + "admin_users_list:", "admin_user_view:", "admin_message_delete_"): + for bad_id in ("abc", "-1", "0", "9" * 30): + calls = await send(callback=prefix + bad_id) + assert "Кнопка устарела" in output(calls) + assert_clean(caplog) + + +async def test_long_detected_account_list_keeps_action_buttons(caplog): + await seed(participants=False) + records = ["0007 " + "-".join(f"{i:014d}"[j:j+2] for j in range(0, 14, 2)) for i in range(120)] + calls = await send(text="\n".join(records)) + pages = [call for call in calls if call.__api_method__ == "sendMessage"] + assert len(pages) > 1 and pages[-1].reply_markup + assert_clean(caplog) + + +async def test_new_account_race_between_different_lotteries(): + lottery_id, _ = await seed(participants=False) + async with async_session_maker() as session: + admin_id = await session.scalar(select(User.id).where(User.telegram_id == ADMIN)) + other = await LotteryService.create_lottery(session, "Second draw", "", ["Prize"], admin_id) + other_id = other.id + account = "01-02-03-04-05-06-07" + async def add(draw_id): + async with async_session_maker() as session: + return await AccountParticipationService.add_account_to_lottery(session, draw_id, "0007 " + account) + results = await asyncio.gather(add(lottery_id), add(other_id)) + assert all(result["success"] for result in results) + async with async_session_maker() as session: + assert await session.scalar(select(func.count(Account.id)).where(Account.account_number == account)) == 1 + assert await session.scalar(select(func.count(Participation.id))) == 2 diff --git a/tests/test_staff_management.py b/tests/test_staff_management.py index 5334c59..37893ba 100644 --- a/tests/test_staff_management.py +++ b/tests/test_staff_management.py @@ -7,7 +7,7 @@ from sqlalchemy import select from src.core.access import get_role from src.core.database import async_session_maker from src.core.models import User -from src.core.services import UserService +from src.core.services import LotteryService, UserService from src.core.staff_service import StaffError, change_role, get_member from test_dispatcher import dispatch, event @@ -104,8 +104,11 @@ async def test_actual_ordinary_admin_and_cashier_cannot_forge_staff_callback(act async def test_revoked_cashier_cannot_continue_open_participant_dialog(): - await create_user(819, is_cashier=True) - await dispatch(event(819, callback_data="cash_add:1")) + cashier = await create_user(819, is_cashier=True) + async with async_session_maker() as session: + lottery = await LotteryService.create_lottery(session, "Open draw", "", ["Prize"], cashier.id) + lottery_id = lottery.id + await dispatch(event(819, callback_data=f"cash_add:{lottery_id}")) await change_role(900001, 819, "user", (False, True)) calls = await dispatch(event(819, text="11-22-33-44-55-66-77")) assert any("Недостаточно прав" in (getattr(call, "text", "") or "") for call in calls)