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" BOT_TOKEN: "123456:TEST_TOKEN_FOR_ISOLATED_TESTS"
DATABASE_URL: "postgresql+asyncpg://lottery_test:lottery_test@postgres:5432/lottery_test" 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_DATABASE_URL: "postgresql+asyncpg://lottery_test:lottery_test@postgres:5432/lottery_test"
TEST_REDIS_URL: "redis://redis:6379/15"
commands: commands:
- pip install -r requirements-dev.txt - pip install -r requirements-dev.txt
- python scripts/wait_database.py - python scripts/wait_database.py
@@ -61,6 +62,9 @@ steps:
status: [success] status: [success]
services: services:
- name: redis
image: redis:7-alpine
- name: postgres - name: postgres
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:

View File

@@ -5,6 +5,7 @@
Актуальные изменения: [отчёт о стабилизации и известных ограничениях](docs/STABILIZATION_REPORT.md), Актуальные изменения: [отчёт о стабилизации и известных ограничениях](docs/STABILIZATION_REPORT.md),
[настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md). [настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md).
[Проверка текстовых обработчиков и регистрации карты](docs/TEXT_INPUT_AUDIT.md). [Проверка текстовых обработчиков и регистрации карты](docs/TEXT_INPUT_AUDIT.md).
[Проверка счетов и сценариев администратора, кассира и клиента](docs/OPERATOR_SCENARIO_AUDIT.md).
Инструкции: [администратору](docs/ADMIN_GUIDE.md), [кассиру](docs/CASHIER_GUIDE.md), Инструкции: [администратору](docs/ADMIN_GUIDE.md), [кассиру](docs/CASHIER_GUIDE.md),
[системному администратору](docs/SYSTEM_ADMIN_GUIDE.md). [системному администратору](docs/SYSTEM_ADMIN_GUIDE.md).
Назначение сотрудников: `/staff` системным администратором, с подтверждением роли. Назначение сотрудников: `/staff` системным администратором, с подтверждением роли.

View File

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

View File

