Fix account enrollment and operator callback flows with Redis scenarios
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2026-09-14 21:01:56 +09:00
parent f3f8d0f737
commit a9af9cb010
17 changed files with 678 additions and 167 deletions

View File

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

View File

@@ -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` системным администратором, с подтверждением роли.

View File

@@ -40,7 +40,9 @@
- Перед проведением проверьте «По розыгрышам» / «Отчёт»: состав, количество участников, отсутствие ошибочно добавленных счетов.
- Ошибочное участие удаляйте через «Удалить» либо соответствующую массовую операцию до проведения розыгрыша. Повторное добавление того же счёта в один розыгрыш не создаёт второй билет.
Бот поддерживает участие по пользователю и по счёту. Используйте способ, соответствующий опубликованным правилам. Счёт может участвовать без связанного профиля; в этом случае автоматическое личное уведомление владельцу невозможно до установления связи.
Для быстрого добавления отправьте в личный диалог `КАРТА СЧЁТ`, например `0007 11-22-33-44-55-66-77`, затем «Добавить в розыгрыш» и нужный розыгрыш. Если карта известна боту, новый счёт будет привязан к клиенту одновременно с добавлением участия. Ведущие нули карты сохраняются. Если счёт уже принадлежит другому клиенту или деактивирован, бот откажет в добавлении. Повторное добавление покажет пропуск без создания второго билета.
Бот поддерживает участие по пользователю и по счёту. Используйте способ, соответствующий опубликованным правилам. Счёт без указания карты может участвовать без связанного профиля; в этом случае автоматическое личное уведомление владельцу невозможно до установления связи.
## Проведение и выдача призов

View File

@@ -38,6 +38,8 @@
3. За один ввод допускается до 1000 распознанных счетов.
4. Проверьте количества «Добавлено», «Пропущено» и ошибки. Повтор того же счёта в том же розыгрыше не создаёт второго участия.
Чтобы сразу привязать новый счёт клиенту, отправьте строку `КАРТА СЧЁТ`, например `0007 11-22-33-44-55-66-77`. Карта должна быть известна боту. Создание счёта и добавление участия сохраняются вместе. Чужой или неактивный счёт нельзя перепривязать таким вводом; исправьте данные или обратитесь к администратору.
Если розыгрыш уже проведён или закрыт, бот отклонит добавление. Если активных розыгрышей нет, обратитесь к администратору. Кассир не может открыть или создать розыгрыш.
Счёт без привязки к клиенту тоже может быть добавлен. Чтобы клиент получил личное уведомление и мог подтвердить выигрыш по коду, сначала проверьте привязку счёта к его зарегистрированному профилю.

View File

@@ -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 и новые ошибки контейнера.

View File

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

View File

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

View File

@@ -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"🔍 <b>Обнаружен ввод счет{'а' if count == 1 else 'ов'}</b>\n\n"
f"Найдено: <b>{count}</b>\n"
f"Владельцев определено: <b>{owners_found}</b>\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"<b>Результаты добавления в розыгрыш:</b>\n<i>{lottery.title}</i>\n\n"
text += f"✅ Добавлено: <b>{results['added']}</b>\n"
text += f"⚠️ Пропущено: <b>{results['skipped']}</b>\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 += "<b>Детали:</b>\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<b>Ошибки:</b>\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"👑 <b>Установка победителя</b>\n\n"
f"Розыгрыш: <i>{lottery.title}</i>\n"
f"Розыгрыш: <i>{escape(lottery.title)}</i>\n"
f"Счет: <code>{account}</code>\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"✅ <b>Победитель установлен!</b>\n\n"
f"Розыгрыш: <i>{lottery.title}</i>\n"
f"Розыгрыш: <i>{escape(lottery_title)}</i>\n"
f"Счет: <code>{account}</code>\n"
f"Место: <b>{place}</b>\n"
)
if prize:
text += f"Приз: <i>{prize}</i>"
text += f"Приз: <i>{escape(prize)}</i>"
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()

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 — отменить текущий ввод.")

View File

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

View File

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

View File

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

View File

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