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)