@@ -38,6 +38,8 @@
3. За один ввод допускается до 1000 распознанных счетов. 3. За один ввод допускается до 1000 распознанных счетов.
4. Проверьте количества «Добавлено», «Пропущено» и ошибки. Повтор того же счёта в том же розыгрыше не создаёт второго участия. 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} не найден") results["errors"].append(f"Пользователь {telegram_id} не найден")
continue continue
first_name, username = user.first_name, user.username
# Пробуем добавить # Пробуем добавить
if await ParticipationService.add_participant(session, lottery_id, user.id): if await ParticipationService.add_participant(session, lottery_id, user.id):
results["added"] += 1 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: else:
results["skipped"] += 1 results["skipped"] += 1
results["details"].append(f"Уже участвует: {user.first_name}") results["details"].append(f"Не добавлен (повтор или розыгрыш закрыт): {first_name}")
except Exception as e: 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 return results
@@ -503,15 +506,18 @@ class ParticipationService:
results["details"].append(f"Не найден: {telegram_id}") results["details"].append(f"Не найден: {telegram_id}")
continue continue
first_name = user.first_name
if await ParticipationService.remove_participant(session, lottery_id, user.id): if await ParticipationService.remove_participant(session, lottery_id, user.id):
results["removed"] += 1 results["removed"] += 1
results["details"].append(f"Удален: {user.first_name}") results["details"].append(f"Удален: {first_name}")
else: else:
results["not_found"] += 1 results["not_found"] += 1
results["details"].append(f"Не участвовал: {user.first_name}") results["details"].append(f"Не удалён (нет участия или розыгрыш закрыт): {first_name}")
except Exception as e: 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 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 src.core.access import is_admin
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
@@ -15,6 +16,9 @@ from ..core.services import LotteryService
from .account_services import AccountParticipationService from .account_services import AccountParticipationService
from ..utils.account_utils import parse_accounts_from_message, validate_account_number from ..utils.account_utils import parse_accounts_from_message, validate_account_number
from typing import List 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 для работы со счетами # Состояния FSM для работы со счетами
@@ -47,11 +51,14 @@ async def detect_account_input(message: Message, state: FSMContext):
raise SkipHandler() raise SkipHandler()
# Парсим счета из сообщения # Парсим счета из сообщения
accounts = parse_accounts_from_message(message.text) accounts, errors = parse_account_records(message.text)
if not accounts: if not accounts:
from aiogram.dispatcher.event.bases import SkipHandler from aiogram.dispatcher.event.bases import SkipHandler
raise SkipHandler() raise SkipHandler()
if errors:
await answer_plain_pages(message, "Исправьте записи и отправьте список заново:\n" + "\n".join(errors[:10]))
return
# Извлекаем номера клубных карт и определяем владельцев # Извлекаем номера клубных карт и определяем владельцев
from ..core.services import UserService 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']) owners_found = sum(1 for item in accounts_with_owners if item['owner'])
text = ( text = (
f"🔍 <b>Обнаружен ввод счет{'а' if count == 1 else 'ов'}</b>\n\n" f"🔍 Обнаружен ввод счет{'а' if count == 1 else 'ов'}\n\n"
f"Найдено: <b>{count}</b>\n" f"Найдено: {count}\n"
f"Владельцев определено: <b>{owners_found}</b>\n\n" f"Владельцев определено: {owners_found}\n\n"
f"{accounts_text}\n\n" f"{accounts_text}\n\n"
f"Выберите действие:" 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") @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): if not await is_admin(callback.from_user.id):
await callback.answer("⛔ Доступно только администраторам", show_alert=True) await callback.answer("⛔ Доступно только администраторам", show_alert=True)
return return
if not (await state.get_data()).get("detected_accounts"):
await callback.answer("Данные устарели. Отправьте счета заново.", show_alert=True)
return
async with async_session_maker() as session: async with async_session_maker() as session:
# Получаем активные розыгрыши # Получаем активные розыгрыши
@@ -197,7 +208,7 @@ async def choose_lottery_for_accounts(callback: CallbackQuery, state: FSMContext
await callback.answer() 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): async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
"""Добавление счетов в выбранный розыгрыш""" """Добавление счетов в выбранный розыгрыш"""
if not await is_admin(callback.from_user.id): 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() await callback.answer()
return return
# A skipped account rolls back the session and expires ORM objects.
lottery_title = lottery.title
# Добавляем счета # Добавляем счета
results = await AccountParticipationService.add_accounts_bulk( results = await AccountParticipationService.add_accounts_bulk(
session, lottery_id, accounts session, lottery_id, accounts
) )
# Формируем результат # Формируем результат
text = f"<b>Результаты добавления в розыгрыш:</b>\n<i>{lottery.title}</i>\n\n" await state.clear()
text += f"✅ Добавлено: <b>{results['added']}</b>\n" text = f"Результаты добавления в розыгрыш:\n{lottery_title}\n\n"
text += f"⚠️ Пропущено: <b>{results['skipped']}</b>\n\n" text += f"✅ Добавлено: {results['added']}\n"
text += f"⚠️ Пропущено: {results['skipped']}\n\n"
if results['details']: if results['details']:
text += "<b>Детали:</b>\n" text += "Детали:\n"
text += "\n".join(results['details'][:20]) # Показываем первые 20 text += "\n".join(results['details'][:20]) # Показываем первые 20
if len(results['details']) > 20: if len(results['details']) > 20:
text += f"\n... и ещё {len(results['details']) - 20}" text += f"\n... и ещё {len(results['details']) - 20}"
if results['errors']: if results['errors']:
text += f"\n\n<b>Ошибки:</b>\n" text += f"\n\nОшибки:\n"
text += "\n".join(results['errors'][:10]) text += "\n".join(results['errors'][:10])
await callback.message.edit_text(text, parse_mode="HTML") await edit_plain_pages(callback.message, text)
await state.clear()
await callback.answer("✅ Готово!") await callback.answer("✅ Готово!")
@@ -276,8 +289,7 @@ async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext):
return return
async with async_session_maker() as session: async with async_session_maker() as session:
# Получаем все розыгрыши (активные и завершенные) lotteries = await LotteryService.get_active_lotteries(session, limit=30)
lotteries = await LotteryService.get_all_lotteries(session, limit=30)
if not lotteries: if not lotteries:
await callback.message.edit_text( await callback.message.edit_text(
@@ -316,7 +328,7 @@ async def choose_lottery_for_winner(callback: CallbackQuery, state: FSMContext):
await callback.answer() 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): async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
"""Выбор места для победителя""" """Выбор места для победителя"""
if not await is_admin(callback.from_user.id): if not await is_admin(callback.from_user.id):
@@ -324,6 +336,10 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
return return
lottery_id = int(callback.data.split(":")[1]) 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 розыгрыша # Сохраняем ID розыгрыша
await state.update_data(winner_lottery_id=lottery_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( await callback.message.edit_text(
f"👑 <b>Установка победителя</b>\n\n" 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"Счет: <code>{account}</code>\n\n"
f"Выберите место:", f"Выберите место:",
reply_markup=keyboard, reply_markup=keyboard,
@@ -379,7 +395,7 @@ async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
await callback.answer() 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): async def set_account_winner(callback: CallbackQuery, state: FSMContext):
"""Установка счета как победителя""" """Установка счета как победителя"""
if not await is_admin(callback.from_user.id): 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() 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") lottery_id = data.get("winner_lottery_id")
if not account or not 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: async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id) 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 prize = None
@@ -416,20 +441,20 @@ async def set_account_winner(callback: CallbackQuery, state: FSMContext):
session, lottery_id, account, place, prize session, lottery_id, account, place, prize
) )
await state.clear()
if result["success"]: if result["success"]:
text = ( text = (
f"✅ <b>Победитель установлен!</b>\n\n" f"✅ <b>Победитель установлен!</b>\n\n"
f"Розыгрыш: <i>{lottery.title}</i>\n" f"Розыгрыш: <i>{escape(lottery_title)}</i>\n"
f"Счет: <code>{account}</code>\n" f"Счет: <code>{account}</code>\n"
f"Место: <b>{place}</b>\n" f"Место: <b>{place}</b>\n"
) )
if prize: if prize:
text += f"Приз: <i>{prize}</i>" text += f"Приз: <i>{escape(prize)}</i>"
await callback.answer("✅ Победитель установлен!", show_alert=True) await callback.answer("✅ Победитель установлен!", show_alert=True)
else: else:
text = f"{result['message']}" text = f"{escape(result['message'])}"
await callback.answer("❌ Ошибка", show_alert=True) await callback.answer("❌ Ошибка", show_alert=True)
await callback.message.edit_text(text, parse_mode="HTML") 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.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func 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 ..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 typing import List, Optional, Dict, Any
from ..core.transactions import lock_open_lottery from ..core.transactions import lock_open_lottery
@@ -78,15 +78,22 @@ class AccountParticipationService:
"account_number": formatted_account "account_number": formatted_account
} }
# Получаем запись Account и владельца # Register a new ticket for the supplied card in the same transaction.
from ..core.registration_services import AccountService # Conflict handling also protects two cashiers adding it to different draws.
from ..core.models import Account account_record = await session.scalar(select(Account).where(Account.account_number == formatted_account))
if card_number and not account_record:
user = await AccountService.get_account_owner(session, formatted_account) owner_id = await session.scalar(select(User.id).where(User.club_card_number == card_number))
account_record = await session.execute( if owner_id is None:
select(Account).where(Account.account_number == formatted_account) await session.rollback()
) return {"success": False, "message": "Владелец клубной карты не найден. Сначала зарегистрируйте клиента.",
account_record = account_record.scalar_one_or_none() "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: if account_record and not account_record.is_active:
await session.rollback() await session.rollback()
return {"success": False, "message": "Счет неактивен", "account_number": formatted_account} return {"success": False, "message": "Счет неактивен", "account_number": formatted_account}
@@ -298,6 +305,17 @@ class AccountParticipationService:
"success": False, "success": False,
"message": f"❌ Счет {formatted_account}{card_info} не участвует в розыгрыше" "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( existing_winner = await session.execute(
@@ -311,7 +329,7 @@ class AccountParticipationService:
if existing_winner: if existing_winner:
# Обновляем существующего победителя # Обновляем существующего победителя
existing_winner.account_number = formatted_account existing_winner.account_number = formatted_account
existing_winner.user_id = None existing_winner.user_id = participation.user_id
existing_winner.is_manual = True existing_winner.is_manual = True
if prize: if prize:
existing_winner.prize = prize existing_winner.prize = prize
@@ -320,7 +338,7 @@ class AccountParticipationService:
winner = Winner( winner = Winner(
lottery_id=lottery_id, lottery_id=lottery_id,
account_number=formatted_account, account_number=formatted_account,
user_id=None, user_id=participation.user_id,
place=place, place=place,
prize=prize, prize=prize,
is_manual=True 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.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.utils.input_validation import numeric_id
from src.filters.dialog_input import NotCommand from src.filters.dialog_input import NotCommand
from src.utils.errors import public_error 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): async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
"""Добавить счета в выбранный розыгрыш""" """Добавить счета в выбранный розыгрыш"""
lottery_id = int(callback.data.split("_")[-1]) lottery_id = int(callback.data.split("_")[-1])
@@ -356,12 +357,14 @@ async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
await state.clear() await state.clear()
return return
lottery_title = lottery.title
from .account_services import AccountParticipationService from .account_services import AccountParticipationService
result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, [a['account_number'] for a in accounts]) result = await AccountParticipationService.add_accounts_bulk(session, lottery_id, [a['account_number'] for a in accounts])
success_count = result['added'] success_count = result['added']
errors = result['errors'] errors = result['errors']
text = f"📊 **Добавление в розыгрыш '{lottery.title}'**\n\n" await state.clear()
text = f"📊 Добавление в розыгрыш '{lottery_title}'\n\n"
if success_count: if success_count:
text += f"✅ Добавлено счетов: {success_count}\n\n" 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]: for error in errors[:3]:
text += f"{error}\n" text += f"{error}\n"
await callback.message.edit_text(text) await edit_plain_pages(callback.message, text)
await state.clear() await callback.answer("Готово")
@router.callback_query(F.data == "skip_lottery_add") @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.input_validation import lottery_field, numeric_id
from src.utils.text_output import answer_plain_pages from src.utils.text_output import answer_plain_pages
from html import escape from html import escape
@@ -33,6 +34,14 @@ from ..core.models import User, Lottery, Participation, Account, ChatMessage, Wi
logger = logging.getLogger(__name__) 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( async def safe_edit_message(
callback: CallbackQuery, callback: CallbackQuery,
text: str, 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)) 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): async def show_lottery_detail(callback: CallbackQuery):
"""Детальная информация о розыгрыше""" """Детальная информация о розыгрыше"""
if not await check_admin_access(callback.from_user.id): if not await check_admin_access(callback.from_user.id):
@@ -578,7 +587,7 @@ async def show_lottery_detail(callback: CallbackQuery):
# Безопасная обработка победителя - может быть без user_id # Безопасная обработка победителя - может быть без user_id
if winner.user: if winner.user:
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name username = _winner_name(winner)
else: else:
# Победитель по номеру счета без связанного пользователя # Победитель по номеру счета без связанного пользователя
username = f"Счет: {winner.account_number}" 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()) 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): async def show_lottery_participants(callback: CallbackQuery):
"""Показать участников конкретного розыгрыша""" """Показать участников конкретного розыгрыша"""
if not await check_admin_access(callback.from_user.id): 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)) 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): async def choose_user_to_add(callback: CallbackQuery, state: FSMContext):
"""Выбор пользователя для добавления""" """Выбор пользователя для добавления"""
if not await check_admin_access(callback.from_user.id): 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: async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id) 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 = f" Добавление в: {lottery.title}\n\n"
text += "Введите Telegram ID или username пользователя:\n\n" text += "Введите Telegram ID или username пользователя:\n\n"
@@ -794,9 +807,14 @@ async def process_add_participant(message: Message, state: FSMContext):
) )
return return
user_first_name = user.first_name
# Добавляем участника # Добавляем участника
success = await ParticipationService.add_participant(session, lottery_id, user.id) success = await ParticipationService.add_participant(session, lottery_id, user.id)
lottery = await LotteryService.get_lottery(session, lottery_id) lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await state.clear()
await message.answer('Розыгрыш не найден. Откройте меню заново.')
return
await state.clear() await state.clear()
@@ -814,7 +832,7 @@ async def process_add_participant(message: Message, state: FSMContext):
) )
else: else:
await message.answer( await message.answer(
f"⚠️ Пользователь {user.first_name} уже участвует в этом розыгрыше", f"⚠️ Пользователь {user_first_name} уже участвует либо розыгрыш закрыт",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")] [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): async def remove_participant_select_lottery(callback: CallbackQuery, state: FSMContext):
"""Выбор розыгрыша для удаления участника""" """Выбор розыгрыша для удаления участника"""
if not await check_admin_access(callback.from_user.id): 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() await state.clear()
return 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) removed = await ParticipationService.remove_participant(session, lottery_id, user.id)
await state.clear() await state.clear()
username = f"@{user.username}" if user.username else "Нет username"
if removed: if removed:
await message.answer( await message.answer(
f"✅ Участник удалён из розыгрыша!\n\n" f"✅ Участник удалён из розыгрыша!\n\n"
@@ -945,7 +963,7 @@ async def process_remove_participant(message: Message, state: FSMContext):
) )
else: else:
await message.answer( await message.answer(
f"⚠️ Пользователь {user.first_name} не участвует в этом розыгрыше", f"⚠️ Пользователь {user_first_name} не участвует либо розыгрыш закрыт",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="👥 К управлению участниками", callback_data="admin_participants")] [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)) 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): async def choose_users_bulk_add(callback: CallbackQuery, state: FSMContext):
"""Выбор пользователей для массового добавления""" """Выбор пользователей для массового добавления"""
if not await check_admin_access(callback.from_user.id): 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: async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id) 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 = f"📥 Массовое добавление в: {lottery.title}\n\n"
text += "Введите список Telegram ID или username через запятую:\n\n" text += "Введите список Telegram ID или username через запятую:\n\n"
@@ -1319,6 +1341,10 @@ async def process_bulk_add_participant(message: Message, state: FSMContext):
return return
results = await ParticipationService.add_participants_bulk(session, lottery_id, telegram_ids) results = await ParticipationService.add_participants_bulk(session, lottery_id, telegram_ids)
lottery = await LotteryService.get_lottery(session, lottery_id) lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await state.clear()
await message.answer('Розыгрыш не найден. Откройте меню заново.')
return
await state.clear() 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)) 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): async def choose_users_bulk_remove(callback: CallbackQuery, state: FSMContext):
"""Выбор пользователей для массового удаления""" """Выбор пользователей для массового удаления"""
if not await check_admin_access(callback.from_user.id): 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: async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id) 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 = f"📤 Массовое удаление из: {lottery.title}\n\n"
text += "Введите список Telegram ID или username через запятую:\n\n" text += "Введите список Telegram ID или username через запятую:\n\n"
@@ -1456,6 +1486,10 @@ async def process_bulk_remove_participant(message: Message, state: FSMContext):
return return
results = await ParticipationService.remove_participants_bulk(session, lottery_id, telegram_ids) results = await ParticipationService.remove_participants_bulk(session, lottery_id, telegram_ids)
lottery = await LotteryService.get_lottery(session, lottery_id) lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await state.clear()
await message.answer('Розыгрыш не найден. Откройте меню заново.')
return
await state.clear() 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): async def add_participant_to_lottery(callback: CallbackQuery, state: FSMContext):
"""Добавление участника в конкретный розыгрыш""" """Добавление участника в конкретный розыгрыш"""
if not await is_admin(callback.from_user.id): 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() await state.clear()
return return
lottery_title = lottery.title
# Добавляем участника # Добавляем участника
if user: 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})" 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: else:
# Добавление по номеру счета from .account_services import AccountParticipationService
from sqlalchemy import select result = await AccountParticipationService.add_account_to_lottery(session, lottery_id, account_number)
from ..core.models import Participation success = result["success"]
# Проверяем, не добавлен ли уже этот счет
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
name = f"Счет: {account_number}" name = f"Счет: {account_number}"
await state.clear() await state.clear()
@@ -1603,7 +1616,7 @@ async def process_add_to_lottery(message: Message, state: FSMContext):
await message.answer( await message.answer(
f"✅ Участник добавлен!\n\n" f"✅ Участник добавлен!\n\n"
f"👤 {name}\n" f"👤 {name}\n"
f"🎯 Розыгрыш: {lottery.title}", f"🎯 Розыгрыш: {lottery_title}",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=" Добавить ещё", callback_data=f"admin_add_to_{lottery_id}")], [InlineKeyboardButton(text=" Добавить ещё", callback_data=f"admin_add_to_{lottery_id}")],
[InlineKeyboardButton(text="👥 К участникам", callback_data=f"admin_participants_{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: else:
await message.answer( await message.answer(
f"⚠️ Участник уже добавлен в этот розыгрыш", result["message"] if account_number else "⚠️ Участник уже добавлен либо розыгрыш закрыт",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")] [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): async def remove_participant_from_lottery(callback: CallbackQuery, state: FSMContext):
"""Удаление участника из конкретного розыгрыша""" """Удаление участника из конкретного розыгрыша"""
if not await is_admin(callback.from_user.id): 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() await state.clear()
return return
lottery_title = lottery.title
removed = False removed = False
name = user_input name = user_input
@@ -1679,36 +1693,25 @@ async def process_remove_from_lottery(message: Message, state: FSMContext):
username = user_input[1:] username = user_input[1:]
user = await UserService.get_user_by_username(session, username) user = await UserService.get_user_by_username(session, username)
if user: if user:
removed = await ParticipationService.remove_participant(session, lottery_id, user.id)
name = f"@{user.username}" if user.username else f"{user.first_name}" 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(): elif user_input.isdigit():
# Telegram ID # Telegram ID
telegram_id = int(user_input) telegram_id = int(user_input)
user = await UserService.get_user_by_telegram_id(session, telegram_id) user = await UserService.get_user_by_telegram_id(session, telegram_id)
if user: 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}" 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: elif '-' in user_input:
# Номер счета # Номер счета
from sqlalchemy import select, delete
from ..core.models import Participation
from src.utils.account_utils import parse_accounts_from_message from src.utils.account_utils import parse_accounts_from_message
accounts = parse_accounts_from_message(user_input) accounts = parse_accounts_from_message(user_input)
if accounts: if accounts:
account_number = accounts[0] account_number = accounts[0].split()[-1]
result = await session.execute( from .account_services import AccountParticipationService
select(Participation).where( result = await AccountParticipationService.remove_account_from_lottery(session, lottery_id, account_number)
Participation.lottery_id == lottery_id, removed = result["success"]
Participation.account_number == account_number name = f"Счет: {account_number}"
)
)
participation = result.scalar_one_or_none()
if participation:
await session.delete(participation)
await session.commit()
removed = True
name = f"Счет: {account_number}"
await state.clear() await state.clear()
@@ -1716,7 +1719,7 @@ async def process_remove_from_lottery(message: Message, state: FSMContext):
await message.answer( await message.answer(
f"✅ Участник удалён!\n\n" f"✅ Участник удалён!\n\n"
f"👤 {name}\n" f"👤 {name}\n"
f"🎯 Розыгрыш: {lottery.title}", f"🎯 Розыгрыш: {lottery_title}",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=" Удалить ещё", callback_data=f"admin_remove_from_{lottery_id}")], [InlineKeyboardButton(text=" Удалить ещё", callback_data=f"admin_remove_from_{lottery_id}")],
[InlineKeyboardButton(text="👥 К участникам", callback_data=f"admin_participants_{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: else:
await message.answer( await message.answer(
f"⚠️ Участник не найден в этом розыгрыше", "⚠️ Участник не найден либо розыгрыш закрыт",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"admin_participants_{lottery_id}")] [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): async def check_winners(callback: CallbackQuery):
"""Проверка подтверждения победителей""" """Проверка подтверждения победителей"""
if not await is_admin(callback.from_user.id): if not await is_admin(callback.from_user.id):
@@ -1781,7 +1784,7 @@ async def check_winners(callback: CallbackQuery):
if winner.account_number: if winner.account_number:
name = f"Счет: {winner.account_number}" name = f"Счет: {winner.account_number}"
elif winner.user: elif winner.user:
name = f"@{winner.user.username}" if winner.user.username else winner.user.first_name name = _winner_name(winner)
else: else:
name = f"ID: {winner.user_id}" 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)) 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): async def redraw_lottery(callback: CallbackQuery):
"""Повторный розыгрыш для неподтверждённых призов""" """Повторный розыгрыш для неподтверждённых призов"""
if not await is_admin(callback.from_user.id): if not await is_admin(callback.from_user.id):
@@ -1837,7 +1840,7 @@ async def redraw_lottery(callback: CallbackQuery):
if winner.account_number: if winner.account_number:
name = f"Счет: {winner.account_number}" name = f"Счет: {winner.account_number}"
elif winner.user: elif winner.user:
name = f"@{winner.user.username}" if winner.user.username else winner.user.first_name name = _winner_name(winner)
else: else:
name = f"ID: {winner.user_id}" 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): async def confirm_redraw(callback: CallbackQuery):
if not await check_admin_access(callback.from_user.id): if not await check_admin_access(callback.from_user.id):
await callback.answer("Недостаточно прав", show_alert=True) 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): async def delete_lottery_confirm(callback: CallbackQuery):
"""Подтверждение удаления розыгрыша""" """Подтверждение удаления розыгрыша"""
if not await is_admin(callback.from_user.id): 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): async def delete_lottery_execute(callback: CallbackQuery):
"""Выполнение удаления розыгрыша""" """Выполнение удаления розыгрыша"""
if not await is_admin(callback.from_user.id): 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)) 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): async def choose_accounts_bulk_add(callback: CallbackQuery, state: FSMContext):
"""Выбор номеров счетов для массового добавления""" """Выбор номеров счетов для массового добавления"""
if not await check_admin_access(callback.from_user.id): 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: async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id) 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 = f"🏦 Массовое добавление в: {lottery.title}\n\n"
text += "Введите список номеров счетов через запятую или новую строку:\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) results = await ParticipationService.add_participants_by_accounts_bulk(session, lottery_id, account_inputs)
lottery = await LotteryService.get_lottery(session, lottery_id) lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await state.clear()
await message.answer('Розыгрыш не найден. Откройте меню заново.')
return
await state.clear() 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)) 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): async def choose_accounts_bulk_remove(callback: CallbackQuery, state: FSMContext):
"""Выбор номеров счетов для массового удаления""" """Выбор номеров счетов для массового удаления"""
if not await check_admin_access(callback.from_user.id): 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: async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id) 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 = f"🏦 Массовое удаление из: {lottery.title}\n\n"
text += "Введите список номеров счетов через запятую или новую строку:\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) results = await ParticipationService.remove_participants_by_accounts_bulk(session, lottery_id, account_inputs)
lottery = await LotteryService.get_lottery(session, lottery_id) lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await state.clear()
await message.answer('Розыгрыш не найден. Откройте меню заново.')
return
await state.clear() 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)) 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): async def show_participants_report(callback: CallbackQuery):
"""Отчет по участникам""" """Отчет по участникам"""
if not await check_admin_access(callback.from_user.id): 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)) 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): async def handle_edit_field(callback: CallbackQuery, state: FSMContext):
"""Обработка выбора поля для редактирования""" """Обработка выбора поля для редактирования"""
if not await check_admin_access(callback.from_user.id): 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() 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): async def redirect_to_edit_lottery(callback: CallbackQuery, state: FSMContext):
"""Редирект на редактирование розыгрыша из детального просмотра""" """Редирект на редактирование розыгрыша из детального просмотра"""
if not await check_admin_access(callback.from_user.id): 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) 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): async def choose_edit_field(callback: CallbackQuery, state: FSMContext):
"""Выбор поля для редактирования""" """Выбор поля для редактирования"""
if not await check_admin_access(callback.from_user.id): 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)) 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): async def toggle_lottery_active(callback: CallbackQuery, state: FSMContext):
"""Переключить активность розыгрыша""" """Переключить активность розыгрыша"""
if not await check_admin_access(callback.from_user.id): 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)) 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): async def confirm_finish_lottery(callback: CallbackQuery):
"""Подтвердить завершение розыгрыша""" """Подтвердить завершение розыгрыша"""
if not await check_admin_access(callback.from_user.id): 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: async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id) 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) count = await ParticipationService.get_participants_count(session, lottery_id)
text = f"🏁 Завершение розыгрыша\n\n" 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)) 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): async def do_finish_lottery(callback: CallbackQuery):
"""Выполнить завершение розыгрыша""" """Выполнить завершение розыгрыша"""
if not await check_admin_access(callback.from_user.id): 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)) 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): async def confirm_delete_lottery(callback: CallbackQuery):
"""Подтвердить удаление розыгрыша""" """Подтвердить удаление розыгрыша"""
if not await check_admin_access(callback.from_user.id): 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: async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id) 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) count = await ParticipationService.get_participants_count(session, lottery_id)
text = f"🗑️ Удаление розыгрыша\n\n" 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)) 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): async def do_delete_lottery(callback: CallbackQuery):
"""Выполнить удаление розыгрыша""" """Выполнить удаление розыгрыша"""
if not await check_admin_access(callback.from_user.id): 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: async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id) lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await callback.answer('Розыгрыш не найден. Откройте меню заново.', show_alert=True)
return
lottery_title = lottery.title lottery_title = lottery.title
success = await LotteryService.delete_lottery(session, lottery_id) 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)) 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): async def handle_set_winner_from_lottery(callback: CallbackQuery, state: FSMContext):
"""Обработчик для кнопки 'Установить победителя' из карточки розыгрыша""" """Обработчик для кнопки 'Установить победителя' из карточки розыгрыша"""
if not await check_admin_access(callback.from_user.id): 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) 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): async def choose_winner_place(callback: CallbackQuery, state: FSMContext):
"""Выбор места для победителя""" """Выбор места для победителя"""
if not await check_admin_access(callback.from_user.id): 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() data = await state.get_data()
async with async_session_maker() as session: async with async_session_maker() as session:
success = await LotteryService.set_manual_winner( if is_account:
session, from .account_services import AccountParticipationService
data['lottery_id'], result = await AccountParticipationService.set_account_as_winner(
data['place'], session, data['lottery_id'], user_input, data['place']
telegram_id )
) success = result["success"]
else:
success = await LotteryService.set_manual_winner(
session, data['lottery_id'], data['place'], telegram_id
)
await state.clear() await state.clear()
@@ -3008,7 +3040,7 @@ async def list_all_winners(callback: CallbackQuery):
text += f"📅 {lottery.created_at.strftime('%d.%m.%Y')}\n" text += f"📅 {lottery.created_at.strftime('%d.%m.%Y')}\n"
for winner in sorted(winners_list, key=lambda w: w.place): 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 "🎲" manual_mark = "🔧" if winner.is_manual else "🎲"
text += f" {manual_mark} {winner.place} место: {username} - {winner.prize}\n" 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): async def edit_winner_select_place(callback: CallbackQuery, state: FSMContext):
"""Выбор места победителя для редактирования""" """Выбор места победителя для редактирования"""
if not await check_admin_access(callback.from_user.id): 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: async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id) 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) winners = await LotteryService.get_winners(session, lottery_id)
if not winners: if not winners:
@@ -3093,7 +3129,7 @@ async def edit_winner_select_place(callback: CallbackQuery, state: FSMContext):
buttons = [] buttons = []
for winner in winners: for winner in winners:
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name username = _winner_name(winner)
buttons.append([ buttons.append([
InlineKeyboardButton( InlineKeyboardButton(
text=f"🏆 {winner.place} место: {username} - {winner.prize}", 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)) 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): async def edit_winner_details(callback: CallbackQuery):
"""Показать детали победителя (пока просто информационное сообщение)""" """Показать детали победителя (пока просто информационное сообщение)"""
if not await check_admin_access(callback.from_user.id): 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) await callback.answer("❌ Победитель не найден", show_alert=True)
return 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 "🎲 Выбран случайно" manual_mark = "🔧 Установлен вручную" if winner.is_manual else "🎲 Выбран случайно"
text = f"📝 Информация о победителе\n\n" text = f"📝 Информация о победителе\n\n"
@@ -3136,7 +3172,7 @@ async def edit_winner_details(callback: CallbackQuery):
text += f"🏆 Место: {winner.place}\n" text += f"🏆 Место: {winner.place}\n"
text += f"💰 Приз: {winner.prize}\n" text += f"💰 Приз: {winner.prize}\n"
text += f"👤 Пользователь: {username}\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"📊 Тип: {manual_mark}\n"
text += f"📅 Дата: {winner.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" text += f"📅 Дата: {winner.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
text += " Редактирование победителей доступно через удаление и повторное добавление." 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): async def remove_winner_select_place(callback: CallbackQuery):
"""Выбор победителя для удаления""" """Выбор победителя для удаления"""
if not await check_admin_access(callback.from_user.id): 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: async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id) 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) winners = await LotteryService.get_winners(session, lottery_id)
if not winners: if not winners:
@@ -3216,7 +3255,7 @@ async def remove_winner_select_place(callback: CallbackQuery):
buttons = [] buttons = []
for winner in winners: for winner in winners:
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name username = _winner_name(winner)
buttons.append([ buttons.append([
InlineKeyboardButton( InlineKeyboardButton(
text=f"🏆 {winner.place} место: {username} - {winner.prize}", 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)) 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): async def confirm_remove_winner(callback: CallbackQuery):
"""Подтверждение удаления победителя""" """Подтверждение удаления победителя"""
if not await check_admin_access(callback.from_user.id): 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) await callback.answer("❌ Победитель не найден", show_alert=True)
return 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"
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)) 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): async def do_remove_winner(callback: CallbackQuery):
"""Выполнение удаления победителя""" """Выполнение удаления победителя"""
if not await check_admin_access(callback.from_user.id): if not await check_admin_access(callback.from_user.id):
@@ -3296,7 +3335,7 @@ async def do_remove_winner(callback: CallbackQuery):
return return
lottery_id = winner.lottery_id 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 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)) 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): async def conduct_lottery_draw_confirm(callback: CallbackQuery):
"""Запрос подтверждения проведения розыгрыша""" """Запрос подтверждения проведения розыгрыша"""
if not await check_admin_access(callback.from_user.id): 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)) 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): async def conduct_lottery_draw(callback: CallbackQuery):
"""Проведение розыгрыша после подтверждения""" """Проведение розыгрыша после подтверждения"""
logger.info(f"🎯 conduct_lottery_draw HANDLER TRIGGERED! data={callback.data}, user={callback.from_user.id}") 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: if winner.account_number:
text += f"{winner.place} место: {winner.account_number}\n" text += f"{winner.place} место: {winner.account_number}\n"
elif winner.user: 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" text += f"{winner.place} место: {username}\n"
else: else:
text += f"{winner.place} место: ID {winner.user_id}\n" 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)) 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): async def choose_display_type(callback: CallbackQuery, state: FSMContext):
"""Выбор типа отображения для конкретного розыгрыша""" """Выбор типа отображения для конкретного розыгрыша"""
if not await check_admin_access(callback.from_user.id): 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: async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id) 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') 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)) 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): async def apply_display_type(callback: CallbackQuery, state: FSMContext):
"""Применить выбранный тип отображения""" """Применить выбранный тип отображения"""
import logging import logging
@@ -3967,6 +4010,10 @@ async def apply_display_type(callback: CallbackQuery, state: FSMContext):
logger.info(f"💾 Результат сохранения: {success}") logger.info(f"💾 Результат сохранения: {success}")
lottery = await LotteryService.get_lottery(session, lottery_id) 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'}") logger.info(f"📋 Получен розыгрыш: {lottery.title if lottery else 'None'}")
if success: 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): async def view_message(callback: CallbackQuery):
"""Просмотр конкретного сообщения""" """Просмотр конкретного сообщения"""
if not await check_admin_access(callback.from_user.id): 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): async def delete_message(callback: CallbackQuery):
"""Удалить сообщение пользователя""" """Удалить сообщение пользователя"""
if not await check_admin_access(callback.from_user.id): 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) 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): async def show_user_messages(callback: CallbackQuery):
"""Показать все сообщения конкретного пользователя""" """Показать все сообщения конкретного пользователя"""
if not await check_admin_access(callback.from_user.id): 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: if winner.user:
nickname = winner.user.nickname nickname = winner.user.nickname
if not 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: elif winner.account_number:
from ..core.registration_services import AccountService from ..core.registration_services import AccountService
owner = await AccountService.get_account_owner(session, winner.account_number) 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): async def broadcast_select_channel(callback: CallbackQuery, state: FSMContext):
"""Выбран канал/группа - запрос сообщения""" """Выбран канал/группа - запрос сообщения"""
if not await check_admin_access(callback.from_user.id): 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() 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): async def admin_users_list(callback: CallbackQuery, state: FSMContext):
"""Список всех пользователей с пагинацией""" """Список всех пользователей с пагинацией"""
if not await check_admin_access(callback.from_user.id): 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): async def admin_users_banned_list(callback: CallbackQuery, state: FSMContext):
"""Список заблокированных пользователей""" """Список заблокированных пользователей"""
if not await check_admin_access(callback.from_user.id): 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): async def admin_user_view(callback: CallbackQuery, state: FSMContext):
"""Просмотр информации о пользователе""" """Просмотр информации о пользователе"""
if not await check_admin_access(callback.from_user.id): 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): async def admin_user_ban(callback: CallbackQuery, state: FSMContext):
"""Заблокировать пользователя в чате""" """Заблокировать пользователя в чате"""
if not await check_admin_access(callback.from_user.id): 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) 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): async def admin_user_unban(callback: CallbackQuery, state: FSMContext):
"""Разблокировать пользователя в чате""" """Разблокировать пользователя в чате"""
if not await check_admin_access(callback.from_user.id): 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.""" """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 src.filters.dialog_input import NotCommand
from aiogram import F, Router from aiogram import F, Router
from src.filters.case_insensitive import CaseInsensitiveCommand as Command 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) 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): async def choose_draw(callback: CallbackQuery, state: FSMContext):
lottery_id = int(callback.data.split(":")[1]) lottery_id = int(callback.data.split(":")[1])
await state.clear() 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.update_data(lottery_id=lottery_id)
await state.set_state(CashierStates.accounts) await state.set_state(CashierStates.accounts)
await callback.answer() await callback.answer()

View File

@@ -1,11 +1,16 @@
"""Explain unmatched input instead of silently dropping it or broadcasting it.""" """Explain unmatched input instead of silently dropping it or broadcasting it."""
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext 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 = 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("/")) @input_fallback_router.message(F.text.lstrip().startswith("/"))
async def unknown_command(message: Message): async def unknown_command(message: Message):
await message.answer("Команда не распознана или недоступна на этом шаге. /help — справка, /cancel — отменить текущий ввод.") 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) await message.answer(text[:end], parse_mode=None)
text = text[end:] text = text[end:]
return await message.answer(text, parse_mode=None, **kwargs) 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.""" """All tests use disposable databases and a synthetic Telegram token."""
import os import os
import tempfile import tempfile
import sys
from pathlib import Path from pathlib import Path
import pytest_asyncio 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["BOT_TOKEN"] = "123456:TEST_TOKEN_FOR_ISOLATED_TESTS"
os.environ["ADMIN_IDS"] = "900001" os.environ["ADMIN_IDS"] = "900001"
os.environ["CASHIER_IDS"] = "900002" 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.database import Base, engine, async_session_maker
from src.core import models from src.core import models
@@ -20,8 +21,18 @@ from src.core import models
@pytest_asyncio.fixture(autouse=True) @pytest_asyncio.fixture(autouse=True)
async def database(): 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: async with engine.begin() as connection:
await connection.run_sync(Base.metadata.drop_all) await connection.run_sync(Base.metadata.drop_all)
await connection.run_sync(Base.metadata.create_all) await connection.run_sync(Base.metadata.create_all)
yield async_session_maker 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() 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.access import get_role
from src.core.database import async_session_maker from src.core.database import async_session_maker
from src.core.models import User 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 src.core.staff_service import StaffError, change_role, get_member
from test_dispatcher import dispatch, event 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(): async def test_revoked_cashier_cannot_continue_open_participant_dialog():
await create_user(819, is_cashier=True) cashier = await create_user(819, is_cashier=True)
await dispatch(event(819, callback_data="cash_add:1")) 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)) await change_role(900001, 819, "user", (False, True))
calls = await dispatch(event(819, text="11-22-33-44-55-66-77")) calls = await dispatch(event(819, text="11-22-33-44-55-66-77"))
assert any("Недостаточно прав" in (getattr(call, "text", "") or "") for call in calls) assert any("Недостаточно прав" in (getattr(call, "text", "") or "") for call in calls)